Audit «Вы искали» search history per TZ 4.1.9.5
TZ §4.1.9.5 requires session-scoped history ("в рамках одной сессии").
Migrate useSearchHistory from localStorage to sessionStorage so history
clears on tab close / page reload.
Add schema-validated get/set/deleteNs helpers to sessionStore in storage.ts
so the hook stays under no-restricted-globals constraints.
Fix hover style in SearchHistory.scss: TZ specifies голубая подложка with
white text/icon on hover — replace the near-white tint with the full blue.
Add TZ §4.1.9.5 assertion tests: session storage target, dedup + bump-to-top,
most-recent-first ordering, item types, empty initial state.
This commit is contained in:
@@ -8,10 +8,10 @@ import { useSearchHistory } from "./useSearchHistory";
|
|||||||
import type { SearchHistoryItem } from "./useSearchHistory";
|
import type { SearchHistoryItem } from "./useSearchHistory";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// localStorage mock
|
// sessionStorage mock (TZ §4.1.9.5: session-scoped — cleared on reload)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function createMockLocalStorage() {
|
function createMockSessionStorage() {
|
||||||
const store = new Map<string, string>();
|
const store = new Map<string, string>();
|
||||||
return {
|
return {
|
||||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||||
@@ -46,16 +46,28 @@ const routeItem: SearchHistoryItem = {
|
|||||||
label: "SVO - JFK",
|
label: "SVO - JFK",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scheduleItem: SearchHistoryItem = {
|
||||||
|
type: "schedule-route",
|
||||||
|
url: "/ru/schedule/SVO-LED-20260601-20260607",
|
||||||
|
label: "SVO — LED",
|
||||||
|
params: {
|
||||||
|
departure: "SVO",
|
||||||
|
arrival: "LED",
|
||||||
|
dateFrom: "20260601",
|
||||||
|
dateTo: "20260607",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("useSearchHistory", () => {
|
describe("useSearchHistory", () => {
|
||||||
let mockStorage: ReturnType<typeof createMockLocalStorage>;
|
let mockStorage: ReturnType<typeof createMockSessionStorage>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockStorage = createMockLocalStorage();
|
mockStorage = createMockSessionStorage();
|
||||||
vi.stubGlobal("localStorage", mockStorage);
|
vi.stubGlobal("sessionStorage", mockStorage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts with empty history when nothing in storage", () => {
|
it("starts with empty history when nothing in storage", () => {
|
||||||
@@ -100,7 +112,8 @@ describe("useSearchHistory", () => {
|
|||||||
expect(result.current.items).toEqual([routeItem, flightItem]);
|
expect(result.current.items).toEqual([routeItem, flightItem]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deduplicates by URL — skips if most recent matches", () => {
|
// TZ §4.1.9.5: dedup — same search must not appear twice
|
||||||
|
it("4.1.9.5-R: deduplicates by URL — skips if most recent matches", () => {
|
||||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -113,7 +126,8 @@ describe("useSearchHistory", () => {
|
|||||||
expect(result.current.items).toEqual([flightItem]);
|
expect(result.current.items).toEqual([flightItem]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves duplicate to front when URL exists in older position", () => {
|
// TZ §4.1.9.5: re-running a saved search pushes it to top
|
||||||
|
it("4.1.9.5-R: re-running an older search moves it to top (dedup + bump)", () => {
|
||||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -190,4 +204,62 @@ describe("useSearchHistory", () => {
|
|||||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||||
expect(result.current.items).toEqual([]);
|
expect(result.current.items).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TZ §4.1.9.5: session-scoped — sessionStorage, NOT localStorage
|
||||||
|
it("4.1.9.5-R: writes to sessionStorage, not localStorage", () => {
|
||||||
|
const localStorageMock = createMockSessionStorage();
|
||||||
|
vi.stubGlobal("localStorage", localStorageMock);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.add(flightItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
// sessionStorage.setItem must have been called with the history key
|
||||||
|
expect(mockStorage.setItem).toHaveBeenCalledWith(
|
||||||
|
"afl_history_ru",
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
// localStorage must NOT have been touched for history writes
|
||||||
|
expect(localStorageMock.setItem).not.toHaveBeenCalledWith(
|
||||||
|
"afl_history_ru",
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TZ §4.1.9.5: supports board-route, schedule-route, flight-number types
|
||||||
|
it("4.1.9.5-R: stores schedule-route items correctly", () => {
|
||||||
|
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.add(scheduleItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.items[0]).toMatchObject({
|
||||||
|
type: "schedule-route",
|
||||||
|
params: { departure: "SVO", arrival: "LED" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// TZ §4.1.9.5: ordering — most-recent first
|
||||||
|
it("4.1.9.5-R: items are ordered most-recent first", () => {
|
||||||
|
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.add(flightItem);
|
||||||
|
result.current.add(routeItem);
|
||||||
|
result.current.add(scheduleItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.items[0]?.type).toBe("schedule-route");
|
||||||
|
expect(result.current.items[1]?.type).toBe("board-route");
|
||||||
|
expect(result.current.items[2]?.type).toBe("flight-number");
|
||||||
|
});
|
||||||
|
|
||||||
|
// TZ §4.1.9.5: block shows when there are items, hidden when empty
|
||||||
|
it("4.1.9.5-R: returns empty array initially (block starts hidden)", () => {
|
||||||
|
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||||
|
expect(result.current.items).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Search history hook backed by localStorage via `@/shared/storage`.
|
* Search history hook backed by sessionStorage via `@/shared/storage`.
|
||||||
|
*
|
||||||
|
* Session-scoped per TZ §4.1.9.5: "last searches within one session".
|
||||||
|
* Cleared on tab close / page reload — never persists across sessions.
|
||||||
|
* History is namespaced per language so switching locales shows the
|
||||||
|
* appropriate history.
|
||||||
*
|
*
|
||||||
* Persists search history per language with namespaced storage keys.
|
|
||||||
* Angular equivalent: `SearchHistoryService` (ClientApp/src/app/shared/
|
* Angular equivalent: `SearchHistoryService` (ClientApp/src/app/shared/
|
||||||
* services/history/search-history.service.ts), but enhanced with
|
* services/history/search-history.service.ts).
|
||||||
* localStorage persistence and per-language namespacing.
|
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { storage } from "@/shared/storage.js";
|
import { sessionStore } from "@/shared/storage.js";
|
||||||
|
|
||||||
// Custom event fired whenever any hook instance mutates search history.
|
// Custom event fired whenever any hook instance mutates search history.
|
||||||
// Other live hook instances listen for it and re-read from storage so
|
// Other live hook instances listen for it and re-read from storage so
|
||||||
@@ -124,7 +127,7 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult {
|
|||||||
const key = storageKey(lang);
|
const key = storageKey(lang);
|
||||||
|
|
||||||
const [items, setItems] = useState<SearchHistoryItem[]>(() => {
|
const [items, setItems] = useState<SearchHistoryItem[]>(() => {
|
||||||
return (storage.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[];
|
return (sessionStore.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-read storage whenever any instance broadcasts a change. Covers
|
// Re-read storage whenever any instance broadcasts a change. Covers
|
||||||
@@ -135,7 +138,7 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult {
|
|||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const handler = (): void => {
|
const handler = (): void => {
|
||||||
const fresh =
|
const fresh =
|
||||||
(storage.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[];
|
(sessionStore.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[];
|
||||||
setItems(fresh);
|
setItems(fresh);
|
||||||
};
|
};
|
||||||
window.addEventListener(SEARCH_HISTORY_CHANGED_EVENT, handler);
|
window.addEventListener(SEARCH_HISTORY_CHANGED_EVENT, handler);
|
||||||
@@ -154,7 +157,7 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult {
|
|||||||
const filtered = prev.filter((existing) => existing.url !== item.url);
|
const filtered = prev.filter((existing) => existing.url !== item.url);
|
||||||
const next = [item, ...filtered].slice(0, MAX_HISTORY_ITEMS);
|
const next = [item, ...filtered].slice(0, MAX_HISTORY_ITEMS);
|
||||||
|
|
||||||
storage.set(key, next, searchHistorySchema);
|
sessionStore.set(key, next, searchHistorySchema);
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.dispatchEvent(new Event(SEARCH_HISTORY_CHANGED_EVENT));
|
window.dispatchEvent(new Event(SEARCH_HISTORY_CHANGED_EVENT));
|
||||||
}
|
}
|
||||||
@@ -165,7 +168,7 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
storage.delete(key);
|
sessionStore.deleteNs(key);
|
||||||
setItems([]);
|
setItems([]);
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.dispatchEvent(new Event(SEARCH_HISTORY_CHANGED_EVENT));
|
window.dispatchEvent(new Event(SEARCH_HISTORY_CHANGED_EVENT));
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ export const storage = {
|
|||||||
* Exists so the rest of the codebase can stay under the
|
* Exists so the rest of the codebase can stay under the
|
||||||
* `no-restricted-globals` rule that blocks direct sessionStorage access.
|
* `no-restricted-globals` rule that blocks direct sessionStorage access.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const SESSION_PREFIX = "afl_";
|
||||||
|
|
||||||
export const sessionStore = {
|
export const sessionStore = {
|
||||||
getRaw(key: string): string | null {
|
getRaw(key: string): string | null {
|
||||||
if (typeof sessionStorage === "undefined") return null;
|
if (typeof sessionStorage === "undefined") return null;
|
||||||
@@ -88,4 +91,28 @@ export const sessionStore = {
|
|||||||
// ignore disabled
|
// ignore disabled
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Schema-validated get from sessionStorage under the `afl_` namespace. */
|
||||||
|
get<T>(key: string, schema: import("zod").ZodSchema<T>): T | null {
|
||||||
|
try {
|
||||||
|
const raw = this.getRaw(SESSION_PREFIX + key);
|
||||||
|
if (raw === null) return null;
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
const result = schema.safeParse(parsed);
|
||||||
|
return result.success ? result.data : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Schema-validated set into sessionStorage under the `afl_` namespace. */
|
||||||
|
set<T>(key: string, value: T, schema: import("zod").ZodSchema<T>): void {
|
||||||
|
schema.parse(value);
|
||||||
|
this.setRaw(SESSION_PREFIX + key, JSON.stringify(value));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Delete a key from the `afl_` namespace in sessionStorage. */
|
||||||
|
deleteNs(key: string): void {
|
||||||
|
this.delete(SESSION_PREFIX + key);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,14 +80,17 @@
|
|||||||
border-top: 1px solid colors.$border;
|
border-top: 1px solid colors.$border;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
// Angular highlights search-history rows with $blue-extra-light
|
// TZ §4.1.9.5: hover → голубая подложка, текст и иконка становятся белыми.
|
||||||
// (#f3f9ff) on hover — same tint as the frame background.
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: colors.$blue-extra-light;
|
background-color: colors.$blue;
|
||||||
|
color: colors.$white;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover .search-history-item__icon {
|
&:hover .search-history-item__icon,
|
||||||
color: colors.$blue;
|
&:hover .search-history-item__title,
|
||||||
|
&:hover .search-history-item__city,
|
||||||
|
&:hover .search-history-item__date {
|
||||||
|
color: colors.$white;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
|
|||||||
Reference in New Issue
Block a user