diff --git a/src/__tests__/page/links-page.test.ts b/src/__tests__/page/links-page.test.ts index 60ca73c..8400442 100644 --- a/src/__tests__/page/links-page.test.ts +++ b/src/__tests__/page/links-page.test.ts @@ -648,6 +648,84 @@ 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 () => { + await seed(3); + const html = await ( + await SELF.fetch(req("/_/admin/links?search=zzz", { headers: { Cookie: "lang=en" } })) + ).text(); + + // 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 ( + await SELF.fetch(req("/_/admin/links?filter=disabled", { headers: { Cookie: "lang=en" } })) + ).text(); + + 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 () => { + await seed(3); + 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("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__/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/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..622be58 --- /dev/null +++ b/src/__tests__/unit/links-empty-state.test.ts @@ -0,0 +1,70 @@ +// 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"); + +/** 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( + 'No links match "zzz" in Disabled.', + ); + expect(emptyStateCopy(t, "no-search-matches", "zzz", "active")).toBe( + 'No links match "zzz" in Active.', + ); + }); + + 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("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-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("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); + expect(copy).toContain("…"); + }); + + it("keeps the all-disabled claim for an expired catalog", () => { + 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", "", "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 735689d..fbbf632 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -64,6 +64,8 @@ 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.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 d227657..2696110 100644 --- a/src/i18n/id.ts +++ b/src/i18n/id.ts @@ -66,6 +66,8 @@ 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.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 4a53b13..100ac39 100644 --- a/src/i18n/sv.ts +++ b/src/i18n/sv.ts @@ -66,6 +66,8 @@ 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.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/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 0cef9e7..341d03f 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,6 +121,82 @@ export function paginationItems( ]; } +/** + * 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", 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; + +/** + * Copy for an empty result set. + * + * 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 + * the paginator off screen. + */ +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": + 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"); + // 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 }); + return t("links.noSearchMatchesInFilter", { query: shown, filter: t(FILTER_LABEL[filter]) }); + } + 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 = { /** * Rows for the current page only: the query already applied the filter, the @@ -186,12 +262,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, @@ -238,13 +308,13 @@ export const LinksPage: FC = ({
- {filterChips.map((chip) => ( + {FILTER_CHIPS.map((chip) => ( {chip.icon} - {t(chip.labelKey)} + {t(FILTER_LABEL[chip.key])} ))}
@@ -273,13 +343,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 || "", 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"; }