diff --git a/components/language-chooser/common/find-language/languageTagUtils.spec.ts b/components/language-chooser/common/find-language/languageTagUtils.spec.ts index 4a9fdd56..0256bbde 100644 --- a/components/language-chooser/common/find-language/languageTagUtils.spec.ts +++ b/components/language-chooser/common/find-language/languageTagUtils.spec.ts @@ -13,7 +13,9 @@ import { isValidBcp47Tag, languageForManuallyEnteredTag, UNLISTED_LANGUAGE, + defaultDisplayName, } from "./languageTagUtils"; +import { demarcateResults } from "./matchingSubstringDemarcation"; import { defaultSearchResultModifier } from "./searchResultModifiers"; import { parseLangtagFromLangChooser } from "./searchForLanguage"; @@ -38,6 +40,7 @@ import { SERBIAN_LANGUAGE, ARABIC_MACROLANGUAGE, AYMARA_MACROLANGUAGE, + createTestLanguageEntry, } from "./testUtils"; describe("Tag creation", () => { @@ -849,3 +852,103 @@ describe("formatting dialect codes", () => { ); }); }); + +describe("defaultDisplayName", () => { + // Helper for the fixtures, whose scripts are listed in an order we don't want tests to depend on + function scriptOf(language: ILanguage, scriptCode: string): IScript { + const script = language.scripts.find((s) => s.code === scriptCode); + expect(script, `${scriptCode} script of ${language.exonym}`).toBeDefined(); + return script as IScript; + } + + it("should return empty string when there is no language", () => { + expect(defaultDisplayName(undefined)).toEqual(""); + expect( + defaultDisplayName(undefined, { code: "Latn", name: "Latin" }) + ).toEqual(""); + }); + + it("should use the autonym when no script is given", () => { + expect(defaultDisplayName(BOSNIAN_LANGUAGE)).toEqual("Bosanski jezik"); + expect(defaultDisplayName(SERBIAN_LANGUAGE)).toEqual("српски"); + }); + + it("should fall back to the exonym when there is no script and no autonym", () => { + expect(defaultDisplayName(ENGLISH_LANGUAGE)).toEqual("English"); + expect(defaultDisplayName(NORTHERN_UZBEK_LANGUAGE)).toEqual( + "Northern Uzbek" + ); + }); + + // This precedence is the whole point of passing the script: choosing a different script for a + // language is what changes the name we display for it, e.g. Serbian shows as "српски" in + // Cyrillic but "srpski" in Latin. + it("should prefer the script's languageNameInScript over the autonym", () => { + expect( + defaultDisplayName(SERBIAN_LANGUAGE, scriptOf(SERBIAN_LANGUAGE, "Latn")) + ).toEqual("srpski"); + expect( + defaultDisplayName(SERBIAN_LANGUAGE, scriptOf(SERBIAN_LANGUAGE, "Cyrl")) + ).toEqual("српски"); + expect( + defaultDisplayName(BOSNIAN_LANGUAGE, scriptOf(BOSNIAN_LANGUAGE, "Cyrl")) + ).toEqual("босански"); + }); + + it("should fall back to the autonym for a script with no languageNameInScript", () => { + // Norwegian's Braille and Runic entries carry no languageNameInScript + expect( + defaultDisplayName( + NORWEGIAN_MACROLANGUAGE, + scriptOf(NORWEGIAN_MACROLANGUAGE, "Brai") + ) + ).toEqual("Norsk"); + expect( + defaultDisplayName(BOSNIAN_LANGUAGE, scriptOf(BOSNIAN_LANGUAGE, "Arab")) + ).toEqual("Bosanski jezik"); + }); + + it("should fall back to the exonym for a script with no languageNameInScript on a language with no autonym", () => { + expect( + defaultDisplayName(ENGLISH_LANGUAGE, scriptOf(ENGLISH_LANGUAGE, "Latn")) + ).toEqual("English"); + }); + + it("should return empty string for the unlisted language, even with a script", () => { + expect(defaultDisplayName(UNLISTED_LANGUAGE)).toEqual(""); + expect( + defaultDisplayName(UNLISTED_LANGUAGE, { code: "Latn", name: "Latin" }) + ).toEqual(""); + }); + + it("should return empty string for a manually entered tag language", () => { + const manualLanguage = languageForManuallyEnteredTag("qxy-Latn-ZZ"); + expect(defaultDisplayName(manualLanguage)).toEqual(""); + expect( + defaultDisplayName(manualLanguage, { code: "Latn", name: "Latin" }) + ).toEqual(""); + }); + + it("should strip the match demarcation that search results carry", () => { + // Languages coming out of a search have the matched substring marked for bolding, + // e.g. searching "san" marks Bosnian's autonym as "Bo[san]ski jezik" + const [demarcatedBosnian] = demarcateResults([BOSNIAN_LANGUAGE], "san"); + expect(demarcatedBosnian.autonym).toEqual("Bo[san]ski jezik"); + expect(defaultDisplayName(demarcatedBosnian)).toEqual("Bosanski jezik"); + + expect( + defaultDisplayName(demarcatedBosnian, { + code: "Cyrl", + name: "Cyrillic", + languageNameInScript: "бо[сан]ски", + }) + ).toEqual("босански"); + + const demarcatedExonymOnlyLanguage = createTestLanguageEntry({ + exonym: "E[ngl]ish", + iso639_3_code: "eng", + languageSubtag: "en", + }); + expect(defaultDisplayName(demarcatedExonymOnlyLanguage)).toEqual("English"); + }); +}); diff --git a/components/language-chooser/react/language-chooser-react-mui/e2e/e2eHelpers.ts b/components/language-chooser/react/language-chooser-react-mui/e2e/e2eHelpers.ts index 7fb34077..e986e8ec 100644 --- a/components/language-chooser/react/language-chooser-react-mui/e2e/e2eHelpers.ts +++ b/components/language-chooser/react/language-chooser-react-mui/e2e/e2eHelpers.ts @@ -10,6 +10,20 @@ export async function loadLanguageChooser(page) { await page.goto("/", { waitUntil: "load" }); } +// Loads src/demos/HostIntegrationDemo.tsx, which renders LanguageChooser with host-supplied +// actionButtons and no rightPanelComponent -- the arrangement Bloom ships -- and puts everything +// onSelectionChange reports on screen. The chooser itself is the same component here, so the +// card/search helpers below work in this demo too. +export async function loadHostIntegrationDemo(page) { + await page.goto("/?demo=host-integration", { waitUntil: "load" }); +} + +export async function createPageAndLoadHostIntegrationDemo(browser) { + const page = await browser.newPage(); + await loadHostIntegrationDemo(page); + return page; +} + export function scriptCardTestId(scriptCode: string) { return `script-card-${scriptCode}`; } diff --git a/components/language-chooser/react/language-chooser-react-mui/e2e/hostIntegration.e2e.ts b/components/language-chooser/react/language-chooser-react-mui/e2e/hostIntegration.e2e.ts new file mode 100644 index 00000000..26e4c5f5 --- /dev/null +++ b/components/language-chooser/react/language-chooser-react-mui/e2e/hostIntegration.e2e.ts @@ -0,0 +1,178 @@ +import { test, expect, Page } from "@playwright/test"; +import { + clickLanguageCard, + createPageAndLoadHostIntegrationDemo, + loadHostIntegrationDemo, + scriptCardTestId, + search, +} from "./e2eHelpers"; + +let page: Page; + +// What the chooser tells a host application, which is the half of the contract no other e2e file +// touches: they all drive the LanguageChooserDialog wrapper, whose own OK button stands in for the +// host. Bloom instead supplies its own actionButtons, passes no rightPanelComponent, and learns +// about the selection solely through onSelectionChange -- so these tests assert on what that +// callback reports, via src/demos/HostIntegrationDemo.tsx. They deliberately do not re-test search +// or card behavior, which the other files already cover. +test.describe("What onSelectionChange reports to the host", () => { + test.beforeAll(async ({ browser }) => { + page = await createPageAndLoadHostIntegrationDemo(browser); + }); + + // These tests change the selection, and there is no host UI here to reset it, so reload between + // them rather than trying to click our way back to a pristine state. + test.beforeEach(async () => { + await loadHostIntegrationDemo(page); + }); + + test("chooser renders and is usable with no rightPanelComponent supplied", async () => { + // The right pane itself still exists (it holds the display name bar, tag preview and the + // host's action buttons); it's the client-supplied slot within it that is empty. + await expect(page.locator("#right-panel-component-container")).toBeEmpty(); + + await search(page, "russian"); + await clickLanguageCard(page, "rus"); + + // The parts of the right pane the component owns still work + await expect(page.locator("#language-name-bar")).toHaveValue( + "русский язык" + ); + await expect(page.getByTestId("right-panel-langtag-preview")).toContainText( + "ru" + ); + }); + + test("onSelectionChange reports the tag and orthography once a language and script are chosen", async () => { + const reportedTag = page.getByTestId("host-integration-reported-tag"); + const reportedScript = page.getByTestId("host-integration-reported-script"); + + // Nothing is reported before there is a complete selection + await expect(page.getByTestId("host-integration-report-count")).toHaveText( + "0" + ); + + await search(page, "chechen"); + await clickLanguageCard(page, "che"); + // Chechen has several scripts, so the selection is not complete until one is picked + await expect(reportedTag).toBeEmpty(); + + await page.getByTestId(scriptCardTestId("Cyrl")).click(); + await expect(reportedTag).toHaveText("ce"); + await expect( + page.getByTestId("host-integration-reported-language") + ).toHaveText("ce"); + await expect(reportedScript).toHaveText("Cyrl"); + + // Switching script re-reports, which is how the host learns the tag changed + await page.getByTestId(scriptCardTestId("Arab")).click(); + await expect(reportedTag).toHaveText("ce-Arab"); + await expect(reportedScript).toHaveText("Arab"); + }); + + test("onSelectionChange reports an undefined selection when the selection is cleared", async () => { + const reportCount = page.getByTestId("host-integration-report-count"); + const reportedTag = page.getByTestId("host-integration-reported-tag"); + + await search(page, "russian"); + await clickLanguageCard(page, "rus"); // Russian has a single script, so this alone is complete + await expect(reportedTag).toHaveText("ru"); + const countWhenSelected = await reportCount.textContent(); + + // Re-clicking the selected card unselects it + await clickLanguageCard(page, "rus"); + // The host must be told the selection went away, not merely left holding the stale one, so + // check that a report actually happened rather than only that the fields went empty. + await expect(reportCount).not.toHaveText(countWhenSelected as string); + await expect(reportedTag).toBeEmpty(); + await expect( + page.getByTestId("host-integration-reported-language") + ).toBeEmpty(); + }); + + test("host-supplied action button enables and disables off the reported selection", async () => { + const okButton = page.getByTestId("host-integration-ok-button"); + await expect(okButton).toBeDisabled(); + + await search(page, "russian"); + await clickLanguageCard(page, "rus"); + await expect(okButton).toBeEnabled(); + + await clickLanguageCard(page, "rus"); + await expect(okButton).toBeDisabled(); + }); + + // A plain selection carries NO custom display name -- the chooser only fills that in once the + // user edits the name field. A host that reads only customDisplayName therefore gets nothing for + // the ordinary case and has to fall back to defaultDisplayName, which is what Bloom does. + test("a selection the user did not rename reports a default name but no custom one", async () => { + await search(page, "arabic"); + await clickLanguageCard(page, "arb"); + await page.getByTestId(scriptCardTestId("Arab")).click(); + + await expect(page.getByTestId("host-integration-reported-tag")).toHaveText( + "arb" + ); + await expect( + page.getByTestId("host-integration-reported-display-name") + ).toBeEmpty(); + await expect( + page.getByTestId("host-integration-default-display-name") + ).not.toBeEmpty(); + // ...so the name a host would actually use comes from the default, not the custom field. + await expect( + page.getByTestId("host-integration-name-a-host-would-use") + ).not.toBeEmpty(); + }); + + // A host has to be able to tell "this script reads right-to-left", "this one reads + // left-to-right", and "this script does not say" apart -- Bloom stores the difference (its IsRtl + // is nullable) and conflating the last two is what BL-13982 was about. + test("the script's reading direction is reported, and distinguishes unknown from false", async () => { + const direction = page.getByTestId("host-integration-script-is-rtl"); + await expect(direction).toBeEmpty(); // nothing selected yet + + await search(page, "chechen"); + await clickLanguageCard(page, "che"); + await page.getByTestId(scriptCardTestId("Cyrl")).click(); + await expect(direction).toHaveText("false"); + + await page.getByTestId(scriptCardTestId("Arab")).click(); + await expect(direction).toHaveText("true"); + }); + + test("the country Bloom would store is derived from the reported tag", async () => { + await expect(page.getByTestId("host-integration-country")).toBeEmpty(); + + await search(page, "russian"); + await clickLanguageCard(page, "rus"); + await expect(page.getByTestId("host-integration-country")).not.toBeEmpty(); + }); + + test("the host action button commits what it was told", async () => { + await expect(page.getByTestId("host-integration-submitted")).toBeEmpty(); + + await search(page, "russian"); + await clickLanguageCard(page, "rus"); + await page.getByTestId("host-integration-ok-button").click(); + + await expect(page.getByTestId("host-integration-submitted")).toContainText( + "ru" + ); + }); + + test("edits to the display name are reported to the host", async () => { + // Bloom reads customDetails.customDisplayName off the reported selection to name the language, + // so the report has to keep up with the display name field, not just the tag. + await search(page, "russian"); + await clickLanguageCard(page, "rus"); + await expect(page.getByTestId("host-integration-reported-tag")).toHaveText( + "ru" + ); + + await page.locator("#language-name-bar").fill("Ruso"); + await expect( + page.getByTestId("host-integration-reported-display-name") + ).toHaveText("Ruso"); + }); +}); diff --git a/components/language-chooser/react/language-chooser-react-mui/src/demos/HostIntegrationDemo.stories.tsx b/components/language-chooser/react/language-chooser-react-mui/src/demos/HostIntegrationDemo.stories.tsx new file mode 100644 index 00000000..79465391 --- /dev/null +++ b/components/language-chooser/react/language-chooser-react-mui/src/demos/HostIntegrationDemo.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { HostIntegrationDemo } from "./HostIntegrationDemo"; + +const meta: Meta = { + title: "Demos/Host Integration Demo", + component: HostIntegrationDemo, +}; + +export default meta; +type Story = StoryObj; + +// Nothing selected yet, so the readout across the top starts empty with a report count of 0. +// Pick a language and a script and watch what the host is told, and when. +export const Primary: Story = { + args: {}, + render: (args, context) => ( + + ), +}; + +// Opened with a selection already in hand, the way a host reopens the chooser on a language the +// user picked earlier. Shows that the host is told about that starting selection rather than +// having to remember it. +export const ReopenedWithASelection: Story = { + args: { + initialLanguageTag: "uz-Cyrl", + initialCustomDisplayName: "ÖzbekCustomizedName", + }, + render: (args, context) => ( + + ), +}; diff --git a/components/language-chooser/react/language-chooser-react-mui/src/demos/HostIntegrationDemo.tsx b/components/language-chooser/react/language-chooser-react-mui/src/demos/HostIntegrationDemo.tsx new file mode 100644 index 00000000..42ef71b4 --- /dev/null +++ b/components/language-chooser/react/language-chooser-react-mui/src/demos/HostIntegrationDemo.tsx @@ -0,0 +1,282 @@ +/** @jsxImportSource @emotion/react */ +import { css } from "@emotion/react"; +import { + IOrthography, + defaultDisplayName, + defaultRegionForLangTag, + defaultSearchResultModifier, +} from "@ethnolib/find-language"; +import { Button } from "@mui/material"; +import React from "react"; +import { LanguageChooser } from "../LanguageChooser"; + +// What a host application actually receives from the chooser. +// +// Each of the other demos answers a different question: DialogDemo shows the modal use case and +// reopening with a prior selection, PageDemo shows how the chooser responds to the space it is +// given, ThemeDemo shows theming. None of them shows the host side of the contract -- ThemeDemo +// happens to render the same props arrangement Bloom uses, but passes no onSelectionChange at all, +// and PageDemo keeps only the language tag. +// +// So this demo puts the callback on screen. The panel deliberately covers everything our main +// client consumes: Bloom's getLanguageData maps a selection onto exactly five things -- the +// language tag, a default name, the name to actually use, the script's reading direction, and a +// country -- so all five are here, next to the raw fields they are derived from. +export const HostIntegrationDemo: React.FunctionComponent<{ + uiLanguage?: string; + initialLanguageTag?: string; + initialSearchString?: string; + initialCustomDisplayName?: string; +}> = ({ + uiLanguage, + initialLanguageTag, + initialSearchString, + initialCustomDisplayName, +}) => { + const [reportedSelection, setReportedSelection] = React.useState< + IOrthography | undefined + >(undefined); + const [reportedLanguageTag, setReportedLanguageTag] = React.useState< + string | undefined + >(undefined); + // Lets a reader -- and the tests -- tell "never reported" apart from "reported as cleared", since + // both leave the fields below empty. + const [reportCount, setReportCount] = React.useState(0); + const [submitted, setSubmitted] = React.useState( + undefined + ); + + function onSelectionChange( + orthographyInfo: IOrthography | undefined, + languageTag: string | undefined + ) { + setReportedSelection(orthographyInfo); + setReportedLanguageTag(languageTag); + setReportCount((count) => count + 1); + } + + const language = reportedSelection?.language; + const script = reportedSelection?.script; + + // The chooser leaves customDisplayName empty until the user edits the name field, so a host that + // reads only that gets nothing for an ordinary selection and has to fall back to + // defaultDisplayName. Showing both, and the combination, makes that visible rather than looking + // like a missing value. + const customDisplayName = reportedSelection?.customDetails?.customDisplayName; + const defaultName = language + ? defaultDisplayName(language, script) + : undefined; + const nameAHostWouldUse = customDisplayName || defaultName; + + // Three states, not two: a script can say right-to-left, say left-to-right, or say nothing at + // all. Bloom keeps that distinction (its IsRtl is a bool?), and conflating "false" with "unknown" + // is what BL-13982 was about, so spell it out rather than showing a blank. + const scriptDirection = !script + ? undefined + : script.isRtl === undefined + ? "not stated" + : `${script.isRtl}`; + + const country = reportedLanguageTag + ? defaultRegionForLangTag(reportedLanguageTag, language)?.name + : undefined; + + // Supplied by the host rather than by the chooser, and enabled off the reported selection -- + // the same condition Bloom uses for its own OK button. Clicking it commits, the way a host + // would; the chooser itself has no notion of "OK". + const hostActionButtons = ( +
+ +
+ ); + + const groups: { + heading: string; + rows: { testId: string; label: string; value: string | undefined }[]; + }[] = [ + { + heading: "As reported", + rows: [ + { + testId: "report-count", + label: "times reported", + value: `${reportCount}`, + }, + { + testId: "reported-tag", + label: "tag", + value: reportedLanguageTag, + }, + { + testId: "reported-language", + label: "subtag", + value: language?.languageSubtag, + }, + { testId: "reported-script", label: "script", value: script?.code }, + { + testId: "script-is-rtl", + label: "right-to-left", + value: scriptDirection, + }, + { + testId: "reported-display-name", + label: "custom name", + value: customDisplayName, + }, + ], + }, + { + heading: "What a host does with it", + rows: [ + { + testId: "default-display-name", + label: "default name", + value: defaultName, + }, + { + testId: "name-a-host-would-use", + label: "name to use", + value: nameAHostWouldUse, + }, + { testId: "country", label: "country", value: country }, + { testId: "submitted", label: "committed", value: submitted }, + ], + }, + ]; + + return ( +
+ {/* + The selection as reported to the host: readable for a person, assertable for the tests. + Values are rendered exactly as reported, so an empty one really is empty in the DOM (the + tests assert on that); the dash a reader sees for an empty value comes from CSS, so it does + not pollute the text content. + */} +
div + div { + margin-top: 16px; + } + `} + > + {groups.map((group) => ( +
+

{group.heading}

+
+ {group.rows.map((row) => ( + +
{row.label}
+
+ {row.value || ""} +
+
+ ))} +
+
+ ))} +
+ {/* + Bloom hosts the chooser in a WinForms dialog sized 1000x580 -- see the SetScaledSize call in + CollectionSettingsDialog.ChangeLanguage -- so give it a box that size here. LanguageChooser + is height:100%, so it needs a parent with a definite height; letting it fill the viewport + instead both misrepresents what users see and pushes this page into a scrollbar. The row + wraps rather than shrinking the box, so a narrow window stacks the panel above it instead of + scrolling sideways. + Note: no rightPanelComponent, matching Bloom. + */} +
+ +
+
+ ); +}; + +export default HostIntegrationDemo; diff --git a/components/language-chooser/react/language-chooser-react-mui/src/main.tsx b/components/language-chooser/react/language-chooser-react-mui/src/main.tsx index 395b1b34..3ba32cc9 100644 --- a/components/language-chooser/react/language-chooser-react-mui/src/main.tsx +++ b/components/language-chooser/react/language-chooser-react-mui/src/main.tsx @@ -1,5 +1,6 @@ import ReactDOM from "react-dom"; import DialogDemo from "./demos/DialogDemo"; +import HostIntegrationDemo from "./demos/HostIntegrationDemo"; import React from "react"; // Read parameters from URL query parameters (for e2e testing) @@ -9,15 +10,24 @@ const initialLanguageTag = urlParams.get("initialLanguageTag") || undefined; const initialSearchString = urlParams.get("initialSearchString") || undefined; const initialCustomDisplayName = urlParams.get("initialCustomDisplayName") || undefined; +// Which demo to serve. Defaults to DialogDemo, which is what every URL without this parameter has +// always got; "host-integration" serves HostIntegrationDemo instead. +const demo = urlParams.get("demo") || undefined; + +const demoProps = { + uiLanguage, + initialLanguageTag, + initialSearchString, + initialCustomDisplayName, +}; ReactDOM.render( - + {demo === "host-integration" ? ( + + ) : ( + + )} , document.getElementById("root") );