diff --git a/.circleci/config.yml b/.circleci/config.yml index 6c857e45b..25b3dc430 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,6 +9,7 @@ jobs: DEBUG: yup ENABLE_WEBPACK_CONTENT_HASH: yup ENABLE_FINDHELP: yup + ENABLE_I18N: yup SPATIALITE_LIBRARY_PATH: mod_spatialite CC_TEST_REPORTER_ID: 0b47f78787493d017e97f3f141ab138e9188d1ebbe149bb0f28a8ff3314dfdd7 steps: diff --git a/airtable/tests/test_record.py b/airtable/tests/test_record.py index 78ca94e5e..d26425255 100644 --- a/airtable/tests/test_record.py +++ b/airtable/tests/test_record.py @@ -3,6 +3,7 @@ from users.tests.factories import UserFactory from onboarding.tests.factories import OnboardingInfoFactory +from project.tests.util import strip_locale from loc.tests.factories import LetterRequestFactory, LandlordDetailsFactory from airtable.record import Fields @@ -48,7 +49,7 @@ def test_from_user_works_with_letter_request(): fields = Fields.from_user(lr.user) assert fields.letter_request__will_we_mail is True assert fields.letter_request__created_at == datetime.datetime.utcnow().date().isoformat() - assert fields.letter_request__admin_pdf_url == \ + assert strip_locale(fields.letter_request__admin_pdf_url) == \ f'https://example.com/loc/admin/{lr.user.pk}/letter.pdf' diff --git a/frontend/lambda/lambda.tsx b/frontend/lambda/lambda.tsx index 60bae390d..cd048f0d5 100644 --- a/frontend/lambda/lambda.tsx +++ b/frontend/lambda/lambda.tsx @@ -24,6 +24,7 @@ import Helmet from 'react-helmet'; import { ErrorDisplay, getErrorString } from '../lib/error-boundary'; import { App, AppProps } from '../lib/app'; import { appStaticContextAsStaticRouterContext, AppStaticContext } from '../lib/app-static-context'; +import i18n from '../lib/i18n'; const readFile = promisify(fs.readFile); @@ -110,6 +111,7 @@ export function getBundleFiles(files: { file: string }[]): string[] { * lazy loading purposes. */ function generateResponse(event: AppProps, bundleStats: any): Promise { + i18n.initialize(event.locale); return new Promise(resolve => { const context: AppStaticContext = { statusCode: 200, diff --git a/frontend/lambda/tests/lambda.test.tsx b/frontend/lambda/tests/lambda.test.tsx index 4432abb36..17e89d79d 100644 --- a/frontend/lambda/tests/lambda.test.tsx +++ b/frontend/lambda/tests/lambda.test.tsx @@ -8,6 +8,7 @@ import { FakeServerInfo, FakeSessionInfo } from '../../lib/tests/util'; const fakeAppProps: AppProps = { initialURL: '/', + locale: '', server: FakeServerInfo, initialSession: FakeSessionInfo }; diff --git a/frontend/lib/app.tsx b/frontend/lib/app.tsx index 130c026e4..42d084e81 100644 --- a/frontend/lib/app.tsx +++ b/frontend/lib/app.tsx @@ -28,6 +28,13 @@ export interface AppProps { /** The initial URL to render on page load. */ initialURL: string; + /** + * 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'. + */ + locale: string; + /** The initial session state the App was started with. */ initialSession: AllSessionInfo; @@ -189,22 +196,30 @@ export class AppWithoutRouter extends React.Component): JSX.Element { return ( - + - + - + {getOnboardingRouteForIntent(OnboardingInfoSignupIntent.LOC)} - + {getOnboardingRouteForIntent(OnboardingInfoSignupIntent.HP)} - + ); } + renderRoute(props: RouteComponentProps): JSX.Element { + const { pathname } = props.location; + if (routeMap.exists(pathname)) { + return this.renderRoutes(props.location); + } + return NotFound(props); + } + render() { if (this.props.modal) { return @@ -220,12 +235,7 @@ export class AppWithoutRouter extends React.Component - { - if (routeMap.exists(props.location.pathname)) { - return this.renderRoutes(props.location); - } - return NotFound(props); - }}/> + this.renderRoute(props)}/> diff --git a/frontend/lib/dev.tsx b/frontend/lib/dev.tsx index 57beda3f5..2b41a736e 100644 --- a/frontend/lib/dev.tsx +++ b/frontend/lib/dev.tsx @@ -75,7 +75,7 @@ export default function DevRoutes(): JSX.Element { return ( - } /> + } /> diff --git a/frontend/lib/hp-action.tsx b/frontend/lib/hp-action.tsx index 729741dab..6e718889f 100644 --- a/frontend/lib/hp-action.tsx +++ b/frontend/lib/hp-action.tsx @@ -16,7 +16,7 @@ import { GetHPActionUploadStatus } from './queries/GetHPActionUploadStatus'; import { Redirect } from 'react-router'; import { SessionPoller } from './session-poller'; -const onboardingForHPActionRoute = Routes.hp.onboarding.latestStep; +const onboardingForHPActionRoute = () => Routes.locale.hp.onboarding.latestStep; function HPActionSplash(): JSX.Element { return ( @@ -25,7 +25,7 @@ function HPActionSplash(): JSX.Element {

Welcome to JustFix.nyc! This website will guide you through the process of starting an HP Action proceeding.

An HP Action is a legal case you can bring against your landlord for failing to make repairs, not providing essential services, or harassing you.

This service is free, secure, and confidential.

- + Start my case @@ -48,7 +48,7 @@ const HPActionWelcome = withAppContext((props: AppContextType) => {
  • Print out this packet and bring it to Housing Court. It will include instructions for filing in court and serving your landlord.
  • - + Select repair issues
    @@ -62,9 +62,9 @@ const HPActionWelcome = withAppContext((props: AppContextType) => { const HPActionIssuesRoutes = () => ( ); @@ -83,7 +83,7 @@ const LandlordDetails = (props: { details: AllSessionInfo_landlordDetails }) => const GeneratePDFForm = (props: { children: FormContextRenderer<{}> }) => ( + onSuccessRedirect={Routes.locale.hp.waitForUpload} {...props} /> ); const HPActionYourLandlord = withAppContext((props: AppContextType) => { @@ -98,7 +98,7 @@ const HPActionYourLandlord = withAppContext((props: AppContextType) => { {(ctx) =>
    - +
    } @@ -139,13 +139,13 @@ const ShowHPUploadStatus = withAppContext((props: AppContextType) => { return ; case HPUploadStatus.SUCCEEDED: - return ; + return ; case HPUploadStatus.ERRORED: return ; case HPUploadStatus.NOT_STARTED: - return ; + return ; } }); @@ -174,27 +174,27 @@ const HPActionConfirmation = withAppContext((props: AppContextType) => { ); }); -export const HPActionProgressRoutesProps: ProgressRoutesProps = { - toLatestStep: Routes.hp.latestStep, +export const getHPActionProgressRoutesProps = (): ProgressRoutesProps => ({ + toLatestStep: Routes.locale.hp.latestStep, label: "HP Action", welcomeSteps: [{ - path: Routes.hp.splash, exact: true, component: HPActionSplash, + path: Routes.locale.hp.splash, exact: true, component: HPActionSplash, isComplete: (s) => !!s.phoneNumber }, { - path: Routes.hp.welcome, exact: true, component: HPActionWelcome + path: Routes.locale.hp.welcome, exact: true, component: HPActionWelcome }], stepsToFillOut: [ - { path: Routes.hp.issues.prefix, component: HPActionIssuesRoutes }, - { path: Routes.hp.yourLandlord, exact: true, component: HPActionYourLandlord, + { path: Routes.locale.hp.issues.prefix, component: HPActionIssuesRoutes }, + { path: Routes.locale.hp.yourLandlord, exact: true, component: HPActionYourLandlord, isComplete: (s) => s.hpActionUploadStatus !== HPUploadStatus.NOT_STARTED }, ], confirmationSteps: [ - { path: Routes.hp.waitForUpload, exact: true, component: ShowHPUploadStatus, + { path: Routes.locale.hp.waitForUpload, exact: true, component: ShowHPUploadStatus, isComplete: (s) => s.hpActionUploadStatus === HPUploadStatus.SUCCEEDED }, - { path: Routes.hp.confirmation, exact: true, component: HPActionConfirmation} + { path: Routes.locale.hp.confirmation, exact: true, component: HPActionConfirmation} ] -}; +}); -const HPActionRoutes = buildProgressRoutesComponent(HPActionProgressRoutesProps); +const HPActionRoutes = buildProgressRoutesComponent(getHPActionProgressRoutesProps); export default HPActionRoutes; diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts new file mode 100644 index 000000000..c4a775e3c --- /dev/null +++ b/frontend/lib/i18n.ts @@ -0,0 +1,103 @@ +/** + * This class keeps track of internationalization-related data. + * + * Instances start out uninitialized, and must be explicitly + * initialized before any other methods or properties can be + * accessed. + * + * Once initialized, an instance can actually be re-initialized; + * clients can register to be notified if and when this happens. + */ +export class I18n { + private _locale: null|string = null; + private _changeListeners: Function[] = []; + + /** + * Create an instance, optionally auto-initializing it. + * + * @param locale An empty string to indicate that localization is + * disabled, or an ISO 639-1 code such as 'en' or 'es'. + */ + constructor(locale?: string) { + if (typeof(locale) === 'string') { + this.initialize(locale); + } + } + + private raiseInitError(): never { + throw new Error('i18n is not initialized!'); + } + + /** + * Return the current locale, raising an error if the + * class is uninitialized. + * + * If the locale is set to the empty string, it means that + * localization is currently disabled. Otherwise, the + * string will be an ISO 639-1 code such as 'en' or 'es'. + */ + get locale(): string { + if (this._locale === null) return this.raiseInitError(); + return this._locale; + } + + /** + * Return the URL path prefix for the current locale. + * If localization is disabled, this will be the + * empty string; otherwise, it will be a slash followed + * by the locale's ISO 639-1 code, e.g. '/en'. + */ + get localePathPrefix(): string { + const { locale } = this; + return locale === '' ? '' : `/${locale}`; + } + + /** + * Initialize the instance to the given locale. + * + * @param locale An empty string to indicate that localization is + * disabled, or an ISO 639-1 code such as 'en' or 'es'. + */ + initialize(locale: string) { + this._locale = locale; + this._changeListeners.forEach(cb => cb()); + } + + /** Return whether the instance is initialized. */ + get isInitialized(): boolean { + return this._locale !== null; + } + + /** + * Register a listener to be notified when the instance + * is initialized or re-initialized. + */ + addChangeListener(cb: Function) { + this._changeListeners.push(cb); + } + + /** Unregister a previously-registered listener. */ + removeChangeListener(cb: Function) { + const index = this._changeListeners.indexOf(cb); + if (index === -1) { + throw new Error('change listener does not exist!'); + } + this._changeListeners.splice(index, 1); + } +} + +/** + * This is a global singleton for the JS runtime. It's largely + * a singleton because passing it around everywhere would be + * a massive headache, especially given the state of the codebase + * at the time that internationalization was introduced. + * + * That said, one should prefer to write client code in a way + * such that an I18n object is passed into it, rather than + * grabbing this singleton directly. This will make it easier + * to unit test, as well as to eventually get rid of the global + * singleton altogether. + */ +const i18n = new I18n(); + +export default i18n; diff --git a/frontend/lib/letter-of-complaint.tsx b/frontend/lib/letter-of-complaint.tsx index 10c24337b..2c5c27972 100644 --- a/frontend/lib/letter-of-complaint.tsx +++ b/frontend/lib/letter-of-complaint.tsx @@ -24,7 +24,7 @@ export const Welcome = withAppContext((props: AppContextType): JSX.Element => {
  • First, conduct a self-inspection of your apartment to document all the issues that need repair.
  • Review your Letter of Complaint and JustFix.nyc will send it to your landlord via USPS Certified Mail®.
  • - + Start my free letter
    @@ -42,30 +42,30 @@ export const Welcome = withAppContext((props: AppContextType): JSX.Element => { const LetterOfComplaintIssuesRoutes = () => ( ); -export const LOCProgressRoutesProps: ProgressRoutesProps = { - toLatestStep: Routes.loc.latestStep, +export const getLOCProgressRoutesProps = (): ProgressRoutesProps => ({ + toLatestStep: Routes.locale.loc.latestStep, label: "Letter of Complaint", welcomeSteps: [{ - path: Routes.loc.home, exact: true, component: Welcome + path: Routes.locale.loc.home, exact: true, component: Welcome }], stepsToFillOut: [ - { path: Routes.loc.issues.prefix, component: LetterOfComplaintIssuesRoutes }, - { path: Routes.loc.accessDates, exact: true, component: AccessDatesPage }, - { path: Routes.loc.yourLandlord, exact: true, component: LandlordDetailsPage }, - { path: Routes.loc.preview, component: LetterRequestPage, + { path: Routes.locale.loc.issues.prefix, component: LetterOfComplaintIssuesRoutes }, + { path: Routes.locale.loc.accessDates, exact: true, component: AccessDatesPage }, + { path: Routes.locale.loc.yourLandlord, exact: true, component: LandlordDetailsPage }, + { path: Routes.locale.loc.preview, component: LetterRequestPage, isComplete: sess => !!sess.letterRequest }, ], confirmationSteps: [{ - path: Routes.loc.confirmation, exact: true, component: LetterConfirmation + path: Routes.locale.loc.confirmation, exact: true, component: LetterConfirmation }] -}; +}); -const LetterOfComplaintRoutes = buildProgressRoutesComponent(LOCProgressRoutesProps); +const LetterOfComplaintRoutes = buildProgressRoutesComponent(getLOCProgressRoutesProps); export default LetterOfComplaintRoutes; diff --git a/frontend/lib/main.ts b/frontend/lib/main.ts index 35db5268a..fbdda7ca3 100644 --- a/frontend/lib/main.ts +++ b/frontend/lib/main.ts @@ -3,6 +3,7 @@ import { startApp, AppProps } from './app'; import { getElement } from './util'; import { ga } from './google-analytics'; +import i18n from './i18n'; function polyfillSmoothScroll() { @@ -46,6 +47,7 @@ window.addEventListener('load', () => { // Since JS is now loaded, let's remove that restriction. div.removeAttribute('hidden'); + i18n.initialize(initialProps.locale); startApp(div, initialProps); polyfillSmoothScroll(); showSafeModeUiOnShake(); diff --git a/frontend/lib/navbar.tsx b/frontend/lib/navbar.tsx index 0a5498fde..1d8c7587e 100644 --- a/frontend/lib/navbar.tsx +++ b/frontend/lib/navbar.tsx @@ -118,7 +118,7 @@ class NavbarWithoutAppContext extends React.Component return (
    - +
    {session.isStaff && Admin} {session.phoneNumber - ? Sign out - : Sign in } + ? Sign out + : Sign in } {this.renderDevMenu()}
    diff --git a/frontend/lib/pages/access-dates.tsx b/frontend/lib/pages/access-dates.tsx index 9ec53a555..8e39a5709 100644 --- a/frontend/lib/pages/access-dates.tsx +++ b/frontend/lib/pages/access-dates.tsx @@ -36,7 +36,7 @@ function renderForm(ctx: FormContext): JSX.Element {
    - +
    @@ -52,7 +52,7 @@ export default function AccessDatesPage(): JSX.Element { getInitialState(session.accessDates)} - onSuccessRedirect={Routes.loc.yourLandlord} + onSuccessRedirect={Routes.locale.loc.yourLandlord} > {renderForm} diff --git a/frontend/lib/pages/example-form-page.tsx b/frontend/lib/pages/example-form-page.tsx index 8879e9810..39f8d2281 100644 --- a/frontend/lib/pages/example-form-page.tsx +++ b/frontend/lib/pages/example-form-page.tsx @@ -71,7 +71,7 @@ export default function ExampleFormPage(): JSX.Element { Use the form in a modal - + ); } diff --git a/frontend/lib/pages/index-page.tsx b/frontend/lib/pages/index-page.tsx index 7b7e34aba..c32cc07c3 100644 --- a/frontend/lib/pages/index-page.tsx +++ b/frontend/lib/pages/index-page.tsx @@ -13,7 +13,7 @@ export interface IndexPageProps { isLoggedIn: boolean; } -const onboardingForLOCRoute = Routes.onboarding.latestStep; +const onboardingForLOCRoute = () => Routes.locale.onboarding.latestStep; export default class IndexPage extends React.Component { renderLoggedOut() { @@ -32,10 +32,10 @@ export default class IndexPage extends React.Component {

    JustFix.nyc is a free tool that notifies your landlord of repair issues via USPS Certified Mail®. Everything is documented, confidential, and secure.

    - + Start my free letter -

    Already have an account? Sign in!

    +

    Already have an account? Sign in!

    @@ -69,7 +69,7 @@ export default class IndexPage extends React.Component { - + Start my free letter diff --git a/frontend/lib/pages/landlord-details.tsx b/frontend/lib/pages/landlord-details.tsx index fce34aa8a..3a3724ada 100644 --- a/frontend/lib/pages/landlord-details.tsx +++ b/frontend/lib/pages/landlord-details.tsx @@ -19,9 +19,9 @@ const BLANK_INPUT: LandlordDetailsInput = { address: '' }; -const PREV_STEP = Routes.loc.accessDates; +const PREV_STEP = () => Routes.locale.loc.accessDates; -const NEXT_STEP = Routes.loc.preview; +const NEXT_STEP = () => Routes.locale.loc.preview; function renderForm(ctx: FormContext): JSX.Element { return ( @@ -29,7 +29,7 @@ function renderForm(ctx: FormContext): JSX.Element {
    - +
    @@ -68,8 +68,8 @@ function ReadOnlyLandlordDetails(props: {details: AllSessionInfo_landlordDetails
    {splitLines(details.address)}
    - - Preview letter + + Preview letter
    ); diff --git a/frontend/lib/pages/letter-request.tsx b/frontend/lib/pages/letter-request.tsx index 1308d4999..a49c7c6a2 100644 --- a/frontend/lib/pages/letter-request.tsx +++ b/frontend/lib/pages/letter-request.tsx @@ -56,7 +56,7 @@ function FormAsButton(props: FormAsButtonProps): JSX.Element { mutation={LetterRequestMutation} formId={'button_' + props.mailChoice} initialState={input} - onSuccessRedirect={Routes.loc.confirmation} + onSuccessRedirect={Routes.locale.loc.confirmation} > {(ctx) => <> @@ -79,11 +79,11 @@ export default function LetterRequestPage(): JSX.Element {

    Here is a preview of the letter for you to review. It includes the repair issues you selected from the Issue Checklist.

    - + Looks good to me!
    - + & AppContextType): JSX.Element => { let next = absolutifyURLToOurOrigin( - getPostOrQuerystringVar(props, NEXT) || Routes.home, + getPostOrQuerystringVar(props, NEXT) || Routes.locale.home, props.server.originURL ); diff --git a/frontend/lib/pages/logout-page.tsx b/frontend/lib/pages/logout-page.tsx index 6337b8dbb..4dc31e63d 100644 --- a/frontend/lib/pages/logout-page.tsx +++ b/frontend/lib/pages/logout-page.tsx @@ -19,7 +19,7 @@ export const LogoutPage = withAppContext((props: AppContextType) => { mutation={LogoutMutation} initialState={{}} // This looks odd but it's required for legacy POST to work. - onSuccessRedirect={Routes.logout} + onSuccessRedirect={Routes.locale.logout} >{(ctx) => ( )} @@ -30,7 +30,7 @@ export const LogoutPage = withAppContext((props: AppContextType) => { return (

    You are now signed out.

    -

    Sign back in

    +

    Sign back in

    ); } diff --git a/frontend/lib/progress-routes.tsx b/frontend/lib/progress-routes.tsx index 3dfd21d61..73ac08b33 100644 --- a/frontend/lib/progress-routes.tsx +++ b/frontend/lib/progress-routes.tsx @@ -78,6 +78,6 @@ export function ProgressRoutes(props: ProgressRoutesProps): JSX.Element { ); } -export function buildProgressRoutesComponent(props: ProgressRoutesProps): () => JSX.Element { - return () => ; +export function buildProgressRoutesComponent(getProps: () => ProgressRoutesProps): () => JSX.Element { + return () => ; } diff --git a/frontend/lib/routes.ts b/frontend/lib/routes.ts index bf2555438..d21506476 100644 --- a/frontend/lib/routes.ts +++ b/frontend/lib/routes.ts @@ -1,5 +1,6 @@ import { matchPath, RouteComponentProps } from 'react-router-dom'; import { OnboardingInfoSignupIntent } from './queries/globalTypes'; +import i18n from './i18n'; /** * Metadata about signup intents. @@ -23,12 +24,12 @@ type SignupIntentOnboardingInfo = { export function getSignupIntentOnboardingInfo(intent: OnboardingInfoSignupIntent): SignupIntentOnboardingInfo { switch (intent) { case OnboardingInfoSignupIntent.LOC: return { - preOnboarding: Routes.home, - postOnboarding: Routes.loc.latestStep, - onboarding: Routes.onboarding + preOnboarding: Routes.locale.home, + postOnboarding: Routes.locale.loc.latestStep, + onboarding: Routes.locale.onboarding }; - case OnboardingInfoSignupIntent.HP: return Routes.hp; + case OnboardingInfoSignupIntent.HP: return Routes.locale.hp; } } @@ -87,12 +88,79 @@ function createOnboardingRouteInfo(prefix: string) { }; } +export type LetterOfComplaintInfo = ReturnType; + +function createLetterOfComplaintRouteInfo(prefix: string) { + return { + [ROUTE_PREFIX]: prefix, + latestStep: prefix, + home: `${prefix}/welcome`, + issues: createIssuesRouteInfo(`${prefix}/issues`), + accessDates: `${prefix}/access-dates`, + yourLandlord: `${prefix}/your-landlord`, + preview: `${prefix}/preview`, + previewSendConfirmModal: `${prefix}/preview/send-confirm-modal`, + confirmation: `${prefix}/confirmation` + }; +} + +export type HPActionInfo = ReturnType; + +function createHPActionRouteInfo(prefix: string) { + return { + [ROUTE_PREFIX]: prefix, + latestStep: prefix, + preOnboarding: `${prefix}/splash`, + splash: `${prefix}/splash`, + onboarding: createOnboardingRouteInfo(`${prefix}/onboarding`), + postOnboarding: prefix, + welcome: `${prefix}/welcome`, + issues: createIssuesRouteInfo(`${prefix}/issues`), + yourLandlord: `${prefix}/your-landlord`, + waitForUpload: `${prefix}/wait`, + confirmation: `${prefix}/confirmation`, + } +} + +export type LocalizedRouteInfo = ReturnType; + +function createLocalizedRouteInfo(prefix: string) { + return { + /** The login page. */ + login: `${prefix}/login`, + + /** The logout page. */ + logout: `${prefix}/logout`, + + /** The home page. */ + home: `${prefix}/`, + + /** The onboarding flow. */ + onboarding: createOnboardingRouteInfo(`${prefix}/onboarding`), + + /** The Letter of Complaint flow. */ + loc: createLetterOfComplaintRouteInfo(`${prefix}/loc`), + + /** The HP Action flow. */ + hp: createHPActionRouteInfo(`${prefix}/hp`), + } +} + +let currentLocaleRoutes: LocalizedRouteInfo|null = null; + +i18n.addChangeListener(() => { currentLocaleRoutes = null; }); + /** * This is an ad-hoc structure that defines URL routes for our app. */ const Routes = { - /** The login page. */ - login: '/login', + /** Localized routes for the user's currently-selected locale. */ + get locale(): LocalizedRouteInfo { + if (currentLocaleRoutes === null) { + currentLocaleRoutes = createLocalizedRouteInfo(i18n.localePathPrefix); + } + return currentLocaleRoutes; + }, /** * The *admin* login page. We override Django's default admin login @@ -101,50 +169,13 @@ const Routes = { */ adminLogin: '/admin/login/', - /** The logout page. */ - logout: '/logout', - - /** The home page. */ - home: '/', - - /** The onboarding flow. */ - onboarding: createOnboardingRouteInfo('/onboarding'), - - /** The Letter of Complaint flow. */ - loc: { - [ROUTE_PREFIX]: '/loc', - latestStep: '/loc', - home: '/loc/welcome', - issues: createIssuesRouteInfo('/loc/issues'), - accessDates: '/loc/access-dates', - yourLandlord: '/loc/your-landlord', - preview: '/loc/preview', - previewSendConfirmModal: '/loc/preview/send-confirm-modal', - confirmation: '/loc/confirmation' - }, - - /** The HP Action flow. */ - hp: { - [ROUTE_PREFIX]: '/hp', - latestStep: '/hp', - preOnboarding: '/hp/splash', - splash: '/hp/splash', - onboarding: createOnboardingRouteInfo('/hp/onboarding'), - postOnboarding: '/hp', - welcome: '/hp/welcome', - issues: createIssuesRouteInfo('/hp/issues'), - yourLandlord: '/hp/your-landlord', - waitForUpload: '/hp/wait', - confirmation: '/hp/confirmation', - }, - /** * Example pages used in integration tests, and other * development-related pages. */ dev: { [ROUTE_PREFIX]: '/dev', - home: '/dev', + home: '/dev/', examples: { [ROUTE_PREFIX]: '/dev/examples', redirect: '/dev/examples/redirect', @@ -186,9 +217,16 @@ export function isParameterizedRoute(path: string): boolean { export class RouteMap { private existenceMap: Map = new Map(); private parameterizedRoutes: string[] = []; + private isInitialized = false; + + constructor(private readonly routes: any) { + } - constructor(routes: any) { - this.populate(routes); + private ensureIsInitialized() { + if (!this.isInitialized) { + this.populate(this.routes); + this.isInitialized = true; + } } private populate(routes: any) { @@ -207,6 +245,7 @@ export class RouteMap { } get size(): number { + this.ensureIsInitialized(); return this.existenceMap.size + this.parameterizedRoutes.length; } @@ -214,6 +253,7 @@ export class RouteMap { * Return an iterator that yields all routes that don't have parameters. */ nonParameterizedRoutes(): IterableIterator { + this.ensureIsInitialized(); return this.existenceMap.keys(); } @@ -228,6 +268,7 @@ export class RouteMap { * further down the view heirarchy to resolve. */ exists(pathname: string): boolean { + this.ensureIsInitialized(); if (this.existenceMap.has(pathname)) { return true; } diff --git a/frontend/lib/tests/app.test.tsx b/frontend/lib/tests/app.test.tsx index ceab64c23..381efea99 100644 --- a/frontend/lib/tests/app.test.tsx +++ b/frontend/lib/tests/app.test.tsx @@ -9,6 +9,7 @@ describe('AppWithoutRouter', () => { const { client } = createTestGraphQlClient(); const props: AppPropsWithRouter = { initialURL: '/', + locale: '', initialSession: FakeSessionInfo, server: FakeServerInfo, history: {} as any, diff --git a/frontend/lib/tests/hp-action.test.tsx b/frontend/lib/tests/hp-action.test.tsx index ee197211a..2679c0e69 100644 --- a/frontend/lib/tests/hp-action.test.tsx +++ b/frontend/lib/tests/hp-action.test.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { AppTesterPal } from "./app-tester-pal"; -import HPActionRoutes, { HPActionProgressRoutesProps } from '../hp-action'; +import HPActionRoutes, { getHPActionProgressRoutesProps } from '../hp-action'; import { ProgressRoutesTester } from './progress-routes-tester'; import Routes from '../routes'; import { HPUploadStatus } from '../queries/globalTypes'; -const tester = new ProgressRoutesTester(HPActionProgressRoutesProps, 'HP Action'); +const tester = new ProgressRoutesTester(getHPActionProgressRoutesProps(), 'HP Action'); tester.defineSmokeTests(); @@ -57,23 +57,23 @@ describe('upload status page', () => { describe('latest step redirector', () => { it('returns splash page when user is not logged-in', () => { - expect(tester.getLatestStep()).toBe(Routes.hp.splash); + expect(tester.getLatestStep()).toBe(Routes.locale.hp.splash); }); it('returns welcome page when user is logged-in', () => { - expect(tester.getLatestStep({ phoneNumber: '123' })).toBe(Routes.hp.welcome); + expect(tester.getLatestStep({ phoneNumber: '123' })).toBe(Routes.locale.hp.welcome); }); it('returns wait page when user has started document assembly', () => { expect(tester.getLatestStep({ hpActionUploadStatus: HPUploadStatus.STARTED - })).toBe(Routes.hp.waitForUpload); + })).toBe(Routes.locale.hp.waitForUpload); }); it('returns confirmation page when user has generated a PDF', () => { expect(tester.getLatestStep({ latestHpActionPdfUrl: '/boop.pdf', hpActionUploadStatus: HPUploadStatus.SUCCEEDED - })).toBe(Routes.hp.confirmation); + })).toBe(Routes.locale.hp.confirmation); }); }); diff --git a/frontend/lib/tests/i18n.test.ts b/frontend/lib/tests/i18n.test.ts new file mode 100644 index 000000000..34d06566d --- /dev/null +++ b/frontend/lib/tests/i18n.test.ts @@ -0,0 +1,45 @@ +import { I18n } from "../i18n"; + +const UNINIT_RE = /i18n is not initialized/i; + +describe('I18n', () => { + it('raises exception on methods/properties if uninitialized', () => { + const i18n = new I18n(); + expect(() => i18n.locale).toThrow(UNINIT_RE); + expect(() => i18n.localePathPrefix).toThrow(UNINIT_RE); + }); + + it('returns whether it is initialized or not', () => { + expect(new I18n('en').isInitialized).toBe(true); + + const i18n = new I18n(); + expect(i18n.isInitialized).toBe(false); + i18n.initialize('en'); + expect(i18n.isInitialized).toBe(true); + }); + + it('raises error if nonexistent listener is removed', () => { + expect(() => new I18n().removeChangeListener(() => {})) + .toThrow(/change listener does not exist/i); + }); + + it('notifies listeners on initialization', () => { + let calls = 0; + const i18n = new I18n(); + const cb = () => calls++; + i18n.addChangeListener(cb); + expect(calls).toBe(0); + i18n.initialize('en'); + expect(calls).toBe(1); + i18n.initialize('es'); + expect(calls).toBe(2); + i18n.removeChangeListener(cb); + i18n.initialize('de'); + expect(calls).toBe(2); + }); + + it('returns locale path prefixes', () => { + expect(new I18n('').localePathPrefix).toBe(''); + expect(new I18n('en').localePathPrefix).toBe('/en'); + }); +}); diff --git a/frontend/lib/tests/letter-of-complaint.test.tsx b/frontend/lib/tests/letter-of-complaint.test.tsx index 8d42202b1..e3d777d65 100644 --- a/frontend/lib/tests/letter-of-complaint.test.tsx +++ b/frontend/lib/tests/letter-of-complaint.test.tsx @@ -1,19 +1,19 @@ -import { LOCProgressRoutesProps } from '../letter-of-complaint'; +import { getLOCProgressRoutesProps } from '../letter-of-complaint'; import Routes from '../routes'; import { ProgressRoutesTester } from './progress-routes-tester'; -const tester = new ProgressRoutesTester(LOCProgressRoutesProps, 'letter of complaint'); +const tester = new ProgressRoutesTester(getLOCProgressRoutesProps(), 'letter of complaint'); tester.defineSmokeTests(); describe('latest step redirector', () => { it('returns welcome page by default', () => { - expect(tester.getLatestStep()).toBe(Routes.loc.home); + expect(tester.getLatestStep()).toBe(Routes.locale.loc.home); }); it('returns confirmation page if letter request has been submitted', () => { expect(tester.getLatestStep({ letterRequest: {} as any - })).toBe(Routes.loc.confirmation); + })).toBe(Routes.locale.loc.confirmation); }); }); diff --git a/frontend/lib/tests/onboarding.test.tsx b/frontend/lib/tests/onboarding.test.tsx index 5a4110dc6..058451195 100644 --- a/frontend/lib/tests/onboarding.test.tsx +++ b/frontend/lib/tests/onboarding.test.tsx @@ -11,7 +11,7 @@ import { OnboardingInfoSignupIntent } from '../queries/globalTypes'; const PROPS: OnboardingRoutesProps = { toCancel: '/cancel', toSuccess: '/success', - routes: Routes.onboarding, + routes: Routes.locale.onboarding, signupIntent: OnboardingInfoSignupIntent.LOC }; @@ -22,14 +22,14 @@ describe('latest step redirector', () => { } it('returns step 1 by default', () => { - expect(getLatestOnboardingStep(FakeSessionInfo)).toBe(Routes.onboarding.step1); + expect(getLatestOnboardingStep(FakeSessionInfo)).toBe(Routes.locale.onboarding.step1); }); it('returns step 2 when step 1 is complete', () => { expect(getLatestOnboardingStep({ ...FakeSessionInfo, onboardingStep1: {} as any - })).toBe(Routes.onboarding.step2); + })).toBe(Routes.locale.onboarding.step2); }); it('returns step 3 when step 2 is complete', () => { @@ -37,7 +37,7 @@ describe('latest step redirector', () => { ...FakeSessionInfo, onboardingStep1: {} as any, onboardingStep2: {} as any - })).toBe(Routes.onboarding.step3); + })).toBe(Routes.locale.onboarding.step3); }); it('returns step 4 when step 3 is complete', () => { @@ -46,7 +46,7 @@ describe('latest step redirector', () => { onboardingStep1: {} as any, onboardingStep2: {} as any, onboardingStep3: {} as any - })).toBe(Routes.onboarding.step4); + })).toBe(Routes.locale.onboarding.step4); }); }); @@ -55,7 +55,7 @@ describe('Onboarding', () => { it('redirects to latest step', () => { const pal = new AppTesterPal(, { - url: Routes.onboarding.latestStep + url: Routes.locale.onboarding.latestStep }); expect(pal.history.location.pathname).toEqual('/onboarding/step/1'); pal.rr.getByLabelText('First name'); diff --git a/frontend/lib/tests/pages/access-dates.test.tsx b/frontend/lib/tests/pages/access-dates.test.tsx index 58c8c5fa6..9e41c7bb2 100644 --- a/frontend/lib/tests/pages/access-dates.test.tsx +++ b/frontend/lib/tests/pages/access-dates.test.tsx @@ -12,7 +12,7 @@ describe('access dates page', () => { it('redirects to next step after successful submission', async () => { const pal = new AppTesterPal(, { - url: Routes.loc.accessDates + url: Routes.locale.loc.accessDates }); pal.fillFormFields([ diff --git a/frontend/lib/tests/pages/index-page.test.tsx b/frontend/lib/tests/pages/index-page.test.tsx index a94ff11e9..222bbc0f8 100644 --- a/frontend/lib/tests/pages/index-page.test.tsx +++ b/frontend/lib/tests/pages/index-page.test.tsx @@ -5,7 +5,7 @@ import Routes from '../../routes'; describe('index page', () => { it('renders when logged in', () => { - ensureRedirect(, Routes.loc.latestStep); + ensureRedirect(, Routes.locale.loc.latestStep); }); it('renders when logged out', () => { diff --git a/frontend/lib/tests/pages/issue-pages.test.tsx b/frontend/lib/tests/pages/issue-pages.test.tsx index 6fb804f66..9e8375743 100644 --- a/frontend/lib/tests/pages/issue-pages.test.tsx +++ b/frontend/lib/tests/pages/issue-pages.test.tsx @@ -9,10 +9,10 @@ import ISSUE_AREA_SVGS from '../../svg/issues'; import { IssueAreaChoices } from '../../../../common-data/issue-area-choices'; -const routes = Routes.loc.issues; +const routes = Routes.locale.loc.issues; const TestIssuesRoutes = () => - ; + ; describe('issues checklist', () => { afterEach(AppTesterPal.cleanup); diff --git a/frontend/lib/tests/pages/landlord-details.test.tsx b/frontend/lib/tests/pages/landlord-details.test.tsx index abbc0e412..5611606b8 100644 --- a/frontend/lib/tests/pages/landlord-details.test.tsx +++ b/frontend/lib/tests/pages/landlord-details.test.tsx @@ -24,7 +24,7 @@ describe('landlord details page', () => { it('works when details are not looked up', () => { const pal = new AppTesterPal(, { - url: Routes.loc.yourLandlord, + url: Routes.locale.loc.yourLandlord, session: { landlordDetails: BLANK_LANDLORD_DETAILS } }); pal.rr.getByText(/Please enter your landlord's name/i); @@ -34,7 +34,7 @@ describe('landlord details page', () => { it('works when details are looked up', () => { const pal = new AppTesterPal(, { - url: Routes.loc.yourLandlord, + url: Routes.locale.loc.yourLandlord, session: { landlordDetails: LOOKED_UP_LANDLORD_DETAILS } }); pal.rr.getByText(/This may be different .+ rent checks/i); @@ -44,7 +44,7 @@ describe('landlord details page', () => { it('redirects to next step after successful submission', async () => { const pal = new AppTesterPal(, { - url: Routes.loc.yourLandlord, + url: Routes.locale.loc.yourLandlord, session: { landlordDetails: BLANK_LANDLORD_DETAILS } }); const name = "Boop Jones"; diff --git a/frontend/lib/tests/pages/letter-request.test.tsx b/frontend/lib/tests/pages/letter-request.test.tsx index 9e2bd3cf8..b7e00da84 100644 --- a/frontend/lib/tests/pages/letter-request.test.tsx +++ b/frontend/lib/tests/pages/letter-request.test.tsx @@ -31,7 +31,7 @@ describe('landlord details page', () => { it('works when user chooses to mail the letter themselves', async () => { const pal = new AppTesterPal(, { - url: Routes.loc.preview, + url: Routes.locale.loc.preview, session: { letterRequest: PRE_EXISTING_LETTER_REQUEST } }); clickButtonAndExpectChoice(pal, /mail this myself/i, LetterRequestMailChoice.USER_WILL_MAIL); @@ -39,7 +39,7 @@ describe('landlord details page', () => { it('works when user wants us to mail the letter', async () => { const pal = new AppTesterPal(, { - url: Routes.loc.preview, + url: Routes.locale.loc.preview, session: { letterRequest: PRE_EXISTING_LETTER_REQUEST } }); pal.clickButtonOrLink(/looks good to me/i); diff --git a/frontend/lib/tests/pages/loc-confirmation.test.tsx b/frontend/lib/tests/pages/loc-confirmation.test.tsx index 1e2f9d46e..6637c5596 100644 --- a/frontend/lib/tests/pages/loc-confirmation.test.tsx +++ b/frontend/lib/tests/pages/loc-confirmation.test.tsx @@ -10,7 +10,7 @@ describe('letter of complaint confirmation', () => { const createPal = (mailChoice: LetterRequestMailChoice) => new AppTesterPal(, { - url: Routes.loc.confirmation, + url: Routes.locale.loc.confirmation, session: { letterRequest: { updatedAt: "2018-09-14T01:42:12.829983+00:00", diff --git a/frontend/lib/tests/pages/onboarding-step-1.test.tsx b/frontend/lib/tests/pages/onboarding-step-1.test.tsx index cf493ba0c..3a52b6f58 100644 --- a/frontend/lib/tests/pages/onboarding-step-1.test.tsx +++ b/frontend/lib/tests/pages/onboarding-step-1.test.tsx @@ -8,7 +8,7 @@ import { FakeGeoResults } from '../util'; import Routes from '../../routes'; const PROPS = { - routes: Routes.onboarding, + routes: Routes.locale.onboarding, toCancel: '/cancel' }; diff --git a/frontend/lib/tests/pages/onboarding-step-2.test.tsx b/frontend/lib/tests/pages/onboarding-step-2.test.tsx index 9a6b80d14..42cc3b8ae 100644 --- a/frontend/lib/tests/pages/onboarding-step-2.test.tsx +++ b/frontend/lib/tests/pages/onboarding-step-2.test.tsx @@ -9,7 +9,7 @@ describe('onboarding step 2 page', () => { afterEach(AppTesterPal.cleanup); it('opens eviction modal', () => { - const pal = new AppTesterPal(); + const pal = new AppTesterPal(); const getDialog = () => pal.getDialogWithLabel(/You need legal help/i); // When we enable the checkbox, the dialog should show. diff --git a/frontend/lib/tests/pages/onboarding-step-3.test.tsx b/frontend/lib/tests/pages/onboarding-step-3.test.tsx index ad6d8d8b7..e297aaae0 100644 --- a/frontend/lib/tests/pages/onboarding-step-3.test.tsx +++ b/frontend/lib/tests/pages/onboarding-step-3.test.tsx @@ -9,7 +9,7 @@ import { getLeaseChoiceLabels } from '../../../../common-data/lease-choices'; const PROPS = { - routes: Routes.onboarding + routes: Routes.locale.onboarding }; const STEP_3 = new OnboardingStep3(PROPS); diff --git a/frontend/lib/tests/pages/onboarding-step-4.test.tsx b/frontend/lib/tests/pages/onboarding-step-4.test.tsx index bfa82b612..9b1070373 100644 --- a/frontend/lib/tests/pages/onboarding-step-4.test.tsx +++ b/frontend/lib/tests/pages/onboarding-step-4.test.tsx @@ -7,7 +7,7 @@ import Routes from '../../routes'; import { OnboardingInfoSignupIntent } from '../../queries/globalTypes'; const PROPS = { - routes: Routes.onboarding, + routes: Routes.locale.onboarding, toSuccess: '/success', signupIntent: OnboardingInfoSignupIntent.HP }; diff --git a/frontend/lib/tests/progress-routes.test.tsx b/frontend/lib/tests/progress-routes.test.tsx index d448617cd..299fb7877 100644 --- a/frontend/lib/tests/progress-routes.test.tsx +++ b/frontend/lib/tests/progress-routes.test.tsx @@ -13,7 +13,7 @@ const myRoutesProps: ProgressRoutesProps = { confirmationSteps: [] }; -const MyRoutes = buildProgressRoutesComponent(myRoutesProps); +const MyRoutes = buildProgressRoutesComponent(() => myRoutesProps); describe("ProgressRoutes", () => { afterEach(AppTesterPal.cleanup); diff --git a/frontend/lib/tests/routes.test.ts b/frontend/lib/tests/routes.test.ts index 5c577077e..f2c5b9650 100644 --- a/frontend/lib/tests/routes.test.ts +++ b/frontend/lib/tests/routes.test.ts @@ -1,5 +1,14 @@ -import { isModalRoute, RouteMap, getSignupIntentOnboardingInfo } from "../routes"; +import Routes, { isModalRoute, RouteMap, getSignupIntentOnboardingInfo } from "../routes"; import { OnboardingInfoSignupIntent } from "../queries/globalTypes"; +import i18n from "../i18n"; + +test('Routes object responds to locale changes', () => { + expect(Routes.locale.home).toBe('/'); + i18n.initialize('en'); + expect(Routes.locale.home).toBe('/en/'); + i18n.initialize(''); + expect(Routes.locale.home).toBe('/'); +}); test('isModalRoute() works', () => { expect(isModalRoute('/blah')).toBe(false); diff --git a/frontend/lib/tests/setup.ts b/frontend/lib/tests/setup.ts index 782cae417..98c27b5ea 100644 --- a/frontend/lib/tests/setup.ts +++ b/frontend/lib/tests/setup.ts @@ -5,9 +5,12 @@ import { defaultContext } from '../app-context'; import { FakeAppContext } from './util'; import chalk from 'chalk'; import './confetti.setup'; +import i18n from '../i18n'; configure({ adapter: new Adapter() }); +i18n.initialize(''); + Object.keys(FakeAppContext).forEach(prop => { Object.defineProperty(defaultContext, prop, { value: (FakeAppContext as any)[prop] diff --git a/hpaction/tests/test_models.py b/hpaction/tests/test_models.py index e387b92ba..e33e50d50 100644 --- a/hpaction/tests/test_models.py +++ b/hpaction/tests/test_models.py @@ -2,6 +2,7 @@ from freezegun import freeze_time from users.tests.factories import UserFactory +from project.tests.util import strip_locale from .factories import HPActionDocumentsFactory, UploadTokenFactory from ..models import ( HPActionDocuments, UploadToken, UPLOAD_TOKEN_LIFETIME, @@ -41,7 +42,8 @@ def test_create_documents_from_works(self, db, django_file_storage): def test_get_upload_url_works(self, db): token = UploadToken(id='boop') - assert token.get_upload_url() == 'https://example.com/hp/upload/boop' + url = strip_locale(token.get_upload_url()) + assert url == 'https://example.com/hp/upload/boop' class TestHPActionDocuments: diff --git a/loc/tests/test_admin.py b/loc/tests/test_admin.py index 1e9e9d4d9..32313cdf4 100644 --- a/loc/tests/test_admin.py +++ b/loc/tests/test_admin.py @@ -2,6 +2,7 @@ from users.models import JustfixUser from users.tests.factories import UserFactory +from project.tests.util import strip_locale from loc.admin import LetterRequestInline, print_loc_envelopes from loc.models import LetterRequest @@ -25,4 +26,5 @@ def test_loc_actions_shows_pdf_link_when_user_has_letter_request(): def test_print_loc_envelopes_works(): user = UserFactory() redirect = print_loc_envelopes(None, None, JustfixUser.objects.all()) - assert redirect.url == f'/loc/admin/envelopes.pdf?user_ids={user.pk}' + url = strip_locale(redirect.url) + assert url == f'/loc/admin/envelopes.pdf?user_ids={user.pk}' diff --git a/loc/tests/test_views.py b/loc/tests/test_views.py index b78cd8e48..89333d3f7 100644 --- a/loc/tests/test_views.py +++ b/loc/tests/test_views.py @@ -1,4 +1,5 @@ from functools import wraps +from django.urls import reverse import pytest from users.tests.factories import UserFactory @@ -22,6 +23,18 @@ "ignore:The html argument of XMLParser") +def example_url(format: str) -> str: + return reverse('loc_example', args=(format,)) + + +def letter_url(format: str) -> str: + return reverse('loc', args=(format,)) + + +def admin_letter_url(user_id: int) -> str: + return reverse('loc_for_user', kwargs={'user_id': user_id}) + + def requires_pdf_rendering(fn): @pytest.mark.skipif(not can_we_render_pdfs(), reason='PDF generation is unsupported') @@ -59,12 +72,12 @@ def test_render_document_raises_err_on_invalid_format(): def test_letter_requires_login(client): - res = client.get('/loc/letter.html') + res = client.get(letter_url('html')) assert res.status_code == 302 def get_letter_html(client): - res = client.get('/loc/letter.html') + res = client.get(letter_url('html')) assert res.status_code == 200 assert res['Content-Type'] == 'text/html; charset=utf-8' return res.content.decode('utf-8') @@ -100,21 +113,21 @@ def test_letter_html_includes_expected_content(client): def test_example_html_works(client): - res = client.get('/loc/example.html') + res = client.get(example_url('html')) assert res.status_code == 200 assert res['Content-Type'] == 'text/html; charset=utf-8' @requires_pdf_rendering def test_letter_pdf_works(admin_client): - res = admin_client.get('/loc/letter.pdf') + res = admin_client.get(letter_url('pdf')) assert res.status_code == 200 assert res['Content-Type'] == 'application/pdf' @requires_pdf_rendering def test_example_pdf_works(client): - res = client.get('/loc/example.pdf') + res = client.get(example_url('pdf')) assert res.status_code == 200 assert res['Content-Type'] == 'application/pdf' @@ -122,13 +135,13 @@ def test_example_pdf_works(client): @requires_pdf_rendering def test_admin_letter_pdf_works(outreach_client): user = UserFactory() - res = outreach_client.get(f'/loc/admin/{user.pk}/letter.pdf') + res = outreach_client.get(admin_letter_url(user.pk)) assert res.status_code == 200 assert res['Content-Type'] == 'application/pdf' def test_admin_letter_pdf_returns_404_for_nonexistent_users(admin_client): - res = admin_client.get(f'/loc/admin/1024/letter.pdf') + res = admin_client.get(admin_letter_url(1024)) assert res.status_code == 404 @@ -138,10 +151,11 @@ def test_admin_letter_pdf_is_inaccessible_to_non_staff_users(client): client.force_login(user) # Yes, even the user's own LoC should be forbidden to them. - res = client.get(f'/loc/admin/{user.pk}/letter.pdf') + url = admin_letter_url(user.pk) + res = client.get(url) assert res.status_code == 302 - assert res.url == f"/login?next=/loc/admin/{user.pk}/letter.pdf" + assert res.url == f"/login?next={url}" @pytest.mark.django_db @@ -149,17 +163,17 @@ def test_admin_envelopes_pdf_is_inaccessible_to_non_staff_users(client): user = UserFactory() client.force_login(user) - res = client.get(f'/loc/admin/envelopes.pdf') + res = client.get(reverse('loc_envelopes')) assert res.status_code == 302 - assert res.url == f"/login?next=/loc/admin/envelopes.pdf" + assert res.url == f"/login?next={reverse('loc_envelopes')}" @requires_pdf_rendering def test_admin_envelopes_pdf_works(outreach_client): user = create_user_with_all_info() bare_user = UserFactory(phone_number='6141234567', username='blah') - res = outreach_client.get(f'/loc/admin/envelopes.pdf?user_ids={user.pk},{bare_user.pk},zz') + res = outreach_client.get(f'{reverse("loc_envelopes")}?user_ids={user.pk},{bare_user.pk},zz') assert res.status_code == 200 assert res['Content-Type'] == 'application/pdf' assert res.context['users'] == [user] diff --git a/loc/urls.py b/loc/urls.py index 0c28f0f91..28bb965f7 100644 --- a/loc/urls.py +++ b/loc/urls.py @@ -8,5 +8,5 @@ path(r'admin/envelopes.pdf', views.envelopes, name='loc_envelopes'), path(r'admin//letter.pdf', views.letter_of_complaint_pdf_for_user, name='loc_for_user'), - re_path(r'^example\.(html|pdf)$', views.example_doc), + re_path(r'^example\.(html|pdf)$', views.example_doc, name='loc_example'), ] diff --git a/loc/views.py b/loc/views.py index 387d997cb..79a027b62 100644 --- a/loc/views.py +++ b/loc/views.py @@ -6,6 +6,7 @@ from django.contrib.auth.decorators import login_required, permission_required from django.template.loader import render_to_string from django.shortcuts import get_object_or_404 +from django.utils import translation from twofactor.decorators import twofactor_required from users.models import JustfixUser, VIEW_LETTER_REQUEST_PERMISSION @@ -155,6 +156,14 @@ def render_document(request, template_name: str, context: Dict[str, Any], format if format not in ['html', 'pdf']: raise ValueError(f'unknown format "{format}"') + # For now, we always want to localize the letter of complaint in English. + # Even if we don't translate the letter itself to other languages, some + # templating functionality provided by Django (such as date formatting) will + # take the current locale into account, and we don't want e.g. a letter to + # have English paragraphs but Spanish dates. So we'll explicitly set + # the locale here. + translation.activate('en') + if format == 'html': html = render_to_string(template_name, context={ **context, diff --git a/project/justfix_environment.py b/project/justfix_environment.py index df2f68b2b..ff73f1bcc 100644 --- a/project/justfix_environment.py +++ b/project/justfix_environment.py @@ -166,6 +166,9 @@ class JustfixEnvironment(typed_environ.BaseEnvironment): # not provided, mapbox integration will be disabled. MAPBOX_ACCESS_TOKEN: str = '' + # Whether or not to enable internationalization/localization. + ENABLE_I18N: bool = False + class JustfixDevelopmentDefaults(JustfixEnvironment): ''' diff --git a/project/settings.py b/project/settings.py index fa9cce5f0..b328a8bb1 100644 --- a/project/settings.py +++ b/project/settings.py @@ -72,6 +72,7 @@ 'django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', @@ -177,13 +178,17 @@ # Internationalization # https://docs.djangoproject.com/en/2.1/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = 'en' + +LANGUAGES = [ + ('en', 'English'), +] TIME_ZONE = 'UTC' -USE_I18N = True +USE_I18N = env.ENABLE_I18N -USE_L10N = True +USE_L10N = env.ENABLE_I18N USE_TZ = True diff --git a/project/tests/test_site_util.py b/project/tests/test_site_util.py index bb4dc969f..5c7bc7562 100644 --- a/project/tests/test_site_util.py +++ b/project/tests/test_site_util.py @@ -1,4 +1,5 @@ from django.test import override_settings, TestCase +from django.conf import settings from ..util.site_util import absolute_reverse, absolutify_url @@ -6,9 +7,13 @@ class SiteUtilsTests(TestCase): @override_settings(DEBUG=False) def test_absolute_reverse_works(self): + if settings.USE_I18N: + url = 'https://example.com/en/graphql' + else: + url = 'https://example.com/graphql' self.assertEqual( absolute_reverse('batch-graphql'), - 'https://example.com/graphql' + url ) def test_absolutify_url_raises_error_on_non_absolute_paths(self): diff --git a/project/tests/test_views.py b/project/tests/test_views.py index e9bbee2c1..1f412c522 100644 --- a/project/tests/test_views.py +++ b/project/tests/test_views.py @@ -15,6 +15,13 @@ from frontend.tests import test_safe_mode +def react_url(path: str) -> str: + base_url = reverse('react') + if base_url.endswith('/'): + base_url = base_url[:-1] + return f"{base_url}{path}" + + def test_get_legacy_form_submission_raises_errors(graphql_client): request = graphql_client.request with pytest.raises(LegacyFormSubmissionError, match='No GraphQL query found'): @@ -45,7 +52,7 @@ def test_get_initial_session_works(graphql_client): def test_invalid_post_returns_400(client): - response = client.post('/') + response = client.post(react_url('/')) assert response.status_code == 400 assert response.content == b'No GraphQL query found' @@ -56,7 +63,7 @@ def test_invalid_post_returns_400(client): def test_index_works_when_not_in_safe_mode(client): - response = client.get('/') + response = client.get(react_url('/')) assert response.status_code == 200 assert 'JustFix.nyc' in response.context['title_tag'] assert ' str: + ''' + If the given URL has a locale prefix in its + pathname, remove it. For example: + + >>> strip_locale('https://blah/blarg') + 'https://blah/blarg' + + >>> strip_locale('https://blah/en/blarg') + 'https://blah/blarg' + ''' + + # This isn't particularly precise, but it gets + # the job done for testing, which is all we're + # using it for. + for lang, _ in settings.LANGUAGES: + url = url.replace(f"/{lang}/", "/") + + return url diff --git a/project/urls.py b/project/urls.py index 874a7991e..5e9ac80ee 100644 --- a/project/urls.py +++ b/project/urls.py @@ -16,6 +16,7 @@ from django.contrib import admin from django.urls import path, re_path, include from django.conf import settings +from django.conf.urls.i18n import i18n_patterns from graphene_django.views import GraphQLView from legacy_tenants.views import redirect_to_legacy_app @@ -26,18 +27,20 @@ admin.site.site_title = "Tenant App admin" admin.site.index_title = "Justfix.nyc Tenant App administration" +dev_patterns = ([ + path('examples/server-error/', example_server_error), + re_path(r'^.*$', react_rendered_view), +], 'dev') + urlpatterns = [ path('verify', twofactor.views.verify, name='verify'), path('health', health), - path('admin/login/', react_rendered_view, kwargs={'url': 'admin/login/'}), + path('admin/login/', react_rendered_view), path('admin/', admin.site.urls), - path('loc/', include('loc.urls')), - path('hp/', include('hpaction.urls')), path('safe-mode/', include('frontend.safe_mode')), path('legacy-app', redirect_to_legacy_app, name='redirect-to-legacy-app'), path('favicon.ico', redirect_favicon), - path('dev/examples/server-error/', example_server_error), - path('graphql', GraphQLView.as_view(batch=True), name='batch-graphql'), + path('dev/', include(dev_patterns, namespace='dev')), ] if settings.DEBUG: @@ -47,4 +50,9 @@ urlpatterns.append( path('graphiql', GraphQLView.as_view(graphiql=True))) -urlpatterns.append(re_path(r'^(?P.*)$', react_rendered_view)) +urlpatterns += i18n_patterns( + path('loc/', include('loc.urls')), + path('hp/', include('hpaction.urls')), + path('graphql', GraphQLView.as_view(batch=True), name='batch-graphql'), + re_path(r'.*$', react_rendered_view, name='react'), +) diff --git a/project/views.py b/project/views.py index 1e21e4e0b..fcf803075 100644 --- a/project/views.py +++ b/project/views.py @@ -5,6 +5,7 @@ from django.views.decorators.http import require_POST from django.views.decorators.csrf import csrf_exempt from django.utils.safestring import SafeString +from django.utils import translation from django.shortcuts import render, redirect from django.urls import reverse from django.conf import settings @@ -137,8 +138,11 @@ def get_legacy_form_submission(request): } -def react_rendered_view(request, url: str): - url = f'/{url}' +def react_rendered_view(request): + url = request.path + cur_language = '' + if settings.USE_I18N: + cur_language = translation.get_language_from_request(request, check_path=True) querystring = request.GET.urlencode() if querystring: url += f'?{querystring}' @@ -150,6 +154,7 @@ def react_rendered_view(request, url: str): initial_props = { 'initialURL': url, 'initialSession': get_initial_session(request), + 'locale': cur_language, 'server': { 'originURL': request.build_absolute_uri('/')[:-1], 'staticURL': settings.STATIC_URL,