Skip to content
Open
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
35 changes: 35 additions & 0 deletions apps/web/src/components/sozluk/SozlukTermCrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {Link} from "react-router";
import {useT} from "../../i18n";
import {sozlukLetterHref} from "../../lib/sozlukLetterHref";

export interface SozlukTermCrumbsProps {
/**
* The letter this headword files under, or an empty string / `null` when it files under
* none. Callers supply it, they never fold it here: the existing term reads the stored
* `first_letter` column and the new-term composer derives it from the slug through
* `sozlukLetterOf` — one markup, two sources, no private fold (#9602).
*/
letter: string | null;
title: string;
}

/**
* The term page's breadcrumb, for both of that page's branches. It lived twice — once in the
* header, once inline in the new-term composer — and the two copies drifted: #9355 and #9331
* each landed on the header alone, leaving the composer pointing its letter crumb at `/sozluk`
* and naming the letter with an ASCII fold.
*/
export function SozlukTermCrumbs({letter, title}: SozlukTermCrumbsProps) {
const t = useT();
return (
<p className="kp-sozluk-term__crumbs">
<Link to="/sozluk">{t("sozluk.term.crumbRoot")}</Link> /{" "}
{letter ? (
<>
<Link to={sozlukLetterHref(letter, false)}>{letter}</Link> /{" "}
</>
) : null}
{title}
</p>
);
}
13 changes: 2 additions & 11 deletions apps/web/src/components/sozluk/SozlukTermHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import {useView, type ViewRef, view} from "react-fate";
import {Link} from "react-router";
import type {Term} from "../../../worker/features/fate/views";
import {toIsoOrNull} from "../../fate/wire";
import {useT, useTPlural} from "../../i18n";
import {formatAgoTR, formatDateTR} from "../../lib/datetime";
import {sozlukLetterHref} from "../../lib/sozlukLetterHref";
import {SozlukTermCrumbs} from "./SozlukTermCrumbs";

