From 42964f4fef2abd326957b9456a8793d2fc972918 Mon Sep 17 00:00:00 2001 From: Dennis Alund Date: Thu, 27 Aug 2026 06:53:20 +0800 Subject: [PATCH 1/4] Name the search query in the links empty state Issue #52 reported a search with no matches rendering "No links yet. Use the + New Link button above to get started." on a catalog holding 200 links. That half is already gone: 4e5c81f gave the service a three-value emptyReason, so a search that matches nothing now resolves to no-matches rather than falling through to the first-run copy. What survived is the other half of the report. no-matches carries two different claims. A filter chip selected none of the catalog, or a search matched none of it, and both rendered "No links match the current filter." Searching zzz blamed a filter the user never touched and never said which query came back empty. The service cannot tell the two apart in copy, since it hands back one reason and the page owns the query. So the split lands in the page: emptyStateCopy takes the reason and the query, and picks links.noSearchMatches with the query interpolated whenever a search is what emptied the window. Pulling the choice out of the JSX also stops a fourth arm from growing the ternary another level. links.noSearchMatches goes into en.ts, id.ts and sv.ts. Translations is typed off en, so the other two locales cannot lag behind it. The query reaches the copy as a t() parameter and hono/jsx escapes string children, so markup in the search box renders as text. A test pins that rather than trusting it. --- src/__tests__/page/links-page.test.ts | 41 +++++++++++++++++++++++++++ src/i18n/en.ts | 1 + src/i18n/id.ts | 1 + src/i18n/sv.ts | 1 + src/pages/links.tsx | 28 +++++++++++++----- 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/__tests__/page/links-page.test.ts b/src/__tests__/page/links-page.test.ts index 60ca73c..661cb78 100644 --- a/src/__tests__/page/links-page.test.ts +++ b/src/__tests__/page/links-page.test.ts @@ -648,6 +648,47 @@ describe("Links listing page windowing", () => { expect(html).not.toContain("Show disabled"); }); + it("names the query in the empty state when a search matched nothing", async () => { + for (const slug of ["one", "two", "three"]) { + await LinkRepository.create(env.DB, { url: `https://example.com/${slug}`, slug }); + } + const html = await ( + await SELF.fetch(req("/_/admin/links?search=zzz", { headers: { Cookie: "lang=en" } })) + ).text(); + + // The catalog is not empty and no filter chip emptied it: the search did. + expect(emptyState(html)).toContain("zzz"); + expect(emptyState(html)).not.toContain("current filter"); + expect(emptyState(html)).not.toContain("No links yet"); + }); + + it("keeps the filter wording when a filter, not a search, emptied the list", async () => { + for (const slug of ["one", "two", "three"]) { + await LinkRepository.create(env.DB, { url: `https://example.com/${slug}`, slug }); + } + const html = await ( + await SELF.fetch(req("/_/admin/links?filter=disabled", { headers: { Cookie: "lang=en" } })) + ).text(); + + expect(emptyState(html)).toContain("current filter"); + }); + + it("escapes markup in a search query before naming it in the empty state", async () => { + for (const slug of ["one", "two", "three"]) { + await LinkRepository.create(env.DB, { url: `https://example.com/${slug}`, slug }); + } + const html = await ( + await SELF.fetch( + req(`/_/admin/links?search=${encodeURIComponent("zzz")}`, { + headers: { Cookie: "lang=en" }, + }), + ) + ).text(); + + expect(emptyState(html)).toContain("<b>zzz</b>"); + expect(emptyState(html)).not.toContain("zzz"); + }); + it("does not print a row count above an empty state", async () => { for (const slug of ["one", "two", "three"]) { await LinkRepository.create(env.DB, { url: `https://example.com/${slug}`, slug }); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 735689d..c9a3a02 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -64,6 +64,7 @@ const en = { "links.allDisabled": 'All links are disabled. Pick the "Disabled" filter to see them.', "links.noMatches": "No links match the current filter.", + "links.noSearchMatches": 'No links match "{query}".', "links.empty": "No links yet. Use the + New Link button above to get started.", "links.disabled": "Disabled", "links.clicks": "clicks", diff --git a/src/i18n/id.ts b/src/i18n/id.ts index d227657..65aae63 100644 --- a/src/i18n/id.ts +++ b/src/i18n/id.ts @@ -66,6 +66,7 @@ const id: Translations = { "links.allDisabled": 'Semua tautan nonaktif. Pilih filter "Nonaktif" untuk melihatnya.', "links.noMatches": "Tidak ada tautan yang cocok dengan filter saat ini.", + "links.noSearchMatches": 'Tidak ada tautan yang cocok dengan "{query}".', "links.empty": "Belum ada tautan. Gunakan tombol + Tautan Baru di atas untuk memulai.", "links.disabled": "Nonaktif", diff --git a/src/i18n/sv.ts b/src/i18n/sv.ts index 4a53b13..b881597 100644 --- a/src/i18n/sv.ts +++ b/src/i18n/sv.ts @@ -66,6 +66,7 @@ const sv: Translations = { "links.allDisabled": 'Alla länkar är avaktiverade. Välj filtret "Avaktiverade" för att se dem.', "links.noMatches": "Inga länkar matchar det valda filtret.", + "links.noSearchMatches": 'Inga länkar matchar "{query}".', "links.empty": "Inga länkar ännu. Använd knappen + Ny länk ovan för att komma igång.", "links.disabled": "Inaktiverad", diff --git a/src/pages/links.tsx b/src/pages/links.tsx index 0cef9e7..61d9f48 100644 --- a/src/pages/links.tsx +++ b/src/pages/links.tsx @@ -121,6 +121,26 @@ export function paginationItems( ]; } +/** + * Copy for an empty result set. + * + * `emptyReason` says why the served window came back with nothing, but its + * `no-matches` value covers two different claims: a filter chip that selected + * none of the catalog, or a search that matched none of it. The service cannot + * tell those apart in copy, so the query splits the case here. Naming the query + * back to the user beats blaming a filter they never touched. + */ +function emptyStateCopy( + t: TranslateFn, + emptyReason: LinksEmptyReason | undefined, + searchQuery: string, +): string { + if (emptyReason === "all-disabled") return t("links.allDisabled"); + if (emptyReason !== "no-matches") return t("links.empty"); + const query = searchQuery.trim(); + return query ? t("links.noSearchMatches", { query }) : t("links.noMatches"); +} + type Props = { /** * Rows for the current page only: the query already applied the filter, the @@ -273,13 +293,7 @@ export const LinksPage: FC = ({ {links.length === 0 ? (
link_off -

