diff --git a/src/app/services/page.test.tsx b/src/app/services/page.test.tsx index 3e91d64..265d680 100644 --- a/src/app/services/page.test.tsx +++ b/src/app/services/page.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen, waitFor, act } from "@testing-library/react"; import { apiGet } from "../../lib/apiClient"; -import ServicesPage, { sortServices } from "./page"; +import ServicesPage, { filterServices, readServicesViewState, servicesViewSearch, sortServices } from "./page"; import { ToastProvider } from "../../components/ToastProvider"; import { truncateMiddle } from "../../lib/format"; @@ -248,6 +248,46 @@ describe("sortServices", () => { }); }); + +describe("services table view helpers", () => { + it("filters service IDs case-insensitively and applies the price enum", () => { + const services = [ + service("Alpha-Free", 0), + service("alpha-paid", 25), + service("beta-paid", 50), + ]; + + expect( + filterServices(services, "ALPHA", "paid")?.map((item) => item.serviceId) + ).toEqual(["alpha-paid"]); + expect( + filterServices(services, "", "free")?.map((item) => item.serviceId) + ).toEqual(["Alpha-Free"]); + }); + + it("normalizes invalid URL state and serializes only canonical view state", () => { + expect( + readServicesViewState("?page=-2&sort=bogus&dir=desc&price=bogus&q=alpha") + ).toEqual({ + page: 1, + sortKey: "", + sortDir: "asc", + query: "alpha", + priceFilter: "all", + }); + + expect( + servicesViewSearch({ + page: 3, + sortKey: "price", + sortDir: "desc", + query: "alpha beta", + priceFilter: "paid", + }) + ).toBe("?page=3&sort=price&dir=desc&q=alpha+beta&price=paid"); + }); +}); + describe("ServicesPage", () => { beforeEach(() => { apiGetMock.mockReset(); @@ -353,6 +393,115 @@ describe("ServicesPage", () => { ]); }); + it("cycles stable column sorting and keeps the canonical sort in the URL", async () => { + apiGetMock.mockResolvedValueOnce({ + services: [ + service("svc-expensive", 300), + service("svc-cheap", 100), + ], + page: 1, + pageCount: 1, + } as never); + + renderServicesPage(); + await screen.findByRole("link", { name: /svc-expensive/i }); + + const priceSort = screen.getByRole("button", { name: /sort by price/i }); + fireEvent.click(priceSort); + + expect(window.location.search).toBe("?sort=price&dir=asc"); + let serviceLinks = screen + .getAllByRole("link") + .filter((link) => link.getAttribute("href") !== "/services/new"); + expect(serviceLinks.map((link) => link.getAttribute("href"))).toEqual([ + "/services/svc-cheap", + "/services/svc-expensive", + ]); + + fireEvent.click(priceSort); + expect(window.location.search).toBe("?sort=price&dir=desc"); + serviceLinks = screen + .getAllByRole("link") + .filter((link) => link.getAttribute("href") !== "/services/new"); + expect(serviceLinks.map((link) => link.getAttribute("href"))).toEqual([ + "/services/svc-expensive", + "/services/svc-cheap", + ]); + + fireEvent.click(priceSort); + expect(window.location.search).toBe(""); + }); + + it("debounces the text filter, narrows rows, and syncs the URL", async () => { + apiGetMock.mockResolvedValueOnce({ + services: [ + service("svc-alpha", 10), + service("svc-beta", 20), + ], + page: 1, + pageCount: 1, + } as never); + + renderServicesPage(); + await screen.findByRole("link", { name: /svc-alpha/i }); + + fireEvent.change(screen.getByRole("searchbox", { name: /filter services/i }), { + target: { value: "beta" }, + }); + + await waitFor( + () => { + expect(window.location.search).toBe("?q=beta"); + expect(screen.queryByRole("link", { name: /svc-alpha/i })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: /svc-beta/i })).toBeInTheDocument(); + }, + { timeout: 1000 } + ); + }); + + it("restores page, filters, and sort state from a shareable URL", async () => { + window.history.replaceState( + null, + "", + "/services?page=2&sort=price&dir=desc&q=beta&price=paid" + ); + apiGetMock.mockResolvedValueOnce({ + services: [ + service("beta-paid", 30), + service("beta-free", 0), + service("alpha-paid", 40), + ], + page: 2, + pageCount: 3, + } as never); + + renderServicesPage(); + + await waitFor(() => { + expect(apiGetMock).toHaveBeenCalledWith("/api/v1/services?page=2&limit=25"); + }); + expect(screen.getByRole("searchbox", { name: /filter services/i })).toHaveValue("beta"); + expect(screen.getByRole("combobox", { name: /price type/i })).toHaveValue("paid"); + expect(screen.getByRole("link", { name: /beta-paid/i })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /beta-free/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /alpha-paid/i })).not.toBeInTheDocument(); + expect(screen.getByText("Page 2 of 3")).toBeInTheDocument(); + }); + + it("shows a distinct filtered-empty state without claiming the dataset is empty", async () => { + window.history.replaceState(null, "", "/services?q=missing"); + apiGetMock.mockResolvedValueOnce({ + services: [service("svc-present", 10)], + page: 1, + pageCount: 1, + } as never); + + renderServicesPage(); + + expect(await screen.findByText("No services match these filters.")).toBeInTheDocument(); + expect(screen.queryByText(/No services registered yet/i)).not.toBeInTheDocument(); + }); + it("shows pagination only when there are multiple pages and refetches when Next is clicked", async () => { apiGetMock .mockResolvedValueOnce({ @@ -378,6 +527,7 @@ describe("ServicesPage", () => { }); expect(await screen.findByText("Page 2 of 2")).toBeInTheDocument(); + expect(window.location.search).toBe("?page=2"); expect(screen.getByRole("link", { name: /svc-b/i })).toHaveAttribute( "href", "/services/svc-b" diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 75dd6ed..ee43b7f 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -22,24 +22,77 @@ type ServicesResponse = { type SortKey = "name" | "price" | "created" | ""; type SortDir = "asc" | "desc"; +type PriceFilter = "all" | "free" | "paid"; +type ViewState = { + page: number; + sortKey: SortKey; + sortDir: SortDir; + query: string; + priceFilter: PriceFilter; +}; const PAGE_SIZE = 25; -const VALID_SORT_KEYS: SortKey[] = ["name", "price", "created"]; +const FILTER_DEBOUNCE_MS = 300; +const VALID_SORT_KEYS: Exclude[] = ["name", "price", "created"]; +const VALID_PRICE_FILTERS: PriceFilter[] = ["all", "free", "paid"]; -const COLUMNS: { key: SortKey; label: string }[] = [ +const COLUMNS: { key: Exclude; label: string }[] = [ { key: "name", label: "Name" }, { key: "price", label: "Price" }, { key: "created", label: "Created" }, ]; -function getInitialSort(): { key: SortKey; dir: SortDir } { - if (typeof window === "undefined") return { key: "", dir: "asc" }; - const params = new URLSearchParams(window.location.search); - const s = params.get("sort") as SortKey | null; - const d = params.get("dir") as SortDir | null; - const key = s && (VALID_SORT_KEYS as string[]).includes(s) ? s : ""; - const dir = d === "asc" || d === "desc" ? d : "asc"; - return { key, dir }; +function parsePage(value: string | null): number { + if (value === null) return 1; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 1; +} + +export function readServicesViewState( + search = typeof window === "undefined" ? "" : window.location.search +): ViewState { + const params = new URLSearchParams(search); + const rawSort = params.get("sort"); + const sortKey = VALID_SORT_KEYS.includes(rawSort as Exclude) + ? (rawSort as Exclude) + : ""; + const rawDir = params.get("dir"); + const sortDir: SortDir = + sortKey && (rawDir === "asc" || rawDir === "desc") ? rawDir : "asc"; + const rawPrice = params.get("price"); + const priceFilter = VALID_PRICE_FILTERS.includes(rawPrice as PriceFilter) + ? (rawPrice as PriceFilter) + : "all"; + + return { + page: parsePage(params.get("page")), + sortKey, + sortDir, + query: params.get("q") ?? "", + priceFilter, + }; +} + +export function servicesViewSearch(state: ViewState): string { + const params = new URLSearchParams(); + if (state.page > 1) params.set("page", String(state.page)); + if (state.sortKey) { + params.set("sort", state.sortKey); + params.set("dir", state.sortDir); + } + if (state.query) params.set("q", state.query); + if (state.priceFilter !== "all") params.set("price", state.priceFilter); + const search = params.toString(); + return search ? "?" + search : ""; +} + +function replaceServicesViewUrl(state: ViewState) { + if (typeof window === "undefined") return; + window.history.replaceState( + null, + "", + window.location.pathname + servicesViewSearch(state) + window.location.hash + ); } function getAriaSort(key: SortKey, sortKey: SortKey, sortDir: SortDir): "ascending" | "descending" | "none" { @@ -112,15 +165,47 @@ export function sortServices( .map(({ service }) => service); } -function useSorted( +export function filterServices( services: Service[] | null, + query: string, + priceFilter: PriceFilter +): Service[] | null { + if (!services) return services; + const normalizedQuery = query.trim().toLocaleLowerCase(); + + return services.filter((service) => { + const textMatches = + normalizedQuery.length === 0 || + service.serviceId.toLocaleLowerCase().includes(normalizedQuery); + const priceMatches = + priceFilter === "all" || + (priceFilter === "free" + ? service.priceStroops === 0 + : service.priceStroops > 0); + return textMatches && priceMatches; + }); +} + +function useVisibleServices( + services: Service[] | null, + query: string, + priceFilter: PriceFilter, sortKey: SortKey, sortDir: SortDir ): Service[] | null { - return useMemo( - () => sortServices(services, sortKey, sortDir), - [services, sortKey, sortDir] - ); + return useMemo(() => { + const filtered = filterServices(services, query, priceFilter); + return sortServices(filtered, sortKey, sortDir); + }, [services, query, priceFilter, sortKey, sortDir]); +} + +function formatCreatedAt(createdAt?: number | string | null) { + if (createdAt == null) return "—"; + const numeric = Number(createdAt); + const date = Number.isFinite(numeric) + ? new Date(numeric) + : new Date(String(createdAt)); + return Number.isNaN(date.getTime()) ? "—" : date.toLocaleDateString(); } export function ServiceCopyButton({ serviceId }: { serviceId: string }) { @@ -178,40 +263,100 @@ export function ServiceCopyButton({ serviceId }: { serviceId: string }) { } export default function ServicesPage() { + const initialView = useMemo(() => readServicesViewState(), []); const [services, setServices] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); - const [page, setPage] = useState(1); - const [requestedPage, setRequestedPage] = useState(1); + const [page, setPage] = useState(initialView.page); + const [requestedPage, setRequestedPage] = useState(initialView.page); const [pageCount, setPageCount] = useState(1); - const [{ key: sortKey, dir: sortDir }, setSort] = useState<{ key: SortKey; dir: SortDir }>( - getInitialSort + const [view, setView] = useState(initialView); + const [filterDraft, setFilterDraft] = useState(initialView.query); + + const visibleServices = useVisibleServices( + services, + view.query, + view.priceFilter, + view.sortKey, + view.sortDir ); - const sortedServices = useSorted(services, sortKey, sortDir); - - const handleSort = (key: SortKey) => { - const next: { key: SortKey; dir: SortDir } = - key === sortKey - ? { key, dir: sortDir === "asc" ? "desc" : "asc" } - : { key, dir: "asc" }; - setSort(next); - const params = new URLSearchParams(window.location.search); - params.set("sort", next.key); - params.set("dir", next.dir); - window.history.replaceState(null, "", `?${params.toString()}`); + const handleSort = (key: Exclude) => { + let sortKey: SortKey = key; + let sortDir: SortDir = "asc"; + + if (view.sortKey === key && view.sortDir === "asc") { + sortDir = "desc"; + } else if (view.sortKey === key && view.sortDir === "desc") { + sortKey = ""; + } + + const next = { ...view, sortKey, sortDir }; + setView(next); + replaceServicesViewUrl(next); }; useEffect(() => { const handlePopState = () => { - const { key, dir } = getInitialSort(); - setSort({ key, dir }); + const next = readServicesViewState(); + setView(next); + setFilterDraft(next.query); + setLoading(true); + setError(null); + setServices(null); + setRequestedPage(next.page); }; window.addEventListener("popstate", handlePopState); return () => window.removeEventListener("popstate", handlePopState); }, []); + useEffect(() => { + if (filterDraft === view.query) return; + + const timeout = window.setTimeout(() => { + const next = { ...view, query: filterDraft, page: 1 }; + setView(next); + replaceServicesViewUrl(next); + if (requestedPage !== 1) { + setLoading(true); + setError(null); + setServices(null); + setRequestedPage(1); + } + }, FILTER_DEBOUNCE_MS); + + return () => window.clearTimeout(timeout); + }, [filterDraft, requestedPage, view]); + + const handlePriceFilter = (priceFilter: PriceFilter) => { + const next = { ...view, priceFilter, page: 1 }; + setView(next); + replaceServicesViewUrl(next); + if (requestedPage !== 1) { + setLoading(true); + setError(null); + setServices(null); + setRequestedPage(1); + } + }; + + const clearFilters = () => { + setFilterDraft(""); + const next = { ...view, query: "", priceFilter: "all" as PriceFilter, page: 1 }; + setView(next); + replaceServicesViewUrl(next); + if (requestedPage !== 1) { + setLoading(true); + setError(null); + setServices(null); + setRequestedPage(1); + } + }; + const onPageChange = (nextPage: number) => { + const next = { ...view, page: nextPage }; + setView(next); + replaceServicesViewUrl(next); setLoading(true); setError(null); setServices(null); @@ -237,6 +382,13 @@ export default function ServicesPage() { setServices(nextServices); setPageCount(nextPageCount); setPage(nextPage); + if (nextPage !== requestedPage) { + setView((current) => { + const next = { ...current, page: nextPage }; + replaceServicesViewUrl(next); + return next; + }); + } }) .catch((e) => { if (cancelled) return; @@ -266,11 +418,52 @@ export default function ServicesPage() { + +
+ + + +
+ {loading && (
)} + {!loading && services && services.length === 0 && ( )} - {!loading && services && services.length > 0 && ( -
    - {sortedServices?.map((s) => ( -
  • 0 && + visibleServices && + visibleServices.length === 0 && ( +
    +

    No services match these filters.

    +

    + Clear or adjust the current filters to see this page's services. +

    +
    + )} + + {!loading && visibleServices && visibleServices.length > 0 && ( +
    + + + + {COLUMNS.map((column) => ( + + ))} + + + + + {visibleServices.map((service) => ( + - {truncateMiddle(s.serviceId)} - - - {s.priceStroops} stroops / request - - - - - ))} - + + + + + + ))} + +
    + + + Actions +
    + + {truncateMiddle(service.serviceId)} + + {" " + service.priceStroops + " stroops / request"} + + + + {service.priceStroops} stroops / request + + {formatCreatedAt(service.createdAt)} + + +
    +
    )} - {!loading && !error && ( + + {!loading && !error && services && services.length > 0 && (