Add useDictionaries hook wiring api + transform

This commit is contained in:
2026-04-17 03:16:42 +03:00
parent e575c1baa1
commit 715b09fd18
2 changed files with 149 additions and 0 deletions
@@ -0,0 +1,108 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { useDictionaries } from "./useDictionaries.js";
import { ApiClientProvider } from "@/shared/api/provider.js";
import type { ApiClient } from "@/shared/api/client.js";
import type { IRawDictionaries } from "./types.js";
const raw: IRawDictionaries = {
regions: [{ world_region_id: 500374, title: { ru: "Россия" } }],
countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }],
cities: [
{ code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } },
],
airports: [
{ code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } },
],
};
function makeClient(
impl: (path: string) => Promise<unknown>,
): ApiClient {
return { get: vi.fn(impl) } as unknown as ApiClient;
}
function wrapper(client: ApiClient) {
return ({ children }: { children: ReactNode }) => (
<ApiClientProvider client={client}>{children}</ApiClientProvider>
);
}
describe("useDictionaries", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("starts in a loading state with no data and no error", () => {
const client = makeClient(async () => []);
const { result } = renderHook(() => useDictionaries("ru"), {
wrapper: wrapper(client),
});
expect(result.current.loading).toBe(true);
expect(result.current.dictionaries).toBeNull();
expect(result.current.error).toBeNull();
});
it("resolves to a populated IDictionaries after fetch", async () => {
const client = makeClient(async (path: string) => {
if (path.includes("world_regions")) return raw.regions;
if (path.includes("countries")) return raw.countries;
if (path.includes("cities")) return raw.cities;
if (path.includes("airports")) return raw.airports;
throw new Error("unexpected");
});
const { result } = renderHook(() => useDictionaries("ru"), {
wrapper: wrapper(client),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.dictionaries).not.toBeNull();
expect(result.current.dictionaries!.cities.map((c) => c.code)).toEqual(["MOW"]);
expect(result.current.error).toBeNull();
});
it("sets error and keeps dictionaries null on fetch rejection", async () => {
const client = makeClient(async () => {
throw new Error("boom");
});
const { result } = renderHook(() => useDictionaries("ru"), {
wrapper: wrapper(client),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.dictionaries).toBeNull();
expect(result.current.error?.message).toBe("boom");
});
it("does not call setState after unmount during an in-flight fetch", async () => {
let resolveFetch: (v: unknown) => void = () => {};
const deferred = new Promise((r) => {
resolveFetch = r;
});
const client = makeClient(async () => deferred);
const warn = vi.spyOn(console, "error").mockImplementation(() => {});
const { unmount } = renderHook(() => useDictionaries("ru"), {
wrapper: wrapper(client),
});
unmount();
resolveFetch([]);
await Promise.resolve();
await Promise.resolve();
expect(warn).not.toHaveBeenCalledWith(
expect.stringContaining("unmounted"),
);
warn.mockRestore();
});
});
@@ -0,0 +1,41 @@
/**
* Loads the four dictionary endpoints and returns the transformed, ready-to-use
* IDictionaries object. Mounts once per session (the map is client-only and
* only mounts once). Not intended for multiple concurrent consumers.
*/
import { useEffect, useState } from "react";
import { useApiClient } from "@/shared/api/provider.js";
import { fetchDictionaries } from "./api.js";
import { transformDictionaries } from "./transform.js";
import type { IDictionariesState } from "./types.js";
export function useDictionaries(lang: string): IDictionariesState {
const client = useApiClient();
const [state, setState] = useState<IDictionariesState>({
dictionaries: null,
loading: true,
error: null,
});
useEffect(() => {
let cancelled = false;
fetchDictionaries(client)
.then((raw) => {
if (cancelled) return;
const dictionaries = transformDictionaries(raw, lang);
setState({ dictionaries, loading: false, error: null });
})
.catch((err: Error) => {
if (cancelled) return;
setState({ dictionaries: null, loading: false, error: err });
});
return () => {
cancelled = true;
};
}, [client, lang]);
return state;
}