Skip to content

Commit

Permalink
Add i18n support (#47)
Browse files Browse the repository at this point in the history
* Add i18n support, not found page translations, and test helper

* Support translations in admin pages

* Support translations in authentication pages

* Support translations in identity pages

* Support translations in scanning pages

* Support translations in testing pages
  • Loading branch information
oliverviljamaa authored May 2, 2020
1 parent 35f7924 commit 8adcdf1
Show file tree
Hide file tree
Showing 29 changed files with 333 additions and 137 deletions.
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"react-qr-reader": "^2.2.1",
"react-router-dom": "^5.1.2",
"react-scripts": "3.4.1",
"retranslate": "^1.2.0",
"theme-ui": "^0.3.1",
"use-media": "^1.4.0",
"yup": "^0.28.3"
Expand Down
10 changes: 7 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom';
import { ThemeProvider } from 'theme-ui';
import { Provider as TranslationProvider } from 'retranslate';

import {
LoginPage,
Expand All @@ -10,6 +11,7 @@ import {
useAuthentication,
} from './authentication';

import { messages } from './i18n/messages';
import theme from './theme';
import { NotFoundPage } from './staticPages';

Expand Down Expand Up @@ -65,9 +67,11 @@ const ConfiguredApp = () => {
return (
<AuthenticationProvider>
<ThemeProvider theme={theme}>
<BrowserRouter>
<App />
</BrowserRouter>
<TranslationProvider messages={messages} fallbackLanguage="en">
<BrowserRouter>
<App />
</BrowserRouter>
</TranslationProvider>
</ThemeProvider>
</AuthenticationProvider>
);
Expand Down
5 changes: 3 additions & 2 deletions src/admin/BulkUserCreationPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { render, waitFor, fireEvent } from '@testing-library/react';
import { BulkUserCreationPage } from '.';
import { useAuthentication } from '../authentication/context';
import { User } from '../api';
import { renderWrapped } from '../testHelpers';

const mockGet = jest.fn();
const mockPost = jest.fn();
Expand Down Expand Up @@ -32,7 +33,7 @@ describe('Bulk user creation page', () => {
})
);

const { getByLabelText, getByText } = render(<BulkUserCreationPage />);
const { getByLabelText, getByText } = renderWrapped(<BulkUserCreationPage />);

const input = getByLabelText(/emails/i);
const button = getByText('Create');
Expand Down Expand Up @@ -86,7 +87,7 @@ describe('Bulk user creation page', () => {
Promise.reject(new Error(url === '/api/v1/roles' ? 'Some role error.' : 'Some other error.'))
);

const { getByText } = render(<BulkUserCreationPage />);
const { getByText } = renderWrapped(<BulkUserCreationPage />);

await waitFor(() => {
expect(getByText(/some role error/i)).toBeInTheDocument();
Expand Down
37 changes: 25 additions & 12 deletions src/admin/BulkUserCreationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import * as yup from 'yup';
import { Role, CreateUserCommand } from '../api';
import useBulkUserCreation from './useBulkUserCreation';
import useRoles from './useRoles';
import { Message, useTranslations } from 'retranslate';

const AnyBox = Box as any;

const BulkUserCreationPage: FC = () => {
const { translate } = useTranslations();
const {
create,
loading: creatingUsers,
Expand All @@ -33,9 +35,7 @@ const BulkUserCreationPage: FC = () => {
const command: CreateUserCommand[] = emails.map((email) => ({ email, roles: [role] }));

if (
window.confirm(
`Are you sure you want to create ${command.length} user(s) with role ${role}?`
)
window.confirm(translate('bulkUserCreationPage.confirm', { number: command.length, role }))
) {
create(command);
}
Expand All @@ -45,17 +45,18 @@ const BulkUserCreationPage: FC = () => {
return (
<Container variant="page">
<Heading as="h1" mb={4}>
Create users
<Message>bulkUserCreationPage.heading</Message>
</Heading>

<Text mb={4}>
Creates users with the specified emails and assigns the selected role to them. If a user
with a given email already exists, the selected role will be assigned to them.
<Message>bulkUserCreationPage.description</Message>
</Text>

<AnyBox as="form" sx={{ display: 'grid', gridGap: 4 }} onSubmit={form.handleSubmit} mb={4}>
<Box>
<Label htmlFor="role">Role *</Label>
<Label htmlFor="role">
<Message>bulkUserCreationPage.role.label</Message> *
</Label>
<Select id="role" {...form.getFieldProps('role')}>
{roles.map(({ name }) => (
<option key={name} value={name}>
Expand All @@ -66,7 +67,9 @@ const BulkUserCreationPage: FC = () => {
</Box>

<Box>
<Label htmlFor="emails">Emails (separated by comma) *</Label>
<Label htmlFor="emails">
<Message>bulkUserCreationPage.emails.label</Message> *
</Label>
<Textarea
id="emails"
{...form.getFieldProps('emailsString')}
Expand All @@ -79,17 +82,27 @@ const BulkUserCreationPage: FC = () => {
type="submit"
disabled={loadingRoles || creatingUsers || !form.isValid}
>
Create
<Message>bulkUserCreationPage.button</Message>
</Button>
</AnyBox>

{createdUsers.length > 0 && (
<Alert variant="success" mb={2}>{createdUsers.length} user(s) successfully created.</Alert>
<Alert variant="success" mb={2}>
<Message params={{ number: createdUsers.length }}>bulkUserCreationPage.success</Message>
</Alert>
)}

{errorLoadingRoles && <Alert variant="error" mb={2}>{errorLoadingRoles.message}</Alert>}
{errorLoadingRoles && (
<Alert variant="error" mb={2}>
{errorLoadingRoles.message}
</Alert>
)}

{errorCreatingUsers && <Alert variant="error" mb={2}>{errorCreatingUsers.message}</Alert>}
{errorCreatingUsers && (
<Alert variant="error" mb={2}>
{errorCreatingUsers.message}
</Alert>
)}
</Container>
);
};
Expand Down
8 changes: 6 additions & 2 deletions src/admin/useBulkUserCreation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useAuthentication } from '../authentication';
import { User, CreateUserCommand, createUsers } from '../api';
import { useTranslations } from 'retranslate';

type NullableError = Error | null;

Expand All @@ -11,6 +12,7 @@ export default function useBulkUserCreation(): {
createdUsers: User[];
} {
const { token } = useAuthentication();
const { translate } = useTranslations();

const [loading, setLoading] = useState(false);
const [error, setError] = useState(null as NullableError);
Expand All @@ -29,14 +31,16 @@ export default function useBulkUserCreation(): {
setLoading(false);
}
} else {
setError(new Error('Authentication failed. Please try logging in again.'));
setError(new Error(translate('bulkUserCreation.error.authentication')));
}
};

return {
create,
loading,
error: error ? new Error(`Failed to create users: ${error.message}`) : null,
error: error
? new Error(translate('bulkUserCreation.error.generic', { message: error.message }))
: null,
createdUsers,
};
}
6 changes: 5 additions & 1 deletion src/admin/useRoles.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { useTranslations } from 'retranslate';

import { fetchRoles } from '../api';
import { useAuthenticatedHttpResource } from '../resources';

export default function useRoles() {
const { translate } = useTranslations();
const { loading, error, resource: roles } = useAuthenticatedHttpResource([], fetchRoles);

return {
loading,
error: error ? new Error(`Failed to get roles: ${error.message}`) : null,
error: error ? new Error(translate('roles.error', { message: error.message })) : null,
roles,
};
}
5 changes: 3 additions & 2 deletions src/authentication/AuthenticatedRoute.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React from 'react';
import { Router, Route } from 'react-router-dom';
import { createMemoryHistory, History } from 'history';
import { render, waitFor, screen } from '@testing-library/react';
import { waitFor, screen } from '@testing-library/react';

import { renderWrapped } from '../testHelpers';
import { useAuthentication } from './context';
import { AuthenticatedRoute } from './AuthenticatedRoute';

Expand All @@ -16,7 +17,7 @@ describe('Authenticated route', () => {
history = createMemoryHistory();
mockAuthenticated();

render(
renderWrapped(
<Router history={history}>
<AuthenticatedRoute path="/private" exact>
<h1>private page</h1>
Expand Down
7 changes: 4 additions & 3 deletions src/authentication/LoginPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { fireEvent, screen } from '@testing-library/react';

import { LoginPage } from './LoginPage';

import { createMagicLink } from '../api';
import { renderWrapped } from '../testHelpers';

jest.mock('../api');
const createMagicLinkMock = createMagicLink as jest.MockedFunction<typeof createMagicLink>;

describe('Login page', () => {
beforeEach(() => {
render(<LoginPage />);
renderWrapped(<LoginPage />);
createMagicLinkMock.mockImplementation(() =>
Promise.resolve({
creationTime: new Date().toISOString(),
active: true,
}),
})
);
});

Expand Down
30 changes: 19 additions & 11 deletions src/authentication/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from 'theme-ui';

import { createMagicLink } from '../api';
import { Message, useTranslations } from 'retranslate';

export const LoginPage = () => {
const [email, setEmail] = useState('');
Expand All @@ -32,29 +33,33 @@ export const LoginPage = () => {
<Container variant="page">
{invalidLink && !submitted && (
<Alert variant="secondary" mb={4}>
That link wasn't valid, please request a new one.
<Message>loginPage.invalidLink</Message>
</Alert>
)}
<Heading as="h1" mb={3}>
Sign in
<Message>loginPage.heading</Message>
</Heading>
{submitted ? (
<Text>
We sent an email to <strong>{email}</strong> with a secure link to sign you in.
<Message params={{ email: <strong>{email}</strong> }}>
loginPage.description.submitted
</Message>
</Text>
) : (
<>
<Text mb={6}>We’ll send you a secure sign in link to your email</Text>
<Text mb={6}>
<Message>loginPage.description.notSubmitted</Message>
</Text>
<LoginForm onComplete={handleLogin} />
</>
)}
<ThemeUiLink
href="https://cov-clear.com/privacy/"
href="https://cov-clear.com/privacy/" // TODO: Add link to Estonian privacy page
mt={2}
py={3}
sx={{ display: 'block', width: '100%', textAlign: 'center' }}
>
Privacy
<Message>loginPage.privacy</Message>
</ThemeUiLink>
</Container>
);
Expand All @@ -67,6 +72,7 @@ const LoginForm = ({
}: {
onComplete: ({ email }: { email: string }) => Promise<any>;
}) => {
const { translate } = useTranslations();
const form = useFormik({
initialValues: {
email: '',
Expand All @@ -75,26 +81,28 @@ const LoginForm = ({
email: yup
.string()
.trim()
.email('Please check your email address')
.required('Please fill your email address'),
.email(translate('loginPage.form.email.invalid'))
.required(translate('loginPage.form.email.required')),
}),
onSubmit: ({ email }) => onComplete({ email: email.trim() }),
});

return (
<AnyBox as="form" sx={{ display: 'grid', gridGap: 4 }} onSubmit={form.handleSubmit}>
<Box>
<Label htmlFor="cov-email">Your email</Label>
<Label htmlFor="cov-email">
<Message>loginPage.form.email.label</Message>
</Label>
<Input
id="cov-email"
type="email"
{...form.getFieldProps('email')}
placeholder="e.g. john.smith@email.com"
placeholder={translate('loginPage.form.email.placeholder')}
/>
{form.touched.email && form.errors.email ? <Text>{form.errors.email}</Text> : null}
</Box>
<Button type="submit" variant="block" disabled={form.isSubmitting} mt={2}>
Send magic link
<Message>loginPage.form.button</Message>
</Button>
</AnyBox>
);
Expand Down
Loading

0 comments on commit 8adcdf1

Please sign in to comment.