- {emptyReason === "all-disabled" - ? t("links.allDisabled") - : emptyReason === "no-matches" - ? t("links.noMatches") - : t("links.empty")} -

+

{emptyStateCopy(t, emptyReason, searchQuery || "")}

) : ( <> From 016a36af5e15acb69eb72499f0f7d2a435b36401 Mon Sep 17 00:00:00 2001 From: Dennis Alund Date: Thu, 27 Aug 2026 07:08:11 +0800 Subject: [PATCH 2/4] Stop a whitespace search from blaming a filter, and pin the copy matrix Review of #55 turned up an empty-state case the first commit left standing. A search of nothing but spaces reaches the repository, which treats a query that trims to empty as matching nothing rather than letting a bare LIKE "%%" match every row. The window comes back empty while the catalog is full, and the reason is then picked from the filter alone: under the default Active chip that renders "All links are disabled. Pick the Disabled filter to see them." over a catalog where nothing is disabled, and under filter=all it renders the blame-the-filter wording this branch set out to remove. The route now trims the query before anything reads it, so a run of spaces is no search at all and the listing renders its rows. Trimming at the route also collapses the two places that tested search.trim(): the service still decides all-disabled vs no-matches, but it can no longer see a query the page would call empty. emptyStateCopy grows an exhaustive switch. The old negative test (emptyReason !== "no-matches") mapped anything unrecognised onto the first-run copy, so a fourth reason would have shipped "No links yet" over a full catalog with no build error. A never-typed default fails the build instead; adding a member to the union was checked to confirm it does. The query is user input pasted into a centred one-paragraph block, so it clips at 60 characters. Escaping already made it safe, this keeps it readable. emptyStateCopy is exported and unit tested alongside pageWindow and paginationItems, which is where the whitespace and long-query cases are cheap to cover. The page tests keep the rendered-HTML cases and now assert the whole sentence rather than the query substring, and they use the seed() helper the enclosing describe already provides. Placeholder parity gets a guard. Translations is typed off en, so a missing key fails the build, but nothing typed the {query} inside a value: a locale could keep the key and drop the interpolation the key exists for. Dropping it from id.ts was checked to confirm the new test fails. --- src/__tests__/page/links-page.test.ts | 31 +++++++++----- src/__tests__/unit/i18n.test.ts | 16 ++++++++ src/__tests__/unit/links-empty-state.test.ts | 43 ++++++++++++++++++++ src/index.tsx | 6 ++- src/pages/links.tsx | 40 ++++++++++++++---- 5 files changed, 116 insertions(+), 20 deletions(-) create mode 100644 src/__tests__/unit/links-empty-state.test.ts diff --git a/src/__tests__/page/links-page.test.ts b/src/__tests__/page/links-page.test.ts index 661cb78..1775cd4 100644 --- a/src/__tests__/page/links-page.test.ts +++ b/src/__tests__/page/links-page.test.ts @@ -649,34 +649,30 @@ describe("Links listing page windowing", () => { }); it("names the query in the empty state when a search matched nothing", async () => { - for (const slug of ["one", "two", "three"]) { - await LinkRepository.create(env.DB, { url: `https://example.com/${slug}`, slug }); - } + await seed(3); const html = await ( await SELF.fetch(req("/_/admin/links?search=zzz", { headers: { Cookie: "lang=en" } })) ).text(); // The catalog is not empty and no filter chip emptied it: the search did. - expect(emptyState(html)).toContain("zzz"); + // Match the whole sentence, so a bare query or an unreplaced placeholder + // sitting next to one cannot pass. + expect(emptyState(html)).toContain("No links match "zzz"."); expect(emptyState(html)).not.toContain("current filter"); expect(emptyState(html)).not.toContain("No links yet"); }); it("keeps the filter wording when a filter, not a search, emptied the list", async () => { - for (const slug of ["one", "two", "three"]) { - await LinkRepository.create(env.DB, { url: `https://example.com/${slug}`, slug }); - } + await seed(3); const html = await ( await SELF.fetch(req("/_/admin/links?filter=disabled", { headers: { Cookie: "lang=en" } })) ).text(); - expect(emptyState(html)).toContain("current filter"); + expect(emptyState(html)).toContain("No links match the current filter."); }); it("escapes markup in a search query before naming it in the empty state", async () => { - for (const slug of ["one", "two", "three"]) { - await LinkRepository.create(env.DB, { url: `https://example.com/${slug}`, slug }); - } + await seed(3); const html = await ( await SELF.fetch( req(`/_/admin/links?search=${encodeURIComponent("zzz")}`, { @@ -689,6 +685,19 @@ describe("Links listing page windowing", () => { expect(emptyState(html)).not.toContain("zzz"); }); + it("treats a whitespace-only search as no search at all", async () => { + // The repository returns nothing for a search that trims to empty, so an + // untrimmed query reaches the empty state and gets attributed to whatever + // the filter happens to be. Under the default Active filter that reads + // "All links are disabled" over a catalog where nothing is disabled. + await seed(3); + for (const url of ["/_/admin/links?search=%20%20", "/_/admin/links?filter=all&search=%20"]) { + const html = await (await SELF.fetch(req(url, { headers: { Cookie: "lang=en" } }))).text(); + expect(emptyState(html)).toBe(""); + expect(rowCount(html)).toBe(3); + } + }); + it("does not print a row count above an empty state", async () => { for (const slug of ["one", "two", "three"]) { await LinkRepository.create(env.DB, { url: `https://example.com/${slug}`, slug }); diff --git a/src/__tests__/unit/i18n.test.ts b/src/__tests__/unit/i18n.test.ts index a119d5c..f53887c 100644 --- a/src/__tests__/unit/i18n.test.ts +++ b/src/__tests__/unit/i18n.test.ts @@ -20,6 +20,11 @@ function flattenKeys(obj: unknown, prefix = ""): string[] { return out; } +/** The `{name}` placeholders a translated string interpolates. */ +function placeholders(value: string): string[] { + return [...(value.match(/\{[a-zA-Z0-9_]+\}/g) ?? [])].sort(); +} + describe("i18n", () => { describe("isSupportedLanguage", () => { it("returns true for supported languages", () => { @@ -57,6 +62,17 @@ describe("i18n", () => { }); }); + describe("placeholder parity", () => { + // Translations is typed off en, so a missing key fails the build. Nothing + // types the placeholders inside a value, so a locale can keep the key and + // silently drop the interpolation the key exists for. + it.each([["id", id], ["sv", sv]] as const)("keeps every %s placeholder that en declares", (_name, locale) => { + for (const key of Object.keys(en) as (keyof typeof en)[]) { + expect(placeholders(locale[key])).toEqual(placeholders(en[key])); + } + }); + }); + describe("createTranslateFn", () => { it("returns translated string for the given language", () => { const t = createTranslateFn("id"); diff --git a/src/__tests__/unit/links-empty-state.test.ts b/src/__tests__/unit/links-empty-state.test.ts new file mode 100644 index 0000000..4e2cf99 --- /dev/null +++ b/src/__tests__/unit/links-empty-state.test.ts @@ -0,0 +1,43 @@ +// Copyright 2026 Oddbit (https://oddbit.id) +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { emptyStateCopy } from "../../pages/links"; +import { createTranslateFn } from "../../i18n"; + +const t = createTranslateFn("en"); + +describe("links empty state copy", () => { + it("names the query when a search emptied the window", () => { + expect(emptyStateCopy(t, "no-matches", "zzz")).toBe('No links match "zzz".'); + }); + + it("blames the filter only when no search is active", () => { + expect(emptyStateCopy(t, "no-matches", "")).toBe("No links match the current filter."); + }); + + it("treats a whitespace-only query as no query", () => { + // A query of spaces empties the window without being something the copy + // can name back to the user. + expect(emptyStateCopy(t, "no-matches", " ")).toBe("No links match the current filter."); + }); + + it("trims the query before naming it", () => { + expect(emptyStateCopy(t, "no-matches", " zzz ")).toBe('No links match "zzz".'); + }); + + it("clamps a long query so the empty state stays one readable line", () => { + const copy = emptyStateCopy(t, "no-matches", "z".repeat(500)); + expect(copy.length).toBeLessThan(120); + expect(copy).toContain("…"); + }); + + it("keeps the all-disabled claim for an expired catalog", () => { + expect(emptyStateCopy(t, "all-disabled", "")).toContain("All links are disabled"); + }); + + it("falls back to the first-run copy for an empty catalog", () => { + expect(emptyStateCopy(t, "no-links", "")).toContain("No links yet"); + expect(emptyStateCopy(t, undefined, "")).toContain("No links yet"); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 6886651..24fd3d0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -220,7 +220,11 @@ app.get("/_/admin/dashboard", async (c) => { app.get("/_/admin/links", async (c) => { const identity = c.var.identity; const { theme, slugLength, t, lang, translations, defaultRange } = await getPageData(c, identity); - const searchQuery = c.req.query("search") || ""; + // Trim before anything reads it. The repository treats a query that trims + // to nothing as matching nothing (a bare LIKE "%%" would match every row), + // so an untrimmed run of spaces empties the window and the empty state + // then blames whichever filter happens to be selected. + const searchQuery = (c.req.query("search") ?? "").trim(); const filters = await resolveClickFilters(c.env, identity); const validRanges = new Set(["24h", "7d", "30d", "90d", "1y", "all"]); const rangeParam = c.req.query("range"); diff --git a/src/pages/links.tsx b/src/pages/links.tsx index 61d9f48..e773859 100644 --- a/src/pages/links.tsx +++ b/src/pages/links.tsx @@ -121,24 +121,48 @@ export function paginationItems( ]; } +/** Longest query the empty state repeats back before it gets clipped. */ +const EMPTY_STATE_QUERY_MAX = 60; + /** * Copy for an empty result set. * * `emptyReason` says why the served window came back with nothing, but its * `no-matches` value covers two different claims: a filter chip that selected - * none of the catalog, or a search that matched none of it. The service cannot - * tell those apart in copy, so the query splits the case here. Naming the query - * back to the user beats blaming a filter they never touched. + * none of the catalog, or a search that matched none of it. The service hands + * back one reason and the page owns the query, so the split lands here. Naming + * the query back to the user beats blaming a filter they never touched. + * + * The query is user input dropped into a centred one-paragraph block. Escaping + * keeps it safe, and clipping keeps a pasted essay from pushing the toolbar and + * the paginator off screen. */ -function emptyStateCopy( +export function emptyStateCopy( t: TranslateFn, emptyReason: LinksEmptyReason | undefined, searchQuery: string, ): string { - if (emptyReason === "all-disabled") return t("links.allDisabled"); - if (emptyReason !== "no-matches") return t("links.empty"); - const query = searchQuery.trim(); - return query ? t("links.noSearchMatches", { query }) : t("links.noMatches"); + switch (emptyReason) { + case "all-disabled": + return t("links.allDisabled"); + case "no-matches": { + const query = searchQuery.trim(); + if (!query) return t("links.noMatches"); + const shown = query.length > EMPTY_STATE_QUERY_MAX + ? `${query.slice(0, EMPTY_STATE_QUERY_MAX)}…` + : query; + return t("links.noSearchMatches", { query: shown }); + } + case "no-links": + case undefined: + return t("links.empty"); + default: { + // A reason added to the union has to pick its own copy here instead of + // inheriting the first-run message by falling through. + const unhandled: never = emptyReason; + return unhandled; + } + } } type Props = { From c8fbe92b1781839b8937d04d6a3fdfb12cb319fe Mon Sep 17 00:00:00 2001 From: Dennis Alund Date: Thu, 27 Aug 2026 07:59:48 +0800 Subject: [PATCH 3/4] Move the search/filter split into the service and name both in the copy Two decisions from the review of #55. The service now owns the split. LinksEmptyReason gains no-search-matches, and listLinksPage raises it wherever the trimmed query is what emptied the window. The page had been re-deriving "did a search happen" from the raw query, one layer above the trim that already answered it, so the two could disagree. The page is now handed the answer. A live search outranks the status filter when picking the reason. The query is the specific thing the copy can name back, and it is the more likely culprit. Everything else is unchanged: an empty catalog is still no-links, an active filter over an expired catalog still all-disabled, and a status filter that selected nothing still no-matches. This renames the assertion in "reports no-matches when a search finds nothing in a populated catalog", which is an existing test. CLAUDE.md forbids editing one to fit a code change, so the conflict went to Dennis first and the rename is deliberate: the value it named no longer describes that case. The copy names both narrowing dimensions. Searching one under the Disabled chip rendered `No links match "one".` while a link matching one sat a chip away, so the branch had swapped blaming the filter for blaming the query. Under active or disabled the copy now names the chip too, and under all, where nothing but the search is hiding rows, it stays as it was. The chip table moves to module scope. The empty state and the chips read their labels from one place, so the filter a user clicks and the filter the copy names cannot drift. --- src/__tests__/page/links-page.test.ts | 36 +++++++++++++-- .../service/link-page-service.test.ts | 18 +++++++- src/__tests__/unit/links-empty-state.test.ts | 40 +++++++++++------ src/i18n/en.ts | 1 + src/i18n/id.ts | 1 + src/i18n/sv.ts | 1 + src/pages/links.tsx | 44 ++++++++++++------- src/services/link-management.ts | 21 ++++++--- 8 files changed, 120 insertions(+), 42 deletions(-) diff --git a/src/__tests__/page/links-page.test.ts b/src/__tests__/page/links-page.test.ts index 1775cd4..8400442 100644 --- a/src/__tests__/page/links-page.test.ts +++ b/src/__tests__/page/links-page.test.ts @@ -654,14 +654,42 @@ describe("Links listing page windowing", () => { await SELF.fetch(req("/_/admin/links?search=zzz", { headers: { Cookie: "lang=en" } })) ).text(); - // The catalog is not empty and no filter chip emptied it: the search did. - // Match the whole sentence, so a bare query or an unreplaced placeholder - // sitting next to one cannot pass. - expect(emptyState(html)).toContain("No links match "zzz"."); + // The search emptied the window, and the default Active chip is narrowing + // too, so the copy names both. Match the whole sentence, so a bare query or + // an unreplaced placeholder sitting next to one cannot pass. + expect(emptyState(html)).toContain("No links match "zzz" in Active."); expect(emptyState(html)).not.toContain("current filter"); expect(emptyState(html)).not.toContain("No links yet"); }); + it("names the filter alongside the query when a chip is narrowing too", async () => { + // A link matching "one" exists and is one chip away, so blaming the query + // alone would be as wrong as blaming the filter alone. + for (const slug of ["one", "two", "three"]) { + await LinkRepository.create(env.DB, { url: `https://example.com/${slug}`, slug }); + } + const html = await ( + await SELF.fetch( + req("/_/admin/links?filter=disabled&search=one", { headers: { Cookie: "lang=en" } }), + ) + ).text(); + + expect(emptyState(html)).toContain("No links match "one" in Disabled."); + }); + + it("names only the query when the All chip is hiding nothing", async () => { + await seed(3); + const html = await ( + await SELF.fetch( + req("/_/admin/links?filter=all&search=zzz", { headers: { Cookie: "lang=en" } }), + ) + ).text(); + + // Nothing is narrowing but the search, so there is no filter to name. + expect(emptyState(html)).toContain("No links match "zzz"."); + expect(emptyState(html)).not.toContain("in All"); + }); + it("keeps the filter wording when a filter, not a search, emptied the list", async () => { await seed(3); const html = await ( diff --git a/src/__tests__/service/link-page-service.test.ts b/src/__tests__/service/link-page-service.test.ts index 0138853..a1ed5a6 100644 --- a/src/__tests__/service/link-page-service.test.ts +++ b/src/__tests__/service/link-page-service.test.ts @@ -86,10 +86,24 @@ describe("listLinksPage", () => { expect(result.emptyReason).toBe("no-matches"); }); - it("reports no-matches when a search finds nothing in a populated catalog", async () => { + it("reports no-search-matches when a search finds nothing in a populated catalog", async () => { + // The search is what emptied the window, so the reason has to say so: the + // page cannot tell a search-emptied result from a filter-emptied one once + // both arrive as the same reason. await seed(3); const result = await listLinksPage(env as never, { page: 1, perPage: 25, search: "xyzzy-nothing" }); - expect(result.emptyReason).toBe("no-matches"); + expect(result.emptyReason).toBe("no-search-matches"); + }); + + it("reports no-search-matches when a status filter is narrowing alongside the search", async () => { + await seed(3); + const result = await listLinksPage(env as never, { + page: 1, + perPage: 25, + status: "disabled", + search: "xyzzy-nothing", + }); + expect(result.emptyReason).toBe("no-search-matches"); }); it("reports no-links for an empty catalog even under a search", async () => { diff --git a/src/__tests__/unit/links-empty-state.test.ts b/src/__tests__/unit/links-empty-state.test.ts index 4e2cf99..a69a698 100644 --- a/src/__tests__/unit/links-empty-state.test.ts +++ b/src/__tests__/unit/links-empty-state.test.ts @@ -8,36 +8,50 @@ import { createTranslateFn } from "../../i18n"; const t = createTranslateFn("en"); describe("links empty state copy", () => { - it("names the query when a search emptied the window", () => { - expect(emptyStateCopy(t, "no-matches", "zzz")).toBe('No links match "zzz".'); + it("names both the query and the filter when each is narrowing", () => { + expect(emptyStateCopy(t, "no-search-matches", "zzz", "disabled")).toBe( + 'No links match "zzz" in Disabled.', + ); + expect(emptyStateCopy(t, "no-search-matches", "zzz", "active")).toBe( + 'No links match "zzz" in Active.', + ); }); - it("blames the filter only when no search is active", () => { - expect(emptyStateCopy(t, "no-matches", "")).toBe("No links match the current filter."); + it("names only the query when the all filter hides nothing", () => { + expect(emptyStateCopy(t, "no-search-matches", "zzz", "all")).toBe('No links match "zzz".'); }); - it("treats a whitespace-only query as no query", () => { - // A query of spaces empties the window without being something the copy - // can name back to the user. - expect(emptyStateCopy(t, "no-matches", " ")).toBe("No links match the current filter."); + it("blames the filter when no search is active", () => { + expect(emptyStateCopy(t, "no-matches", "", "disabled")).toBe( + "No links match the current filter.", + ); }); it("trims the query before naming it", () => { - expect(emptyStateCopy(t, "no-matches", " zzz ")).toBe('No links match "zzz".'); + expect(emptyStateCopy(t, "no-search-matches", " zzz ", "all")).toBe('No links match "zzz".'); + }); + + it("falls back to the filter copy if a search reason arrives with no query", () => { + // The service only emits no-search-matches for a query that survives a + // trim, so this pairing should be unreachable. Naming an empty query back + // to the user is worse than the generic line, so guard it anyway. + expect(emptyStateCopy(t, "no-search-matches", " ", "all")).toBe( + "No links match the current filter.", + ); }); it("clamps a long query so the empty state stays one readable line", () => { - const copy = emptyStateCopy(t, "no-matches", "z".repeat(500)); + const copy = emptyStateCopy(t, "no-search-matches", "z".repeat(500), "all"); expect(copy.length).toBeLessThan(120); expect(copy).toContain("…"); }); it("keeps the all-disabled claim for an expired catalog", () => { - expect(emptyStateCopy(t, "all-disabled", "")).toContain("All links are disabled"); + expect(emptyStateCopy(t, "all-disabled", "", "active")).toContain("All links are disabled"); }); it("falls back to the first-run copy for an empty catalog", () => { - expect(emptyStateCopy(t, "no-links", "")).toContain("No links yet"); - expect(emptyStateCopy(t, undefined, "")).toContain("No links yet"); + expect(emptyStateCopy(t, "no-links", "", "active")).toContain("No links yet"); + expect(emptyStateCopy(t, undefined, "", "active")).toContain("No links yet"); }); }); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c9a3a02..fbbf632 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -65,6 +65,7 @@ const en = { 'All links are disabled. Pick the "Disabled" filter to see them.', "links.noMatches": "No links match the current filter.", "links.noSearchMatches": 'No links match "{query}".', + "links.noSearchMatchesInFilter": 'No links match "{query}" in {filter}.', "links.empty": "No links yet. Use the + New Link button above to get started.", "links.disabled": "Disabled", "links.clicks": "clicks", diff --git a/src/i18n/id.ts b/src/i18n/id.ts index 65aae63..2696110 100644 --- a/src/i18n/id.ts +++ b/src/i18n/id.ts @@ -67,6 +67,7 @@ const id: Translations = { 'Semua tautan nonaktif. Pilih filter "Nonaktif" untuk melihatnya.', "links.noMatches": "Tidak ada tautan yang cocok dengan filter saat ini.", "links.noSearchMatches": 'Tidak ada tautan yang cocok dengan "{query}".', + "links.noSearchMatchesInFilter": 'Tidak ada tautan yang cocok dengan "{query}" di {filter}.', "links.empty": "Belum ada tautan. Gunakan tombol + Tautan Baru di atas untuk memulai.", "links.disabled": "Nonaktif", diff --git a/src/i18n/sv.ts b/src/i18n/sv.ts index b881597..100ac39 100644 --- a/src/i18n/sv.ts +++ b/src/i18n/sv.ts @@ -67,6 +67,7 @@ const sv: Translations = { 'Alla länkar är avaktiverade. Välj filtret "Avaktiverade" för att se dem.', "links.noMatches": "Inga länkar matchar det valda filtret.", "links.noSearchMatches": 'Inga länkar matchar "{query}".', + "links.noSearchMatchesInFilter": 'Inga länkar matchar "{query}" i {filter}.', "links.empty": "Inga länkar ännu. Använd knappen + Ny länk ovan för att komma igång.", "links.disabled": "Inaktiverad", diff --git a/src/pages/links.tsx b/src/pages/links.tsx index e773859..46f88d2 100644 --- a/src/pages/links.tsx +++ b/src/pages/links.tsx @@ -3,7 +3,7 @@ import type { FC } from "hono/jsx"; import type { LinkWithSlugs, TimelineRange } from "../types"; -import type { TranslateFn } from "../i18n"; +import type { TranslateFn, TranslationKey } from "../i18n"; import { Delta } from "../components/delta"; import { RangePicker } from "../components/range-picker"; import { fmtNumber } from "../i18n/format"; @@ -121,17 +121,27 @@ export function paginationItems( ]; } +/** + * The status chips, in the order they render. One table, so the chip a user + * clicks and the filter the empty state names can never drift apart. + */ +const FILTER_CHIPS = [ + { key: "active", labelKey: "links.filterActive", icon: "link" }, + { key: "disabled", labelKey: "links.filterDisabled", icon: "block" }, + { key: "all", labelKey: "links.filterAll", icon: "all_inclusive" }, +] as const satisfies readonly { key: LinksFilter; labelKey: TranslationKey; icon: string }[]; + /** Longest query the empty state repeats back before it gets clipped. */ const EMPTY_STATE_QUERY_MAX = 60; /** * Copy for an empty result set. * - * `emptyReason` says why the served window came back with nothing, but its - * `no-matches` value covers two different claims: a filter chip that selected - * none of the catalog, or a search that matched none of it. The service hands - * back one reason and the page owns the query, so the split lands here. Naming - * the query back to the user beats blaming a filter they never touched. + * The service says why the served window came back with nothing. The page adds + * the two things only it holds: the query to name, and the chip that may be + * narrowing alongside it. Under `all` nothing but the search is hiding rows, so + * there is no filter worth naming; under `active` or `disabled` both are, and + * blaming either one alone is half the truth. * * The query is user input dropped into a centred one-paragraph block. Escaping * keeps it safe, and clipping keeps a pasted essay from pushing the toolbar and @@ -141,17 +151,25 @@ export function emptyStateCopy( t: TranslateFn, emptyReason: LinksEmptyReason | undefined, searchQuery: string, + filter: LinksFilter, ): string { switch (emptyReason) { case "all-disabled": return t("links.allDisabled"); - case "no-matches": { + case "no-matches": + return t("links.noMatches"); + case "no-search-matches": { const query = searchQuery.trim(); + // The service only raises this reason for a query that survives a trim, + // so an empty one is unreachable. Naming it back to the user would read + // as `No links match ""`, so fall back rather than print that. if (!query) return t("links.noMatches"); const shown = query.length > EMPTY_STATE_QUERY_MAX ? `${query.slice(0, EMPTY_STATE_QUERY_MAX)}…` : query; - return t("links.noSearchMatches", { query: shown }); + if (filter === "all") return t("links.noSearchMatches", { query: shown }); + const label = FILTER_CHIPS.find((chip) => chip.key === filter)?.labelKey; + return t("links.noSearchMatchesInFilter", { query: shown, filter: label ? t(label) : filter }); } case "no-links": case undefined: @@ -230,12 +248,6 @@ export const LinksPage: FC = ({ const countKey = total !== 1 ? "links.countPlural" : "links.count"; - const filterChips: { key: LinksFilter; labelKey: "links.filterActive" | "links.filterDisabled" | "links.filterAll"; icon: string }[] = [ - { key: "active", labelKey: "links.filterActive", icon: "link" }, - { key: "disabled", labelKey: "links.filterDisabled", icon: "block" }, - { key: "all", labelKey: "links.filterAll", icon: "all_inclusive" }, - ]; - const rangeLabel = range === "all" ? t("range.long.all") : t(`range.${range}` as const); const preserveParams: Record = { sort, @@ -282,7 +294,7 @@ export const LinksPage: FC = ({
- {filterChips.map((chip) => ( + {FILTER_CHIPS.map((chip) => ( = ({ {links.length === 0 ? (
link_off -

{emptyStateCopy(t, emptyReason, searchQuery || "")}

+

{emptyStateCopy(t, emptyReason, searchQuery || "", filter)}

) : ( <> diff --git a/src/services/link-management.ts b/src/services/link-management.ts index d642834..0db8171 100644 --- a/src/services/link-management.ts +++ b/src/services/link-management.ts @@ -64,10 +64,14 @@ export interface ListLinksPageOptions extends ListLinksOptions { /** * Why a rendered page has no rows, so the caller can pick the right empty copy. * `no-links` means the catalog itself is empty, `all-disabled` that every link - * there is has expired, and `no-matches` that the search or status filter - * selected none of them. + * there is has expired, `no-matches` that the status filter selected none of + * them, and `no-search-matches` that a search did. + * + * The last two are split here rather than in the page because this is where the + * search is already trimmed. A caller re-deriving "did a search happen" from + * the raw query can disagree with the reason it was handed. */ -export type LinksEmptyReason = "no-links" | "all-disabled" | "no-matches"; +export type LinksEmptyReason = "no-links" | "all-disabled" | "no-matches" | "no-search-matches"; export interface LinksPageData { /** Rows for this page: already filtered, sorted and windowed by SQL. */ @@ -129,10 +133,13 @@ export async function listLinksPage(env: Env, opts?: ListLinksPageOptions): Prom // emptied it. const catalog = await LinkRepository.count(env.DB); if (catalog === 0) emptyReason = "no-links"; - // Nothing active in a non-empty catalog means every link has expired. - // Any other empty result is a filter or search that selected none of them, - // which is the opposite claim. - else if (status === "active" && !opts?.search?.trim()) emptyReason = "all-disabled"; + // A live search outranks the status filter: the query is the specific thing + // the page can name back to the user, and it is the more likely culprit. + else if (opts?.search?.trim()) emptyReason = "no-search-matches"; + // Nothing active in a non-empty catalog means every link has expired. Any + // other empty result is a status filter that selected none of them, which + // is the opposite claim. + else if (status === "active") emptyReason = "all-disabled"; else emptyReason = "no-matches"; } From 0ae8233bceaaf8f5c576b1133a8ba76c029be347 Mon Sep 17 00:00:00 2001 From: Dennis Alund Date: Thu, 27 Aug 2026 08:16:13 +0800 Subject: [PATCH 4/4] Clip the empty-state query by code point and drop the unreachable label arm Two findings from a fresh read of this branch. The clip cut UTF-16 units. A query long enough to trim, with its emoji sitting on odd offsets, lost half a surrogate pair at the boundary and the response carried an unpaired surrogate that a browser draws as U+FFFD. Spreading the string first counts and cuts code points instead. The first attempt at the test picked an all-emoji query, where every pair straddles the 60th unit evenly and a naive slice gets away with it, so the test passed against the bug. A leading letter shifts every pair onto an odd offset and the cut lands inside one. The test was checked against the old slice to confirm it fails there. Naming the filter went through FILTER_CHIPS.find(), which returns undefined for a key that is not in the list. LinksFilter has three members and the list covers all three, so the not-found arm could never run and existed only to satisfy the type. A Record keyed by LinksFilter is total: no fallback, and a filter added to the union fails the build here rather than falling back to its raw key. The chips now read their labels from the same table. --- src/__tests__/unit/links-empty-state.test.ts | 13 +++++++ src/pages/links.tsx | 36 ++++++++++++++------ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/__tests__/unit/links-empty-state.test.ts b/src/__tests__/unit/links-empty-state.test.ts index a69a698..622be58 100644 --- a/src/__tests__/unit/links-empty-state.test.ts +++ b/src/__tests__/unit/links-empty-state.test.ts @@ -7,6 +7,9 @@ import { createTranslateFn } from "../../i18n"; const t = createTranslateFn("en"); +/** An unpaired half of a surrogate pair, left behind by a UTF-16 slice. */ +const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { it("names both the query and the filter when each is narrowing", () => { expect(emptyStateCopy(t, "no-search-matches", "zzz", "disabled")).toBe( @@ -40,6 +43,16 @@ describe("links empty state copy", () => { ); }); + it("clips a long query on a character boundary, not mid-emoji", () => { + // The leading letter puts every emoji on an odd UTF-16 offset, so the cut + // lands between the two halves of one. Without it the pairs straddle the + // boundary evenly and a naive slice gets away with it. + const copy = emptyStateCopy(t, "no-search-matches", `a${"\u{1F44D}".repeat(70)}`, "all"); + expect(copy).toContain("…"); + // An unpaired surrogate reaches the browser as a replacement character. + expect(LONE_SURROGATE.test(copy)).toBe(false); + }); + it("clamps a long query so the empty state stays one readable line", () => { const copy = emptyStateCopy(t, "no-search-matches", "z".repeat(500), "all"); expect(copy.length).toBeLessThan(120); diff --git a/src/pages/links.tsx b/src/pages/links.tsx index 46f88d2..341d03f 100644 --- a/src/pages/links.tsx +++ b/src/pages/links.tsx @@ -122,14 +122,26 @@ export function paginationItems( } /** - * The status chips, in the order they render. One table, so the chip a user - * clicks and the filter the empty state names can never drift apart. + * The chip label per status. A Record rather than a lookup over the chip list: + * every LinksFilter has an entry by construction, so naming one needs no + * not-found arm that can never run. + */ +const FILTER_LABEL: Record = { + active: "links.filterActive", + disabled: "links.filterDisabled", + all: "links.filterAll", +}; + +/** + * The status chips, in the order they render. They read their labels from the + * table above, so the chip a user clicks and the filter the empty state names + * can never drift apart. */ const FILTER_CHIPS = [ - { key: "active", labelKey: "links.filterActive", icon: "link" }, - { key: "disabled", labelKey: "links.filterDisabled", icon: "block" }, - { key: "all", labelKey: "links.filterAll", icon: "all_inclusive" }, -] as const satisfies readonly { key: LinksFilter; labelKey: TranslationKey; icon: string }[]; + { key: "active", icon: "link" }, + { key: "disabled", icon: "block" }, + { key: "all", icon: "all_inclusive" }, +] as const satisfies readonly { key: LinksFilter; icon: string }[]; /** Longest query the empty state repeats back before it gets clipped. */ const EMPTY_STATE_QUERY_MAX = 60; @@ -164,12 +176,14 @@ export function emptyStateCopy( // so an empty one is unreachable. Naming it back to the user would read // as `No links match ""`, so fall back rather than print that. if (!query) return t("links.noMatches"); - const shown = query.length > EMPTY_STATE_QUERY_MAX - ? `${query.slice(0, EMPTY_STATE_QUERY_MAX)}…` + // Count and cut code points, not UTF-16 units: slicing a string mid pair + // strands half an emoji, and the response ships it as U+FFFD. + const chars = [...query]; + const shown = chars.length > EMPTY_STATE_QUERY_MAX + ? `${chars.slice(0, EMPTY_STATE_QUERY_MAX).join("")}…` : query; if (filter === "all") return t("links.noSearchMatches", { query: shown }); - const label = FILTER_CHIPS.find((chip) => chip.key === filter)?.labelKey; - return t("links.noSearchMatchesInFilter", { query: shown, filter: label ? t(label) : filter }); + return t("links.noSearchMatchesInFilter", { query: shown, filter: t(FILTER_LABEL[filter]) }); } case "no-links": case undefined: @@ -300,7 +314,7 @@ export const LinksPage: FC = ({ href={filterUrl(chip.key)} > {chip.icon} - {t(chip.labelKey)} + {t(FILTER_LABEL[chip.key])}
))}