Skip to content

Commit

Permalink
✨(frontend) sync user and frontend language
Browse files Browse the repository at this point in the history
On Language change in the frontend,
the user language is updated via API.
If user language is available, it will
be preferred and set in the frontend.
  • Loading branch information
rvveber committed Nov 18, 2024
1 parent e03c01c commit 62ebf55
Show file tree
Hide file tree
Showing 8 changed files with 128 additions and 16 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ and this project adheres to
- 🌐(backend) add german translation #259
- ♻️(frontend) simplify stores #402
- ✨(frontend) update $css Box props type to add styled components RuleSet #423
- ✨(frontend) sync user and frontend language #401

## Fixed

Expand Down
2 changes: 1 addition & 1 deletion src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class UserSerializer(serializers.ModelSerializer):

class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
fields = ["id", "email", "full_name", "short_name", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]


Expand Down
50 changes: 41 additions & 9 deletions src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import { expect, test } from '@playwright/test';
import { Page, expect, test } from '@playwright/test';

test.beforeEach(async ({ page }) => {
await page.goto('/');
});

test.describe('Language', () => {
test('checks the language picker', async ({ page }) => {
test('checks language switching', async ({ page }) => {
const header = page.locator('header').first();

// initial language should be english
await expect(
page.getByRole('button', {
name: 'Create a new document',
}),
).toBeVisible();

const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
// switch to french
await waitForLanguageSwitch(page, Languages.French);

await expect(
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
Expand Down Expand Up @@ -63,12 +66,41 @@ test.describe('Language', () => {
// Check for English 404 response
await check404Response('Not found.');

// Switch language to French
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await waitForLanguageSwitch(page, Languages.French);

// Check for French 404 response
await check404Response('Pas trouvé.');
});
});

test.afterEach(async ({ page }) => {
// Switch back to English - important for other tests to run, as this updates the language in the user entity
// and in turn updates the language of the frontend.
// Therefore continuing tests, messages that are expected to be english would be french
await waitForLanguageSwitch(page, Languages.English);
});

// eslint-disable-next-line @typescript-eslint/no-unused-vars
enum Languages {
English = 'English',
French = 'Français',
}
const serverSideLanguageValues: Record<Languages, string> = {
[Languages.English]: 'en-us',
[Languages.French]: 'fr-fr',
};
async function waitForLanguageSwitch(page: Page, lang: Languages) {
const header = page.locator('header').first();
await header.getByRole('combobox').click();

const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/user') && resp.request().method() === 'PATCH',
),
header.getByRole('option', { name: lang }).click(),
]);

const updatedUserResponse = await response.json();
expect(updatedUserResponse.language).toBe(serverSideLanguageValues[lang]);
}
16 changes: 14 additions & 2 deletions src/frontend/apps/impress/src/core/AppProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useEffect } from 'react';

import { useCunninghamTheme } from '@/cunningham';
import '@/i18n/initI18n';
import { LANGUAGES_ALLOWED } from '@/i18n/conf';
import i18n from '@/i18n/initI18n';
import { useResponsiveStore } from '@/stores/';

import { Auth } from './auth/';
import { Auth, useAuthStore } from './auth/';

/**
* QueryClient:
Expand All @@ -26,6 +27,7 @@ const queryClient = new QueryClient({

export function AppProvider({ children }: { children: React.ReactNode }) {
const { theme } = useCunninghamTheme();
const { userData } = useAuthStore();

const initializeResizeListener = useResponsiveStore(
(state) => state.initializeResizeListener,
Expand All @@ -36,6 +38,16 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return cleanupResizeListener;
}, [initializeResizeListener]);

useEffect(() => {
if (userData?.language) {
Object.keys(LANGUAGES_ALLOWED).forEach((key) => {
if (userData.language.includes(key) && i18n.language !== key) {
void i18n.changeLanguage(key);
}
});
}
}, [userData?.language]);

return (
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme={theme}>
Expand Down
4 changes: 4 additions & 0 deletions src/frontend/apps/impress/src/core/auth/api/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { UserLanguage } from '@/i18n/types';

/**
* Represents user retrieved from the API.
* @interface User
* @property {string} id - The id of the user.
* @property {string} email - The email of the user.
* @property {string} name - The name of the user.
* @property {string} language - The language of the user.
*/
export interface User {
id: string;
email: string;
full_name: string;
short_name: string;
language: UserLanguage;
}
20 changes: 16 additions & 4 deletions src/frontend/apps/impress/src/features/language/LanguagePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import styled from 'styled-components';
import { Box, Text } from '@/components/';
import { LANGUAGES_ALLOWED } from '@/i18n/conf';

import { useSetUserLanguage } from './api/useChangeUserLanguage';

const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`
flex-shrink: 0;
width: auto;
Expand Down Expand Up @@ -33,6 +35,7 @@ const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`

export const LanguagePicker = () => {
const { t, i18n } = useTranslation();
const { mutateAsync: setUserLanguage } = useSetUserLanguage();
const { preload: languages } = i18n.options;

const optionsPicker = useMemo(() => {
Expand Down Expand Up @@ -63,13 +66,22 @@ export const LanguagePicker = () => {
showLabelWhenSelected={false}
clearable={false}
hideLabel
defaultValue={i18n.language}
value={i18n.language}
className="c_select__no_bg"
options={optionsPicker}
onChange={(e) => {
i18n.changeLanguage(e.target.value as string).catch((err) => {
console.error('Error changing language', err);
});
void i18n
.changeLanguage(e.target.value as string)
.catch((err) => {
console.error('Error changing language', err);
})
.then(() => {
setUserLanguage({
language: i18n.language === 'fr' ? 'fr-fr' : 'en-us',
}).catch((err) => {
console.error('Error changing users language', err);
});
});
}}
/>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { APIError, errorCauses, fetchAPI } from '@/api';
import { User, useAuthStore } from '@/core';

interface SetUserLanguageParams {
language: User['language'];
}

export const setUserLanguage = async ({
language,
}: SetUserLanguageParams): Promise<User> => {
const { userData } = useAuthStore.getState();

if (!userData?.id) {
console.warn('Id of user is needed for this request.');
return {} as Promise<User>;
}

const response = await fetchAPI(`users/${userData.id}/`, {
method: 'PATCH',
body: JSON.stringify({
language,
}),
});

if (!response.ok) {
throw new APIError(
`Failed to change the user language to ${language}`,
await errorCauses(response, {
value: language,
type: 'language',
}),
);
}

return response.json() as Promise<User>;
};

export function useSetUserLanguage() {
const queryClient = useQueryClient();
return useMutation<User, APIError, SetUserLanguageParams>({
mutationFn: setUserLanguage,
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['set-user-language'],
});
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export class ApiPlugin implements WorkboxPlugin {
email: 'dummy-email',
full_name: 'dummy-full-name',
short_name: 'dummy-short-name',
language: 'en-us',
},
abilities: {
destroy: false,
Expand Down

0 comments on commit 62ebf55

Please sign in to comment.