Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-byline-search-debounce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/admin": patch
---

Fixes the byline search box reloading the whole page on every keystroke. The search term is now debounced (300ms) before it feeds the bylines query, and the full-page loader only takes over when there is no data yet (`isLoading && !data`) instead of on every new query key. Typing now stays responsive and keeps the input focused, matching the behaviour of the users page. The load-more snapshot and its filter-match check both use the debounced search value so appended pages are no longer discarded.
21 changes: 16 additions & 5 deletions packages/admin/src/routes/bylines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type UserListItem,
} from "../lib/api";
import { fetchManifest } from "../lib/api/client.js";
import { useDebouncedValue } from "../lib/hooks.js";

interface BylineFormState {
slug: string;
Expand Down Expand Up @@ -86,6 +87,10 @@ export function BylinesPage() {
const navigate = useNavigate();
const { locale: routeLocale } = useSearch({ from: "/_admin/bylines" });
const [search, setSearch] = React.useState("");
// Debounce the search before it feeds the query key/fetch so typing stays
// responsive — the input stays bound to raw `search` while only the
// debounced value drives refetches.
const debouncedSearch = useDebouncedValue(search, 300);
const [guestFilter, setGuestFilter] = React.useState<"all" | "guest" | "linked">("all");
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
Expand Down Expand Up @@ -118,10 +123,10 @@ export function BylinesPage() {
};

const { data, isLoading, error } = useQuery({
queryKey: ["bylines", search, guestFilter, activeLocale ?? null],
queryKey: ["bylines", debouncedSearch, guestFilter, activeLocale ?? null],
queryFn: () =>
fetchBylines({
search: search || undefined,
search: debouncedSearch || undefined,
isGuest: guestFilter === "all" ? undefined : guestFilter === "guest",
locale: activeLocale,
limit: 50,
Expand Down Expand Up @@ -158,7 +163,13 @@ export function BylinesPage() {
return { result, snapshot };
},
onSuccess: ({ result, snapshot }) => {
if (!loadMoreSnapshotMatches(snapshot, { search, guestFilter, locale: activeLocale })) {
if (
!loadMoreSnapshotMatches(snapshot, {
search: debouncedSearch,
guestFilter,
locale: activeLocale,
})
) {
return;
}
setAllItems((prev) => [...prev, ...result.items]);
Expand Down Expand Up @@ -275,7 +286,7 @@ export function BylinesPage() {
},
});

if (isLoading) {
if (isLoading && !data) {
return (
<div className="flex items-center justify-center min-h-[30vh]">
<Loader />
Expand Down Expand Up @@ -370,7 +381,7 @@ export function BylinesPage() {
className="w-full mt-2"
onClick={() =>
loadMoreMutation.mutate({
search,
search: debouncedSearch,
guestFilter,
locale: activeLocale,
cursor: nextCursor,
Expand Down
92 changes: 92 additions & 0 deletions packages/admin/tests/routes/bylines.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";

import { fetchBylines } from "../../src/lib/api";
import { BylinesPage } from "../../src/routes/bylines";
import { render } from "../utils/render.tsx";
import { QueryWrapper } from "../utils/test-helpers.tsx";

// The bylines page reads the active locale from the URL and navigates on
// locale switches; neither matters for the search-debounce behaviour, so we
// stub the router hooks to a single-locale, no-op shape.
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useSearch: () => ({}),
};
});

// fetchManifest is imported from the client module directly.
vi.mock("../../src/lib/api/client.js", async () => {
const actual = await vi.importActual("../../src/lib/api/client.js");
return {
...actual,
fetchManifest: vi.fn().mockResolvedValue({}),
};
});

vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
fetchBylines: vi.fn(),
fetchUsers: vi.fn().mockResolvedValue({ items: [] }),
fetchByline: vi.fn().mockResolvedValue(null),
fetchBylineTranslations: vi.fn().mockResolvedValue({ items: [] }),
};
});

const fetchBylinesMock = vi.mocked(fetchBylines);

function searchArgs(): (string | undefined)[] {
return fetchBylinesMock.mock.calls.map((call) => call[0]?.search);
}

describe("BylinesPage search", () => {
beforeEach(() => {
vi.clearAllMocks();
fetchBylinesMock.mockResolvedValue({ items: [], nextCursor: undefined });
});

it("debounces rapid typing into a single refetch and keeps the input mounted", async () => {
vi.useFakeTimers();
try {
const screen = await render(
<QueryWrapper>
<BylinesPage />
</QueryWrapper>,
);

// Let the initial bylines query resolve so the full-page loader
// gate (isLoading && !data) clears and the list view renders.
await vi.advanceTimersByTimeAsync(0);
expect(searchArgs()).toEqual([undefined]);

const input = screen.getByPlaceholder("Search bylines");
await expect.element(input).toBeInTheDocument();

// Three keystrokes in quick succession (under the 300ms window).
await input.fill("a");
await input.fill("al");
await input.fill("ali");

// No new fetch yet: the debounce has not elapsed, and the input
// must stay mounted/focused rather than being unmounted by a
// full-page loader takeover on every keystroke.
expect(searchArgs()).toEqual([undefined]);
await expect.element(input).toHaveValue("ali");

// After the debounce window, exactly one additional refetch fires
// for the final value — not one per intermediate keystroke.
await vi.advanceTimersByTimeAsync(300);
expect(searchArgs()).toEqual([undefined, "ali"]);

Check failure on line 84 in packages/admin/tests/routes/bylines.test.tsx

View workflow job for this annotation

GitHub Actions / Browser Tests

[chromium] tests/routes/bylines.test.tsx > BylinesPage search > debounces rapid typing into a single refetch and keeps the input mounted

AssertionError: expected [ undefined ] to deeply equal [ undefined, 'ali' ] - Expected + Received [ undefined, - "ali", ] ❯ toEqual tests/routes/bylines.test.tsx:84:24

// The list view (and its search input) is still mounted.
await expect.element(screen.getByPlaceholder("Search bylines")).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
});
Loading