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

feat(app): implement ODD choose language screen, app language toggles #16573

Merged
merged 8 commits into from
Oct 25, 2024
4 changes: 4 additions & 0 deletions app/src/App/OnDeviceDisplayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { MaintenanceRunTakeover } from '/app/organisms/TakeoverModal'
import { FirmwareUpdateTakeover } from '/app/organisms/FirmwareUpdateModal/FirmwareUpdateTakeover'
import { IncompatibleModuleTakeover } from '/app/organisms/IncompatibleModule'
import { EstopTakeover } from '/app/organisms/EmergencyStop'
import { ChooseLanguage } from '/app/pages/ODD/ChooseLanguage'
import { ConnectViaEthernet } from '/app/pages/ODD/ConnectViaEthernet'
import { ConnectViaUSB } from '/app/pages/ODD/ConnectViaUSB'
import { ConnectViaWifi } from '/app/pages/ODD/ConnectViaWifi'
Expand Down Expand Up @@ -66,6 +67,7 @@ import type { Dispatch } from '/app/redux/types'
hackWindowNavigatorOnLine()

export const ON_DEVICE_DISPLAY_PATHS = [
'/choose-language',
'/dashboard',
'/deck-configuration',
'/emergency-stop',
Expand Down Expand Up @@ -94,6 +96,8 @@ function getPathComponent(
path: typeof ON_DEVICE_DISPLAY_PATHS[number]
): JSX.Element {
switch (path) {
case '/choose-language':
return <ChooseLanguage />
case '/dashboard':
return <RobotDashboard />
case '/deck-configuration':
Expand Down
6 changes: 6 additions & 0 deletions app/src/App/__tests__/OnDeviceDisplayApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { MemoryRouter } from 'react-router-dom'
import { renderWithProviders } from '/app/__testing-utils__'
import { i18n } from '/app/i18n'
import { LocalizationProvider } from '../../LocalizationProvider'
import { ChooseLanguage } from '/app/pages/ODD/ChooseLanguage'
import { ConnectViaEthernet } from '/app/pages/ODD/ConnectViaEthernet'
import { ConnectViaUSB } from '/app/pages/ODD/ConnectViaUSB'
import { ConnectViaWifi } from '/app/pages/ODD/ConnectViaWifi'
Expand Down Expand Up @@ -48,6 +49,7 @@ vi.mock('@opentrons/react-api-client', async () => {
vi.mock('../../LocalizationProvider')
vi.mock('/app/pages/ODD/Welcome')
vi.mock('/app/pages/ODD/NetworkSetupMenu')
vi.mock('/app/pages/ODD/ChooseLanguage')
vi.mock('/app/pages/ODD/ConnectViaEthernet')
vi.mock('/app/pages/ODD/ConnectViaUSB')
vi.mock('/app/pages/ODD/ConnectViaWifi')
Expand Down Expand Up @@ -109,6 +111,10 @@ describe('OnDeviceDisplayApp', () => {
vi.resetAllMocks()
})

it('renders ChooseLanguage component from /choose-language', () => {
render('/choose-language')
expect(vi.mocked(ChooseLanguage)).toHaveBeenCalled()
})
it('renders Welcome component from /welcome', () => {
render('/welcome')
expect(vi.mocked(Welcome)).toHaveBeenCalled()
Expand Down
5 changes: 5 additions & 0 deletions app/src/assets/localization/en/app_settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@
"additional_labware_folder_title": "Additional Custom Labware Source Folder",
"advanced": "Advanced",
"app_changes": "App Changes in ",
"app_language_description": "All app features use this language. Protocols and other user content will not change language.",
"app_language_preferences": "App Language Preferences",
"app_settings": "App Settings",
"bug_fixes": "Bug Fixes",
"cal_block": "Always use calibration block to calibrate",
"change_folder_button": "Change labware source folder",
"channel": "Channel",
"choose_your_language": "Choose your language",
"clear_confirm": "Clear unavailable robots",
"clear_robots_button": "Clear unavailable robots list",
"clear_robots_description": "Clear the list of unavailable robots on the Devices page. This action cannot be undone.",
Expand Down Expand Up @@ -48,6 +51,7 @@
"ip_available": "Available",
"ip_description_first": "Enter an IP address or hostname to connect to a robot.",
"language_preference": "Language preference",
"language": "Language",
"manage_versions": "It is very important for the robot and app software to be on the same version. Manage the robot software versions via Robot Settings > Advanced.",
"new_features": "New Features",
"no_folder": "No additional source folder specified",
Expand All @@ -73,6 +77,7 @@
"restarting_app": "Download complete, restarting the app...",
"restore_previous": "See how to restore a previous software version",
"searching": "Searching for 30s",
"select_a_language": "Select a language to personalize your experience.",
"select_language": "Select language",
"setup_connection": "Set up connection",
"share_display_usage": "Share display usage",
Expand Down
14 changes: 14 additions & 0 deletions app/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ import { titleCase } from '@opentrons/shared-data'

import type { InitOptions } from 'i18next'

export const US_ENGLISH = 'en-US'
export const SIMPLIFIED_CHINESE = 'zh-CN'

// these strings will not be translated so should not be localized
export const US_ENGLISH_DISPLAY_NAME = 'English (US)'
export const SIMPLIFIED_CHINESE_DISPLAY_NAME = '中文'

export type Language = typeof US_ENGLISH | typeof SIMPLIFIED_CHINESE

export const LANGUAGES: Array<{ name: string; value: Language }> = [
{ name: US_ENGLISH_DISPLAY_NAME, value: US_ENGLISH },
{ name: SIMPLIFIED_CHINESE_DISPLAY_NAME, value: SIMPLIFIED_CHINESE },
]

const i18nConfig: InitOptions = {
resources,
lng: 'en',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ describe('SystemLanguagePreferenceModal', () => {

it('should set a supported app language when system language is an unsupported locale of the same language', () => {
vi.mocked(getAppLanguage).mockReturnValue(null)
vi.mocked(getSystemLanguage).mockReturnValue('en-UK')
vi.mocked(getSystemLanguage).mockReturnValue('en-GB')

render()

Expand All @@ -116,7 +116,7 @@ describe('SystemLanguagePreferenceModal', () => {
'language.appLanguage',
MOCK_DEFAULT_LANGUAGE
)
expect(updateConfigValue).toBeCalledWith('language.systemLanguage', 'en-UK')
expect(updateConfigValue).toBeCalledWith('language.systemLanguage', 'en-GB')
})

it('should render the correct header, description, and buttons when system language changes', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
StyledText,
} from '@opentrons/components'

import { LANGUAGES } from '/app/i18n'
import {
getAppLanguage,
getStoredSystemLanguage,
Expand All @@ -26,18 +27,12 @@ import { getSystemLanguage } from '/app/redux/shell'
import type { DropdownOption } from '@opentrons/components'
import type { Dispatch } from '/app/redux/types'

// these strings will not be translated so should not be localized
const languageOptions: DropdownOption[] = [
{ name: 'English (US)', value: 'en-US' },
{ name: '中文', value: 'zh-CN' },
]

export function SystemLanguagePreferenceModal(): JSX.Element | null {
const { i18n, t } = useTranslation(['app_settings', 'shared', 'branded'])
const enableLocalization = useFeatureFlag('enableLocalization')

const [currentOption, setCurrentOption] = useState<DropdownOption>(
languageOptions[0]
LANGUAGES[0]
)

const dispatch = useDispatch<Dispatch>()
Expand Down Expand Up @@ -76,7 +71,7 @@ export function SystemLanguagePreferenceModal(): JSX.Element | null {
}

const handleDropdownClick = (value: string): void => {
const selectedOption = languageOptions.find(lng => lng.value === value)
const selectedOption = LANGUAGES.find(lng => lng.value === value)

if (selectedOption != null) {
setCurrentOption(selectedOption)
Expand All @@ -89,8 +84,8 @@ export function SystemLanguagePreferenceModal(): JSX.Element | null {
if (systemLanguage != null) {
// prefer match entire locale, then match just language e.g. zh-Hant and zh-CN
const matchedSystemLanguageOption =
languageOptions.find(lng => lng.value === systemLanguage) ??
languageOptions.find(
LANGUAGES.find(lng => lng.value === systemLanguage) ??
LANGUAGES.find(
lng =>
new Intl.Locale(lng.value).language ===
new Intl.Locale(systemLanguage).language
Expand All @@ -115,7 +110,7 @@ export function SystemLanguagePreferenceModal(): JSX.Element | null {
</StyledText>
{showBootModal ? (
<DropdownMenu
filterOptions={languageOptions}
filterOptions={LANGUAGES}
currentOption={currentOption}
onClick={handleDropdownClick}
title={t('select_language')}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components'

import {
BORDERS,
COLORS,
CURSOR_POINTER,
DIRECTION_COLUMN,
Flex,
SPACING,
StyledText,
} from '@opentrons/components'

import { LANGUAGES } from '/app/i18n'
import { ChildNavigation } from '/app/organisms/ODD/ChildNavigation'
import { getAppLanguage, updateConfigValue } from '/app/redux/config'

import type { Dispatch } from '/app/redux/types'
import type { SetSettingOption } from './types'

interface LabelProps {
isSelected?: boolean
}

const SettingButton = styled.input`
display: none;
`

const SettingButtonLabel = styled.label<LabelProps>`
padding: ${SPACING.spacing24};
border-radius: ${BORDERS.borderRadius16};
cursor: ${CURSOR_POINTER};
background: ${({ isSelected }) =>
isSelected === true ? COLORS.blue50 : COLORS.blue35};
color: ${({ isSelected }) => isSelected === true && COLORS.white};
`

interface LanguageSettingProps {
setCurrentOption: SetSettingOption
}

export function LanguageSetting({
setCurrentOption,
}: LanguageSettingProps): JSX.Element {
const { t } = useTranslation('app_settings')
const dispatch = useDispatch<Dispatch>()

const appLanguage = useSelector(getAppLanguage)

const handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
dispatch(updateConfigValue('language.appLanguage', event.target.value))
}

return (
<Flex flexDirection={DIRECTION_COLUMN}>
<ChildNavigation
header={t('language')}
onClickBack={() => {
setCurrentOption(null)
}}
/>
<Flex
flexDirection={DIRECTION_COLUMN}
gridGap={SPACING.spacing8}
marginTop="7.75rem"
padding={`${SPACING.spacing16} ${SPACING.spacing40} ${SPACING.spacing40} ${SPACING.spacing40}`}
>
{LANGUAGES.map(lng => (
<React.Fragment key={`language_setting_${lng.name}`}>
<SettingButton
id={lng.name}
type="radio"
value={lng.value}
checked={lng.value === appLanguage}
onChange={handleChange}
/>
<SettingButtonLabel
htmlFor={lng.name}
isSelected={lng.value === appLanguage}
>
<StyledText oddStyle="level4HeaderSemiBold">
{lng.name}
</StyledText>
</SettingButtonLabel>
</React.Fragment>
))}
</Flex>
</Flex>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type * as React from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '@testing-library/jest-dom/vitest'

import {
i18n,
US_ENGLISH_DISPLAY_NAME,
US_ENGLISH,
SIMPLIFIED_CHINESE_DISPLAY_NAME,
SIMPLIFIED_CHINESE,
} from '/app/i18n'
import { getAppLanguage, updateConfigValue } from '/app/redux/config'
import { renderWithProviders } from '/app/__testing-utils__'

import { LanguageSetting } from '../LanguageSetting'

vi.mock('/app/redux/config')

const mockSetCurrentOption = vi.fn()

const render = (props: React.ComponentProps<typeof LanguageSetting>) => {
return renderWithProviders(<LanguageSetting {...props} />, {
i18nInstance: i18n,
})
}

describe('LanguageSetting', () => {
let props: React.ComponentProps<typeof LanguageSetting>
beforeEach(() => {
props = {
setCurrentOption: mockSetCurrentOption,
}
vi.mocked(getAppLanguage).mockReturnValue(US_ENGLISH)
})

it('should render text and buttons', () => {
render(props)
screen.getByText('Language')
screen.getByText(US_ENGLISH_DISPLAY_NAME)
screen.getByText(SIMPLIFIED_CHINESE_DISPLAY_NAME)
})

it('should call mock function when tapping a language button', () => {
render(props)
const button = screen.getByText(SIMPLIFIED_CHINESE_DISPLAY_NAME)
fireEvent.click(button)
expect(updateConfigValue).toHaveBeenCalledWith(
'language.appLanguage',
SIMPLIFIED_CHINESE
)
})

it('should call mock function when tapping back button', () => {
render(props)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(props.setCurrentOption).toHaveBeenCalled()
})
})
1 change: 1 addition & 0 deletions app/src/organisms/ODD/RobotSettingsDashboard/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './DeviceReset'
export * from './LanguageSetting'
export * from './NetworkSettings/RobotSettingsJoinOtherNetwork'
export * from './NetworkSettings/RobotSettingsSelectAuthenticationType'
export * from './NetworkSettings/RobotSettingsSetWifiCred'
Expand Down
1 change: 1 addition & 0 deletions app/src/organisms/ODD/RobotSettingsDashboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ export type SettingOption =
| 'RobotSettingsSetWifiCred'
| 'RobotSettingsWifi'
| 'RobotSettingsWifiConnect'
| 'LanguageSetting'

export type SetSettingOption = (option: SettingOption | null) => void
Loading
Loading