export const TermHeaderView = view<Term>()({
id: true,
Expand Down Expand Up @@ -33,15 +32,7 @@ export function SozlukTermHeader(props: SozlukTermHeaderProps) {
const lastEdit = toIsoOrNull(term.lastEdit);
return (
<header className="kp-sozluk-term__head">
<p className="kp-sozluk-term__crumbs">
<Link to="/sozluk">{t("sozluk.term.crumbRoot")}</Link> /{" "}
{firstLetter ? (
<>
<Link to={sozlukLetterHref(firstLetter, false)}>{firstLetter}</Link> /{" "}
</>
) : null}
{term.title}
</p>
<SozlukTermCrumbs letter={firstLetter} title={term.title} />
<h1 className="kp-sozluk-term__title">{term.title}</h1>
<div className="kp-sozluk-term__meta">
<span>
Expand Down
51 changes: 50 additions & 1 deletion apps/web/src/pages/SozlukTermPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {MemoryRouter} from "react-router";
import {describe, expect, it, vi} from "vitest";
import {LocaleProvider} from "../i18n";
import {LOCALE_STORAGE_KEY} from "../lib/localeStorage";
import {DefinitionsList} from "./SozlukTermPage";
import {DefinitionsList, NewTermComposer} from "./SozlukTermPage";

vi.mock("react-fate", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-fate")>();
Expand Down Expand Up @@ -94,3 +94,52 @@ describe("the term page's composer reads English at locale en (#7529)", () => {
expect(screen.getByText("markdown ·", {exact: false})).toBeTruthy();
});
});

// The undefined slug has no stored `first_letter`, so the letter comes off the slug — through
// `sozlukLetterOf`, the fold the column's own producers share. `slug.charAt(0).toLowerCase()`
// read `IŞIK` as `i` while the same headword reads `ı` everywhere else (#9602).
describe("NewTermComposer's breadcrumb letter", () => {
function renderComposer(slug: string) {
sessionMock.data = {user: {id: "u1", name: "yazar"}};
const {container} = render(
<MemoryRouter>
<NewTermComposer slug={slug} onCreated={vi.fn()} />
</MemoryRouter>,
);
return container.querySelector(".kp-sozluk-term__crumbs") as HTMLElement;
}

function letterCrumb(slug: string, letter: string): string | null {
renderComposer(slug);
return screen.queryByRole("link", {name: letter})?.getAttribute("href") ?? null;
}

it("folds the dotless capital I to ı, not to i", () => {
expect(letterCrumb("IŞIK", "ı")).toBe(`/sozluk/harf/${encodeURIComponent("ı")}`);
expect(screen.queryByRole("link", {name: "i"})).toBeNull();
});

it("keeps a slug already starting ı on the dotless letter's page", () => {
expect(letterCrumb("ışık", "ı")).toBe(`/sozluk/harf/${encodeURIComponent("ı")}`);
});

it("folds the dotted capital İ to i", () => {
expect(letterCrumb("İMECE", "i")).toBe("/sozluk/harf/i");
});

it("keeps a slug already starting i on the dotted letter's page", () => {
expect(letterCrumb("imece", "i")).toBe("/sozluk/harf/i");
});

it("renders no letter crumb for a slug the alphabet does not index", () => {
const crumbs = renderComposer("3-adim");
expect(crumbs.textContent).toContain("3 adim");
expect(crumbs.querySelectorAll("a").length).toBe(1);
});

it("routes both crumbs through the router, so a click does not reload the page", () => {
const crumbs = renderComposer("imece");
const hrefs = [...crumbs.querySelectorAll("a")].map((a) => a.getAttribute("href"));
expect(hrefs).toEqual(["/sozluk", "/sozluk/harf/i"]);
});
});
17 changes: 11 additions & 6 deletions apps/web/src/pages/SozlukTermPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import {
} from "react-fate";
import {Link, useNavigate, useParams} from "react-router";
import type {Term} from "../../worker/features/fate/views";
import {sozlukLetterOf} from "../../worker/features/sozluk/turkish-alphabet";
import {useSession} from "../auth/client";
import {FirstContributionOnramp} from "../components/authorship/FirstContributionOnramp";
import {actorLabel} from "../components/moderation/actor-identity";
import {DefinitionCard, DefinitionView} from "../components/sozluk/DefinitionCard";
import {SozlukTermCrumbs} from "../components/sozluk/SozlukTermCrumbs";
import {SozlukTermHeader, TermHeaderView} from "../components/sozluk/SozlukTermHeader";
import {Screen} from "../fate/Screen";
import {useDraftSubmit} from "../fate/useDraftSubmit";
Expand Down Expand Up @@ -188,22 +190,25 @@ function SozlukTermContent({
);
}

function NewTermComposer({
export function NewTermComposer({
slug,
onCreated,
}: {
slug: string;
onCreated: (definitionId: string | null) => void;
}) {
const t = useT();
const title = slug.replace(/-/g, " ");
// The term does not exist yet, so there is no stored `first_letter` to read — the letter has
// to come off the slug, and through the same Turkish-aware fold the column's producers use.
// `null` for a slug the alphabet does not index, which renders no letter crumb, matching the
// header (#9602).
const letter = sozlukLetterOf(slug);
return (
<>
<header className="kp-sozluk-term__head">
<p className="kp-sozluk-term__crumbs">
<a href="/sozluk">{t("sozluk.term.crumbRoot")}</a> /{" "}
<a href="/sozluk">{slug.charAt(0).toLowerCase()}</a> / {slug.replace(/-/g, " ")}
</p>
<h1 className="kp-sozluk-term__title kp-prose">{slug.replace(/-/g, " ")}</h1>
<SozlukTermCrumbs letter={letter} title={title} />
<h1 className="kp-sozluk-term__title kp-prose">{title}</h1>
<div className="kp-sozluk-term__meta">
<span>{t("sozluk.term.noEntriesYet")}</span>
</div>
Expand Down
Loading