Skip to content
Merged
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
78 changes: 78 additions & 0 deletions src/__tests__/page/links-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<b>zzz</b>")}`, {
headers: { Cookie: "lang=en" },
}),
)
).text();

expect(emptyState(html)).toContain("&lt;b&gt;zzz&lt;/b&gt;");
expect(emptyState(html)).not.toContain("<b>zzz</b>");
});

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 });
Expand Down
18 changes: 16 additions & 2 deletions src/__tests__/service/link-page-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
16 changes: 16 additions & 0 deletions src/__tests__/unit/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
Expand Down
70 changes: 70 additions & 0 deletions src/__tests__/unit/links-empty-state.test.ts
Original file line number Diff line number Diff line change
@@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;

describe("links empty state copy", () => {
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");
});
});
2 changes: 2 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/sv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TimelineRange>(["24h", "7d", "30d", "90d", "1y", "all"]);
const rangeParam = c.req.query("range");
Expand Down
96 changes: 80 additions & 16 deletions src/pages/links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<LinksFilter, TranslationKey> = {
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
Expand Down Expand Up @@ -186,12 +262,6 @@ export const LinksPage: FC<Props> = ({

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<string, string | undefined> = {
sort,
Expand Down Expand Up @@ -238,13 +308,13 @@ export const LinksPage: FC<Props> = ({
<div class="toolbar">
<div class="toolbar-group">
<div class="filter-chips" role="group" aria-label={t("links.filter")}>
{filterChips.map((chip) => (
{FILTER_CHIPS.map((chip) => (
<a
class={`filter-chip${filter === chip.key ? " active" : ""}`}
href={filterUrl(chip.key)}
>
<span class="icon">{chip.icon}</span>
<span>{t(chip.labelKey)}</span>
<span>{t(FILTER_LABEL[chip.key])}</span>
</a>
))}
</div>
Expand Down Expand Up @@ -273,13 +343,7 @@ export const LinksPage: FC<Props> = ({
{links.length === 0 ? (
<div class="empty-state">
<span class="icon">link_off</span>
<p>
{emptyReason === "all-disabled"
? t("links.allDisabled")
: emptyReason === "no-matches"
? t("links.noMatches")
: t("links.empty")}
</p>
<p>{emptyStateCopy(t, emptyReason, searchQuery || "", filter)}</p>
</div>
) : (
<>
Expand Down
Loading