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
Merged
Show file tree
Hide file tree
Changes from 3 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: 1 addition & 1 deletion .github/workflows/app-test-build-deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ jobs:
yarn config set cache-folder ${{ github.workspace }}/.yarn-cache
make setup-js
- name: 'test native(er) packages'
run: make test-js-internal tests="${{}matrix.shell}/src" cov_opts="--coverage=true"
run: make test-js-internal tests="${{matrix.shell}}/src" cov_opts="--coverage=true"
brenthagen marked this conversation as resolved.
Show resolved Hide resolved
- name: 'Upload coverage report'
uses: 'codecov/codecov-action@v3'
with:
Expand Down
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
2 changes: 2 additions & 0 deletions app/src/assets/localization/en/app_settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"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 @@ -73,6 +74,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
11 changes: 11 additions & 0 deletions app/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ import { titleCase } from '@opentrons/shared-data'

import type { InitOptions } from 'i18next'

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

export type Language = typeof US_ENGLISH | typeof SIMPLIFIED_CHINESE

// these strings will not be translated so should not be localized
export const LANGUAGES: Array<{ name: string; value: Language }> = [
{ name: 'English (US)', value: US_ENGLISH },
{ 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,59 @@
import { vi, it, describe, expect } from 'vitest'
import { fireEvent, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'

import { renderWithProviders } from '/app/__testing-utils__'
import { i18n } from '/app/i18n'
import { updateConfigValue } from '/app/redux/config'
import { ChooseLanguage } from '..'

import type { NavigateFunction } from 'react-router-dom'

const mockNavigate = vi.fn()
vi.mock('react-router-dom', async importOriginal => {
const actual = await importOriginal<NavigateFunction>()
return {
...actual,
useNavigate: () => mockNavigate,
}
})
vi.mock('/app/redux/config')

const render = () => {
return renderWithProviders(
<MemoryRouter>
<ChooseLanguage />
</MemoryRouter>,
{
i18nInstance: i18n,
}
)
}

describe('ChooseLanguage', () => {
it('should render text, language options, and continue button', () => {
render()
screen.getByText('Choose your language')
screen.getByText('Select a language to personalize your experience.')
screen.getByRole('label', { name: 'English (US)' })
screen.getByRole('label', { name: '中文' })
screen.getByRole('button', { name: 'Continue' })
})

it('should initialize english', () => {
render()
expect(updateConfigValue).toBeCalledWith('language.appLanguage', 'en-US')
})

it('should change language when language option selected', () => {
render()
fireEvent.click(screen.getByRole('label', { name: '中文' }))
expect(updateConfigValue).toBeCalledWith('language.appLanguage', 'zh-CN')
})

it('should call mockNavigate when tapping continue', () => {
render()
fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
expect(mockNavigate).toHaveBeenCalledWith('/welcome')
})
})
79 changes: 79 additions & 0 deletions app/src/pages/ODD/ChooseLanguage/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import { useNavigate } from 'react-router-dom'

import {
DIRECTION_COLUMN,
Flex,
JUSTIFY_SPACE_BETWEEN,
RadioButton,
SPACING,
StyledText,
TYPOGRAPHY,
} from '@opentrons/components'

import { MediumButton } from '/app/atoms/buttons'
import { LANGUAGES, US_ENGLISH } from '/app/i18n'
import { RobotSetupHeader } from '/app/organisms/ODD/RobotSetupHeader'
import { getAppLanguage, updateConfigValue } from '/app/redux/config'

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

export function ChooseLanguage(): JSX.Element {
const { i18n, t } = useTranslation(['app_settings', 'shared'])
const navigate = useNavigate()
const dispatch = useDispatch<Dispatch>()

const appLanguage = useSelector(getAppLanguage)

useEffect(() => {
// initialize en-US language on mount
dispatch(updateConfigValue('language.appLanguage', US_ENGLISH))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

return (
<Flex
flexDirection={DIRECTION_COLUMN}
height="100%"
padding={`0 ${SPACING.spacing40} ${SPACING.spacing40} ${SPACING.spacing40}`}
>
<RobotSetupHeader header={t('choose_your_language')} />
<Flex
flex="1"
flexDirection={DIRECTION_COLUMN}
justifyContent={JUSTIFY_SPACE_BETWEEN}
>
<Flex flexDirection={DIRECTION_COLUMN} gridGap={SPACING.spacing24}>
<StyledText
oddStyle="level4HeaderRegular"
textAlign={TYPOGRAPHY.textAlignCenter}
>
{t('select_a_language')}
</StyledText>
<Flex flexDirection={DIRECTION_COLUMN} gridGap={SPACING.spacing4}>
{LANGUAGES.map(lng => (
<RadioButton
key={lng.value}
buttonLabel={lng.name}
buttonValue={lng.value}
isSelected={lng.value === appLanguage}
onChange={() => {
dispatch(updateConfigValue('language.appLanguage', lng.value))
}}
></RadioButton>
))}
</Flex>
</Flex>
<MediumButton
buttonText={i18n.format(t('shared:continue'), 'capitalize')}
onClick={() => {
navigate('/welcome')
}}
width="100%"
/>
</Flex>
</Flex>
)
}
3 changes: 1 addition & 2 deletions app/src/redux/config/schema-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { LogLevel } from '../../logger'
import type { Language } from '/app/i18n'
import type { ProtocolSort } from '/app/redux/protocol-storage'

export type UrlProtocol = 'file:' | 'http:'
Expand Down Expand Up @@ -31,8 +32,6 @@ export type QuickTransfersOnDeviceSortKey =
| 'recentCreated'
| 'oldCreated'

export type Language = 'en-US' | 'zh-CN'

export interface OnDeviceDisplaySettings {
sleepMs: number
brightness: number
Expand Down
2 changes: 1 addition & 1 deletion app/src/redux/config/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import type {
ProtocolsOnDeviceSortKey,
QuickTransfersOnDeviceSortKey,
OnDeviceDisplaySettings,
Language,
} from './types'
import type { Language } from '/app/i18n'
import type { ProtocolSort } from '/app/redux/protocol-storage'

export interface SelectOption {
Expand Down
Loading