Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Lingui support, take 2. #1376

Merged
merged 12 commits into from
May 5, 2020
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
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ __pycache__/
node_modules/
tsc-build/
coverage/
locales/_build
locales/**/messages.js
frontend/static/frontend/*.js
frontend/static/frontend/*.css
frontend/static/frontend/*.map
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/lint_typescript.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ jobs:
- name: Install dependencies
run: |
yarn --frozen-lockfile
- name: Build auto-generated TypeScript files
- name: Build auto-generated TypeScript/JS files
run: |
yarn lingui:compile
yarn querybuilder
- name: Run linters
run: |
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ __pycache__/
node_modules/
tsc-build/
coverage/
locales/_build
locales/**/messages.js
frontend/static/frontend/*.js
frontend/static/frontend/*.css
frontend/static/frontend/*.map
Expand Down
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ tsconfig.json
node_modules

# These files are auto-generated.
locales/_build/**/*.json
locales/**/messages.js
frontend/lib/queries
lambda.js
frontend/static
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,25 @@ python3 deploy.py heroku
```

You'll likely want to use [Heroku Postgres][] as your
database backend.
ndatabase backend.

## Internationalization

The front-end uses [Lingui][] for internationalization. To extract
messages for localization, run:

```
yarn lingui:extract
```

Once `.po` files have been updated, the catalogs can be compiled to JS
with:

```
yarn lingui:compile
```

[Lingui]: https://lingui.js.org/

## Optional integrations

Expand Down
12 changes: 12 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from django.core.management import call_command
from django.contrib.auth.models import AnonymousUser, Group
from django.contrib.sessions.middleware import SessionMiddleware
from django.contrib.sites.models import Site
import subprocess
import pytest
import requests_mock as requests_mock_module
Expand Down Expand Up @@ -306,3 +307,14 @@ def mockdocusign(db, settings, monkeypatch):
from docusign.tests.docusign_fixture import mockdocusign

yield from mockdocusign(db, settings, monkeypatch)


@pytest.fixture
def use_norent_site(db):
'''
Set the default site as being the NoRent.org site.
'''

site = Site.objects.get(pk=1)
site.name = "NoRent.org"
site.save()
6 changes: 3 additions & 3 deletions frontend/lambda/tests/lambda.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { AppProps } from "../../lib/app";
import { FakeServerInfo, FakeSessionInfo } from "../../lib/tests/util";

const fakeAppProps: AppProps = {
initialURL: "/",
locale: "",
initialURL: "/en/",
locale: "en",
server: FakeServerInfo,
initialSession: FakeSessionInfo,
};
Expand All @@ -24,7 +24,7 @@ test("lambda redirects", async () => {
initialURL: "/dev/examples/redirect",
});
expect(response.status).toBe(302);
expect(response.location).toBe("/");
expect(response.location).toBe("/en/");
});

test("lambda catches errors", async () => {
Expand Down
24 changes: 17 additions & 7 deletions frontend/lib/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { HelmetProvider } from "react-helmet-async";
import { browserStorage } from "./browser-storage";
import { areAnalyticsEnabled } from "./analytics/analytics";
import { default as JustfixRoutes } from "./routes";
import { LinguiI18n } from "./i18n-lingui";
import { NorentRoutes, getNorentJumpToTopOfPageRoutes } from "./norent/routes";

// Note that these don't need any special fallback loading screens
Expand All @@ -57,6 +58,13 @@ export interface AppProps {
* The locale the user is on. This can be an empty string to
* indicate that localization is disabled, or an ISO 639-1
* code such as 'en' or 'es'.
*
* NOTE: Since the back-end *always* enables localization, this
* will never be the empty string in production. However, some
* tests still assume it will be empty, so we're still allowing
* it for now. For more details, see:
*
* https://github.com/JustFixNYC/tenants2/issues/1382
*/
locale: string;

Expand Down Expand Up @@ -351,13 +359,15 @@ export class AppWithoutRouter extends React.Component<

return (
<ErrorBoundary debug={this.props.server.debug}>
<HistoryBlockerManager>
<AppContext.Provider value={this.getAppContext()}>
<AriaAnnouncer>
<Site {...this.props} ref={this.pageBodyRef} />
</AriaAnnouncer>
</AppContext.Provider>
</HistoryBlockerManager>
<LinguiI18n>
<HistoryBlockerManager>
<AppContext.Provider value={this.getAppContext()}>
<AriaAnnouncer>
<Site {...this.props} ref={this.pageBodyRef} />
</AriaAnnouncer>
</AppContext.Provider>
</HistoryBlockerManager>
</LinguiI18n>
</ErrorBoundary>
);
}
Expand Down
101 changes: 101 additions & 0 deletions frontend/lib/i18n-lingui.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React, { useMemo } from "react";
import { Catalog } from "@lingui/core";
import loadable, { LoadableLibrary } from "@loadable/component";
import { I18nProvider } from "@lingui/react";
import i18n from "./i18n";
import { setupI18n as linguiSetupI18n } from "@lingui/core";

/**
* We use code splitting to make sure that we only load the message
* catalog needed for our currently selected locale.
*
* This defines the type of component whose children prop is a
* callable that receives a Lingui message catalog as its only
* argument.
*/
export type LoadableCatalog = LoadableLibrary<Catalog>;

const EnCatalog: LoadableCatalog = loadable.lib(
() => import("../../locales/en/messages") as any
);

const EsCatalog: LoadableCatalog = loadable.lib(
() => import("../../locales/es/messages") as any
);

/**
* Returns a component that loads the Lingui message catalog for
* the given string.
*/
function getLinguiCatalogForLanguage(locale: string): LoadableCatalog {
switch (locale) {
case "en":
return EnCatalog;
case "es":
return EsCatalog;
}
throw new Error(`Unsupported locale "${locale}"`);
}

const SetupI18n: React.FC<
LinguiI18nProps & {
locale: string;
catalog: Catalog;
}
> = (props) => {
const { locale, catalog } = props;

// This useMemo() call might be overkill. -AV
const ourLinguiI18n = useMemo(() => {
li18n.load({
[locale]: catalog,
});
li18n.activate(locale);
return li18n;
}, [locale, catalog]);

return (
<I18nProvider language={locale} i18n={ourLinguiI18n}>
{props.children}
</I18nProvider>
);
};

export type LinguiI18nProps = {
/** Children to render once localization data is loaded. */
children: React.ReactNode;
};

/**
* Loads the Lingui message catalog for the currently selected
* locale, as dictated by our global i18n module. Children
* will then be rendered with the catalog loaded and ready
* to translate.
*
* While a loading message will appear while the catalog is being loaded,
* because we do server-side rendering and pre-load JS bundles in the
* server-rendered HTML output, the user won't see the message most
* (possibly all) of the time.
*
* Note that this component is currently a singleton; more than one
* instance of it should never exist in a component tree at once.
*/
export const LinguiI18n: React.FC<LinguiI18nProps> = (props) => {
const locale = i18n.locale;

const Catalog = getLinguiCatalogForLanguage(locale);

return (
<Catalog fallback={<p>Loading locale data...</p>}>
{(catalog) => <SetupI18n {...props} locale={locale} catalog={catalog} />}
</Catalog>
);
};

/**
* A global instance of Lingui's I18n object, which can be used to perform
* localization outside of React JSX. Note that this object is populated
* by the <LinguiI18n> component, however, so it should only really be
* used by components that exist below it in the hierarchy.
*/
export const li18n = linguiSetupI18n();
Comment on lines +95 to +101
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made the Lingui I18n instance a global instead of always retrieving it from the component hierarchy because we found in Who Owns What that retrieving it for use outside of JSX tags like <Trans> was a huge hassle, as we constantly had to wrap components needing it in Lingui's <I18n> or withI18n(). Since our locale setting is already global anyways (e.g. accessing Routes.locale.home always dynamically looks up the currently set locale) we might as well roll with it.

Regardless, it looks like the in-development version of Lingui is introducing a useLingui() hook that will make things a lot easier in the future, and migrating to it from this setup should be straightforward.

4 changes: 3 additions & 1 deletion frontend/lib/norent/faqs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { NorentRoutes } from "./routes";
import { FaqsContent, Faq, FaqCategory } from "./data/faqs-content";
import Page from "../ui/page";
import { ScrollyLink } from "../ui/scrolly-link";
import { li18n } from "../i18n-lingui";
import { t } from "@lingui/macro";

const FAQS_PAGE_CATEGORIES_IN_ORDER: FaqCategory[] = [
"Letter Builder",
Expand Down Expand Up @@ -84,7 +86,7 @@ export const NorentFaqsPreview = () => {

export const NorentFaqsPage: React.FC<{}> = () => {
return (
<Page title="FAQs" className="content">
<Page title={li18n._(t`FAQs`)} className="content">
<section className="hero is-medium">
<div className="hero-body">
<div className="container jf-has-text-centered-tablet">
Expand Down
3 changes: 2 additions & 1 deletion frontend/lib/norent/site.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { NorentLetterBuilderRoutes } from "./letter-builder/steps";
import { NorentLogoutPage } from "./log-out";
import { NorentHelmet } from "./components/helmet";
import { NorentLetterEmailToUserStaticPage } from "./letter-email-to-user";
import { Trans } from "@lingui/macro";

function getRoutesForPrimaryPages() {
return new Set(getNorentRoutesForPrimaryPages());
Expand Down Expand Up @@ -95,7 +96,7 @@ const NorentMenuItems: React.FC<{}> = () => {
return (
<>
<Link className="navbar-item" to={Routes.locale.letter.latestStep}>
Build my Letter
<Trans>Build my Letter</Trans>
</Link>
<Link className="navbar-item" to={Routes.locale.aboutLetter}>
The Letter
Expand Down
32 changes: 32 additions & 0 deletions frontend/lib/tests/i18n-lingui.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from "react";
import i18n from "../i18n";
import ReactTestingLibraryPal from "./rtl-pal";
import { LinguiI18n, li18n } from "../i18n-lingui";
import { Trans, t } from "@lingui/macro";
import { wait } from "@testing-library/react";

describe("<LinguiI18n>", () => {
afterEach(ReactTestingLibraryPal.cleanup);

const helloWorldJSX = (
<LinguiI18n>
<Trans>Hello world</Trans>
</LinguiI18n>
);

it("Works in English", async () => {
i18n.initialize("en");
const pal = new ReactTestingLibraryPal(helloWorldJSX);
await wait(() => pal.rr.getByText("Hello world"));
expect(li18n.language).toBe("en");
expect(li18n._(t`Hello world`)).toBe("Hello world");
});

it("works in Spanish", async () => {
i18n.initialize("es");
const pal = new ReactTestingLibraryPal(helloWorldJSX);
await wait(() => pal.rr.getByText("Hola mundo"));
expect(li18n.language).toBe("es");
expect(li18n._(t`Hello world`)).toBe("Hola mundo");
});
});
1 change: 1 addition & 0 deletions frontend/webpack/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const baseBabelOptions = {
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator",
"babel-plugin-macros",
"@loadable/babel-plugin",
],
};
Expand Down
15 changes: 15 additions & 0 deletions lingui.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const assert = require("assert");

const { nodeBabelOptions } = require("./frontend/webpack/base");

let { presets, plugins } = nodeBabelOptions;

assert(presets && plugins);

module.exports = {
extractBabelOptions: { presets, plugins },
localeDir: "locales/",
srcPathDirs: ["frontend/lib/"],
format: "po",
sourceLocale: "en",
};
28 changes: 28 additions & 0 deletions locales/en/messages.po
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2020-05-05 12:34+0000\n"
"Mime-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: en\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"

#: frontend/lib/norent/site.tsx:58
msgid "Build my Letter"
msgstr "Build my Letter"

#: frontend/lib/norent/faqs.tsx:70
msgid "FAQs"
msgstr "FAQs"

#: frontend/lib/tests/i18n-lingui.test.tsx:19
#: frontend/lib/tests/i18n-lingui.test.tsx:26
#: frontend/lib/tests/i18n-lingui.test.tsx:33
msgid "Hello world"
msgstr "Hello world"
Loading