From 3f9e4d410b5347451b1d513f98cb5373dd1071c0 Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Tue, 22 Oct 2024 17:17:16 -0400 Subject: [PATCH 1/5] feat(app): implement ODD choose language screen implements the choose language screen that will be the first step of the ODD unboxing flow. migration of the unboxing flow route config value from '/welcome' to '/choose-langauge' will happen in a future PR removing the localization feature flag. closes PLAT-537 --- app/src/App/OnDeviceDisplayApp.tsx | 4 + .../App/__tests__/OnDeviceDisplayApp.test.tsx | 6 ++ .../assets/localization/en/app_settings.json | 2 + app/src/i18n.ts | 11 +++ .../SystemLanguagePreferenceModal.test.tsx | 4 +- .../SystemLanguagePreferenceModal/index.tsx | 17 ++-- .../__tests__/ChooseLanguage.test.tsx | 59 ++++++++++++++ app/src/pages/ODD/ChooseLanguage/index.tsx | 79 +++++++++++++++++++ app/src/redux/config/schema-types.ts | 3 +- app/src/redux/config/selectors.ts | 2 +- 10 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 app/src/pages/ODD/ChooseLanguage/__tests__/ChooseLanguage.test.tsx create mode 100644 app/src/pages/ODD/ChooseLanguage/index.tsx diff --git a/app/src/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx index 42335754432..7be1070ca12 100644 --- a/app/src/App/OnDeviceDisplayApp.tsx +++ b/app/src/App/OnDeviceDisplayApp.tsx @@ -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' @@ -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', @@ -94,6 +96,8 @@ function getPathComponent( path: typeof ON_DEVICE_DISPLAY_PATHS[number] ): JSX.Element { switch (path) { + case '/choose-language': + return case '/dashboard': return case '/deck-configuration': diff --git a/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx b/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx index 662b2523436..2d2d09ca15e 100644 --- a/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx +++ b/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx @@ -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' @@ -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') @@ -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() diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index 5f6354da6c5..597cd9e2068 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -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.", @@ -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", diff --git a/app/src/i18n.ts b/app/src/i18n.ts index 0a9701a8e87..ed9ba17ff1f 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -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', diff --git a/app/src/organisms/Desktop/SystemLanguagePreferenceModal/__tests__/SystemLanguagePreferenceModal.test.tsx b/app/src/organisms/Desktop/SystemLanguagePreferenceModal/__tests__/SystemLanguagePreferenceModal.test.tsx index 3a378d5f04f..a91d7389072 100644 --- a/app/src/organisms/Desktop/SystemLanguagePreferenceModal/__tests__/SystemLanguagePreferenceModal.test.tsx +++ b/app/src/organisms/Desktop/SystemLanguagePreferenceModal/__tests__/SystemLanguagePreferenceModal.test.tsx @@ -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() @@ -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', () => { diff --git a/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx b/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx index 42ff74a0eb8..59ae8640f92 100644 --- a/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx +++ b/app/src/organisms/Desktop/SystemLanguagePreferenceModal/index.tsx @@ -15,6 +15,7 @@ import { StyledText, } from '@opentrons/components' +import { LANGUAGES } from '/app/i18n' import { getAppLanguage, getStoredSystemLanguage, @@ -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( - languageOptions[0] + LANGUAGES[0] ) const dispatch = useDispatch() @@ -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) @@ -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 @@ -115,7 +110,7 @@ export function SystemLanguagePreferenceModal(): JSX.Element | null { {showBootModal ? ( { + const actual = await importOriginal() + return { + ...actual, + useNavigate: () => mockNavigate, + } +}) +vi.mock('/app/redux/config') + +const render = () => { + return renderWithProviders( + + + , + { + 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') + }) +}) diff --git a/app/src/pages/ODD/ChooseLanguage/index.tsx b/app/src/pages/ODD/ChooseLanguage/index.tsx new file mode 100644 index 00000000000..d0110e68591 --- /dev/null +++ b/app/src/pages/ODD/ChooseLanguage/index.tsx @@ -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() + + 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 ( + + + + + + {t('select_a_language')} + + + {LANGUAGES.map(lng => ( + { + dispatch(updateConfigValue('language.appLanguage', lng.value)) + }} + > + ))} + + + { + navigate('/welcome') + }} + width="100%" + /> + + + ) +} diff --git a/app/src/redux/config/schema-types.ts b/app/src/redux/config/schema-types.ts index ce4af4ddaeb..a38ed931ec9 100644 --- a/app/src/redux/config/schema-types.ts +++ b/app/src/redux/config/schema-types.ts @@ -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:' @@ -31,8 +32,6 @@ export type QuickTransfersOnDeviceSortKey = | 'recentCreated' | 'oldCreated' -export type Language = 'en-US' | 'zh-CN' - export interface OnDeviceDisplaySettings { sleepMs: number brightness: number diff --git a/app/src/redux/config/selectors.ts b/app/src/redux/config/selectors.ts index 4069aa7320b..dbac2cd3c05 100644 --- a/app/src/redux/config/selectors.ts +++ b/app/src/redux/config/selectors.ts @@ -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 { From 3ed105079e9a405eefb4998d5c1f0bcdf165594b Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Wed, 23 Oct 2024 11:46:49 -0400 Subject: [PATCH 2/5] fix app github workflow --- .github/workflows/app-test-build-deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/app-test-build-deploy.yaml b/.github/workflows/app-test-build-deploy.yaml index d3c03e1f500..f0bfe7d8946 100644 --- a/.github/workflows/app-test-build-deploy.yaml +++ b/.github/workflows/app-test-build-deploy.yaml @@ -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" - name: 'Upload coverage report' uses: 'codecov/codecov-action@v3' with: From 88bf57a70d9a933551b2f768fe3f057f8dd1ddd0 Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Wed, 23 Oct 2024 12:14:06 -0400 Subject: [PATCH 3/5] fix config tests --- app/src/redux/config/__tests__/config.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/redux/config/__tests__/config.test.ts b/app/src/redux/config/__tests__/config.test.ts index d99eb95c36e..bf5b4e98004 100644 --- a/app/src/redux/config/__tests__/config.test.ts +++ b/app/src/redux/config/__tests__/config.test.ts @@ -28,6 +28,7 @@ describe('config', () => { expect(Cfg.configInitialized(state.config as any)).toEqual({ type: 'config:INITIALIZED', payload: { config: state.config }, + meta: { shell: true }, }) }) @@ -35,6 +36,7 @@ describe('config', () => { expect(Cfg.configValueUpdated('foo.bar', false)).toEqual({ type: 'config:VALUE_UPDATED', payload: { path: 'foo.bar', value: false }, + meta: { shell: true }, }) }) From bc58fdd86359da0513266854cbb0becd246e2cb7 Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Thu, 24 Oct 2024 10:26:08 -0400 Subject: [PATCH 4/5] feat(app): implement desktop app localization setting toggle adds the desktop app dropdown menu to choose language on the app settings general tab closes PLAT-507 --- .../assets/localization/en/app_settings.json | 3 + app/src/i18n.ts | 9 ++- .../Desktop/AppSettings/AdvancedSettings.tsx | 54 +------------- .../Desktop/AppSettings/GeneralSettings.tsx | 45 ++++++++++++ .../__test__/GeneralSettings.test.tsx | 72 ++++++++++++++----- 5 files changed, 108 insertions(+), 75 deletions(-) diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index 597cd9e2068..3f957ebe608 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -15,6 +15,8 @@ "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", @@ -49,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", diff --git a/app/src/i18n.ts b/app/src/i18n.ts index ed9ba17ff1f..e1c772cb582 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -10,12 +10,15 @@ 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 -// 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 }, + { name: US_ENGLISH_DISPLAY_NAME, value: US_ENGLISH }, + { name: SIMPLIFIED_CHINESE_DISPLAY_NAME, value: SIMPLIFIED_CHINESE }, ] const i18nConfig: InitOptions = { diff --git a/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx b/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx index 4eda66f68e1..e8f3724299b 100644 --- a/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx +++ b/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx @@ -1,16 +1,4 @@ -import { useContext } from 'react' -import { I18nContext } from 'react-i18next' -import { useDispatch } from 'react-redux' -import { css } from 'styled-components' - -import { - Box, - DIRECTION_COLUMN, - Flex, - RadioGroup, - SPACING, - TYPOGRAPHY, -} from '@opentrons/components' +import { Box, SPACING } from '@opentrons/components' import { Divider } from '/app/atoms/structure' import { @@ -25,9 +13,6 @@ import { UpdatedChannel, AdditionalCustomLabwareSourceFolder, } from '/app/organisms/Desktop/AdvancedSettings' -import { updateConfigValue, useFeatureFlag } from '/app/redux/config' - -import type { Dispatch } from '/app/redux/types' export function AdvancedSettings(): JSX.Element { return ( @@ -52,44 +37,7 @@ export function AdvancedSettings(): JSX.Element { - {/* TODO(bh, 2024-09-23): remove when localization setting designs implemented */} - ) } - -function LocalizationSetting(): JSX.Element | null { - const enableLocalization = useFeatureFlag('enableLocalization') - const dispatch = useDispatch() - - const { i18n } = useContext(I18nContext) - - return enableLocalization ? ( - <> - - - ) => { - dispatch( - updateConfigValue( - 'language.appLanguage', - event.currentTarget.value - ) - ) - }} - options={[ - { name: 'EN', value: 'en' }, - { name: 'CN', value: 'zh' }, - ]} - /> - - - ) : null -} diff --git a/app/src/pages/Desktop/AppSettings/GeneralSettings.tsx b/app/src/pages/Desktop/AppSettings/GeneralSettings.tsx index 82960177c9b..1da46c14f36 100644 --- a/app/src/pages/Desktop/AppSettings/GeneralSettings.tsx +++ b/app/src/pages/Desktop/AppSettings/GeneralSettings.tsx @@ -12,6 +12,7 @@ import { COLORS, DIRECTION_COLUMN, DIRECTION_ROW, + DropdownMenu, Flex, JUSTIFY_SPACE_BETWEEN, Link, @@ -25,6 +26,7 @@ import { import { TertiaryButton, ToggleButton } from '/app/atoms/buttons' import { ExternalLink } from '/app/atoms/Link/ExternalLink' import { Divider } from '/app/atoms/structure' +import { LANGUAGES } from '/app/i18n' import { CURRENT_VERSION, getAvailableShellUpdate, @@ -40,6 +42,11 @@ import { useTrackEvent, ANALYTICS_APP_UPDATE_NOTIFICATIONS_TOGGLED, } from '/app/redux/analytics' +import { + getAppLanguage, + updateConfigValue, + useFeatureFlag, +} from '/app/redux/config' import { UpdateAppModal } from '/app/organisms/Desktop/UpdateAppModal' import { PreviousVersionModal } from '/app/organisms/Desktop/AppSettings/PreviousVersionModal' import { ConnectRobotSlideout } from '/app/organisms/Desktop/AppSettings/ConnectRobotSlideout' @@ -62,6 +69,15 @@ export function GeneralSettings(): JSX.Element { setShowPreviousVersionModal, ] = useState(false) const updateAvailable = Boolean(useSelector(getAvailableShellUpdate)) + + const enableLocalization = useFeatureFlag('enableLocalization') + const appLanguage = useSelector(getAppLanguage) + const currentOption = LANGUAGES.find(lng => lng.value === appLanguage) + + const handleDropdownClick = (value: string): void => { + dispatch(updateConfigValue('language.appLanguage', value)) + } + const [showUpdateBanner, setShowUpdateBanner] = useState( updateAvailable ) @@ -260,6 +276,35 @@ export function GeneralSettings(): JSX.Element { {t('setup_connection')} + + {enableLocalization && currentOption != null ? ( + <> + + + + {t('app_language_preferences')} + + + {t('app_language_description')} + + + + + + + ) : null} {showUpdateModal ? createPortal( diff --git a/app/src/pages/Desktop/AppSettings/__test__/GeneralSettings.test.tsx b/app/src/pages/Desktop/AppSettings/__test__/GeneralSettings.test.tsx index 5cfd02e09a8..539c5899e8a 100644 --- a/app/src/pages/Desktop/AppSettings/__test__/GeneralSettings.test.tsx +++ b/app/src/pages/Desktop/AppSettings/__test__/GeneralSettings.test.tsx @@ -1,11 +1,23 @@ import { MemoryRouter } from 'react-router-dom' import { vi, it, describe, expect, beforeEach, afterEach } from 'vitest' -import { screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' +import { when } from 'vitest-when' import { renderWithProviders } from '/app/__testing-utils__' -import { i18n } from '/app/i18n' +import { + i18n, + SIMPLIFIED_CHINESE, + SIMPLIFIED_CHINESE_DISPLAY_NAME, + US_ENGLISH, + US_ENGLISH_DISPLAY_NAME, +} from '/app/i18n' import { getAlertIsPermanentlyIgnored } from '/app/redux/alerts' +import { + getAppLanguage, + updateConfigValue, + useFeatureFlag, +} from '/app/redux/config' import * as Shell from '/app/redux/shell' import { GeneralSettings } from '../GeneralSettings' @@ -29,34 +41,38 @@ describe('GeneralSettings', () => { beforeEach(() => { vi.mocked(Shell.getAvailableShellUpdate).mockReturnValue(null) vi.mocked(getAlertIsPermanentlyIgnored).mockReturnValue(false) + vi.mocked(getAppLanguage).mockReturnValue(US_ENGLISH) + when(vi.mocked(useFeatureFlag)) + .calledWith('enableLocalization') + .thenReturn(true) }) afterEach(() => { vi.resetAllMocks() }) it('renders correct titles', () => { - const [{ getByText }] = render() - getByText('App Software Version') - getByText('Software Update Alerts') - getByText('Connect to a Robot via IP Address') + render() + screen.getByText('App Software Version') + screen.getByText('Software Update Alerts') + screen.getByText('Connect to a Robot via IP Address') }) it('renders software version section with no update available', () => { - const [{ getByText, getByRole }] = render() - getByText('Up to date') - getByText('View latest release notes on') - expect(getByRole('link', { name: 'GitHub' })).toHaveAttribute( + render() + screen.getByText('Up to date') + screen.getByText('View latest release notes on') + expect(screen.getByRole('link', { name: 'GitHub' })).toHaveAttribute( 'href', 'https://github.com/Opentrons/opentrons/blob/edge/app-shell/build/release-notes.md' ) - getByRole('button', { + screen.getByRole('button', { name: 'See how to restore a previous software version', }) expect( '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.' ).toBeTruthy() expect( - getByRole('link', { + screen.getByRole('link', { name: 'Learn more about keeping the Opentrons App and robot software in sync', }) @@ -65,8 +81,8 @@ describe('GeneralSettings', () => { it('renders correct info if there is update available', () => { vi.mocked(Shell.getAvailableShellUpdate).mockReturnValue('5.0.0-beta.8') - const [{ getByRole }] = render() - getByRole('button', { name: 'View software update' }) + render() + screen.getByRole('button', { name: 'View software update' }) }) it('renders correct info if there is no update available', () => { @@ -80,17 +96,35 @@ describe('GeneralSettings', () => { }) it('renders the text and toggle for update alert section', () => { - const [{ getByText, getByRole }] = render() - getByText( + render() + screen.getByText( 'Receive an alert when an Opentrons software update is available.' ) - getByRole('switch', { + screen.getByRole('switch', { name: 'Enable app update notifications', }) }) it('renders the ip address button', () => { - const [{ getByRole }] = render() - getByRole('button', { name: 'Set up connection' }) + render() + screen.getByRole('button', { name: 'Set up connection' }) + }) + + it('renders the text and dropdown for the app language preferences section', () => { + render() + screen.getByText('App Language Preferences') + screen.getByText( + 'All app features use this language. Protocols and other user content will not change language.' + ) + fireEvent.click(screen.getByText(US_ENGLISH_DISPLAY_NAME)) + fireEvent.click( + screen.getByRole('button', { + name: SIMPLIFIED_CHINESE_DISPLAY_NAME, + }) + ) + expect(updateConfigValue).toBeCalledWith( + 'language.appLanguage', + SIMPLIFIED_CHINESE + ) }) }) From d5e9932641d680d2e26ff6f6b608ab6897719ad8 Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Thu, 24 Oct 2024 15:09:07 -0400 Subject: [PATCH 5/5] feat(app,components): implement ODD language setting toggle adds the ODD language setting toggle to the robot settings page closes PLAT-506 --- .../LanguageSetting.tsx | 92 +++++++++++++++++++ .../__tests__/LanguageSetting.test.tsx | 60 ++++++++++++ .../ODD/RobotSettingsDashboard/index.ts | 1 + .../ODD/RobotSettingsDashboard/types.ts | 1 + .../Desktop/AppSettings/GeneralSettings.tsx | 6 +- .../RobotSettingsList.tsx | 43 ++++----- .../__tests__/RobotSettingsDashboard.test.tsx | 25 ++++- .../ODD/RobotSettingsDashboard/index.tsx | 4 + components/src/icons/icon-data.ts | 10 +- 9 files changed, 213 insertions(+), 29 deletions(-) create mode 100644 app/src/organisms/ODD/RobotSettingsDashboard/LanguageSetting.tsx create mode 100644 app/src/organisms/ODD/RobotSettingsDashboard/__tests__/LanguageSetting.test.tsx diff --git a/app/src/organisms/ODD/RobotSettingsDashboard/LanguageSetting.tsx b/app/src/organisms/ODD/RobotSettingsDashboard/LanguageSetting.tsx new file mode 100644 index 00000000000..a935a5571ad --- /dev/null +++ b/app/src/organisms/ODD/RobotSettingsDashboard/LanguageSetting.tsx @@ -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` + 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() + + const appLanguage = useSelector(getAppLanguage) + + const handleChange = (event: React.ChangeEvent): void => { + dispatch(updateConfigValue('language.appLanguage', event.target.value)) + } + + return ( + + { + setCurrentOption(null) + }} + /> + + {LANGUAGES.map(lng => ( + + + + + {lng.name} + + + + ))} + + + ) +} diff --git a/app/src/organisms/ODD/RobotSettingsDashboard/__tests__/LanguageSetting.test.tsx b/app/src/organisms/ODD/RobotSettingsDashboard/__tests__/LanguageSetting.test.tsx new file mode 100644 index 00000000000..80d35ebea15 --- /dev/null +++ b/app/src/organisms/ODD/RobotSettingsDashboard/__tests__/LanguageSetting.test.tsx @@ -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) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('LanguageSetting', () => { + let props: React.ComponentProps + 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() + }) +}) diff --git a/app/src/organisms/ODD/RobotSettingsDashboard/index.ts b/app/src/organisms/ODD/RobotSettingsDashboard/index.ts index 30933095135..a468c86829b 100644 --- a/app/src/organisms/ODD/RobotSettingsDashboard/index.ts +++ b/app/src/organisms/ODD/RobotSettingsDashboard/index.ts @@ -1,4 +1,5 @@ export * from './DeviceReset' +export * from './LanguageSetting' export * from './NetworkSettings/RobotSettingsJoinOtherNetwork' export * from './NetworkSettings/RobotSettingsSelectAuthenticationType' export * from './NetworkSettings/RobotSettingsSetWifiCred' diff --git a/app/src/organisms/ODD/RobotSettingsDashboard/types.ts b/app/src/organisms/ODD/RobotSettingsDashboard/types.ts index 231d26c837b..78e1f552daa 100644 --- a/app/src/organisms/ODD/RobotSettingsDashboard/types.ts +++ b/app/src/organisms/ODD/RobotSettingsDashboard/types.ts @@ -17,5 +17,6 @@ export type SettingOption = | 'RobotSettingsSetWifiCred' | 'RobotSettingsWifi' | 'RobotSettingsWifiConnect' + | 'LanguageSetting' export type SetSettingOption = (option: SettingOption | null) => void diff --git a/app/src/pages/Desktop/AppSettings/GeneralSettings.tsx b/app/src/pages/Desktop/AppSettings/GeneralSettings.tsx index 1da46c14f36..db948403fd0 100644 --- a/app/src/pages/Desktop/AppSettings/GeneralSettings.tsx +++ b/app/src/pages/Desktop/AppSettings/GeneralSettings.tsx @@ -72,7 +72,7 @@ export function GeneralSettings(): JSX.Element { const enableLocalization = useFeatureFlag('enableLocalization') const appLanguage = useSelector(getAppLanguage) - const currentOption = LANGUAGES.find(lng => lng.value === appLanguage) + const currentLanguageOption = LANGUAGES.find(lng => lng.value === appLanguage) const handleDropdownClick = (value: string): void => { dispatch(updateConfigValue('language.appLanguage', value)) @@ -277,7 +277,7 @@ export function GeneralSettings(): JSX.Element { - {enableLocalization && currentOption != null ? ( + {enableLocalization && currentLanguageOption != null ? ( <> lng.value === appLanguage) + const enableLocalization = useFeatureFlag('enableLocalization') + return ( @@ -139,6 +143,18 @@ export function RobotSettingsList(props: RobotSettingsListProps): JSX.Element { } /> + {enableLocalization ? ( + { + setCurrentOption('LanguageSetting') + }} + iconName="language" + /> + ) : null} dispatch(toggleDevtools())} /> {devToolsOn ? : null} - {/* TODO(bh, 2024-09-23): remove when localization setting designs implemented */} - ) @@ -282,22 +296,3 @@ function FeatureFlags(): JSX.Element { ) } - -function LanguageToggle(): JSX.Element | null { - const enableLocalization = useFeatureFlag('enableLocalization') - const dispatch = useDispatch() - - const { i18n } = useContext(I18nContext) - - return enableLocalization ? ( - { - i18n.language === 'en' - ? dispatch(updateConfigValue('language.appLanguage', 'zh')) - : dispatch(updateConfigValue('language.appLanguage', 'en')) - }} - rightElement={<>} - /> - ) : null -} diff --git a/app/src/pages/ODD/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx b/app/src/pages/ODD/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx index 07fdb119ee4..00b70120809 100644 --- a/app/src/pages/ODD/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx +++ b/app/src/pages/ODD/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx @@ -1,19 +1,26 @@ import { vi, it, describe, expect, beforeEach, afterEach } from 'vitest' import { MemoryRouter } from 'react-router-dom' import { fireEvent, screen } from '@testing-library/react' +import { when } from 'vitest-when' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' import { getRobotSettings } from '/app/redux/robot-settings' import { getLocalRobot } from '/app/redux/discovery' -import { toggleDevtools, toggleHistoricOffsets } from '/app/redux/config' +import { + getAppLanguage, + toggleDevtools, + toggleHistoricOffsets, + useFeatureFlag, +} from '/app/redux/config' import { mockConnectedRobot } from '/app/redux/discovery/__fixtures__' import { Navigation } from '/app/organisms/ODD/Navigation' import { DeviceReset, TouchScreenSleep, TouchscreenBrightness, + LanguageSetting, NetworkSettings, Privacy, RobotSystemVersion, @@ -44,6 +51,7 @@ vi.mock('/app/organisms/ODD/RobotSettingsDashboard/RobotSystemVersion') vi.mock('/app/organisms/ODD/RobotSettingsDashboard/TouchscreenBrightness') vi.mock('/app/organisms/ODD/RobotSettingsDashboard/UpdateChannel') vi.mock('/app/organisms/ODD/RobotSettingsDashboard/Privacy') +vi.mock('/app/organisms/ODD/RobotSettingsDashboard/LanguageSetting') const mockToggleLights = vi.fn() const mockToggleER = vi.fn() @@ -59,6 +67,8 @@ const render = () => { ) } +const MOCK_DEFAULT_LANGUAGE = 'en-US' + // Note kj 01/25/2023 Currently test cases only check text since this PR is bare-bones for RobotSettings Dashboard describe('RobotSettingsDashboard', () => { beforeEach(() => { @@ -81,6 +91,10 @@ describe('RobotSettingsDashboard', () => { isEREnabled: true, toggleERSettings: mockToggleER, }) + vi.mocked(getAppLanguage).mockReturnValue(MOCK_DEFAULT_LANGUAGE) + when(vi.mocked(useFeatureFlag)) + .calledWith('enableLocalization') + .thenReturn(true) }) afterEach(() => { @@ -249,4 +263,13 @@ describe('RobotSettingsDashboard', () => { render() screen.getByText('Update available') }) + + it('should render component when tapping Language', () => { + render() + + screen.getByText('English (US)') + const button = screen.getByText('Language') + fireEvent.click(button) + expect(vi.mocked(LanguageSetting)).toHaveBeenCalled() + }) }) diff --git a/app/src/pages/ODD/RobotSettingsDashboard/index.tsx b/app/src/pages/ODD/RobotSettingsDashboard/index.tsx index 30925f1ae44..401c4815aac 100644 --- a/app/src/pages/ODD/RobotSettingsDashboard/index.tsx +++ b/app/src/pages/ODD/RobotSettingsDashboard/index.tsx @@ -8,6 +8,7 @@ import { DeviceReset, TouchscreenBrightness, TouchScreenSleep, + LanguageSetting, NetworkSettings, Privacy, RobotName, @@ -200,6 +201,9 @@ export function RobotSettingsDashboard(): JSX.Element { /> ) + case 'LanguageSetting': + return + // fallthrough option: render the robot settings list of buttons default: return diff --git a/components/src/icons/icon-data.ts b/components/src/icons/icon-data.ts index edf90a1512c..9f8338c7e87 100644 --- a/components/src/icons/icon-data.ts +++ b/components/src/icons/icon-data.ts @@ -1,5 +1,8 @@ // icon data -export const ICON_DATA_BY_NAME = { +export const ICON_DATA_BY_NAME: Record< + string, + { path: string; viewBox: string } +> = { add: { path: 'M24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24C0 37.2548 10.7452 48 24 48ZM21 21V12H27V21H36V27H27V36H21V27H12V21H21Z', @@ -272,6 +275,11 @@ export const ICON_DATA_BY_NAME = { 'M8.63355 14.215C8.6365 14.5124 8.63911 14.7764 8.63911 15H7.36528C7.36528 14.8447 7.36714 14.6621 7.36924 14.4568C7.38181 13.225 7.40273 11.1766 7.07922 9.31768C6.89019 8.23151 6.59339 7.27753 6.15429 6.60988C5.73178 5.96745 5.2075 5.625 4.50024 5.625C3.79297 5.625 3.2687 5.96745 2.84618 6.60988C2.40708 7.27753 2.11028 8.23151 1.92125 9.31768C1.59774 11.1766 1.61866 13.225 1.63124 14.4568C1.63333 14.6621 1.6352 14.8447 1.6352 15H0.385197C0.385197 14.8588 0.383339 14.6876 0.38121 14.4914C0.367987 13.273 0.344279 11.0886 0.689764 9.10337C0.890012 7.95271 1.2241 6.80142 1.80181 5.92302C2.39611 5.01939 3.27456 4.375 4.50024 4.375C5.72592 4.375 6.60437 5.01939 7.19867 5.92301C7.40983 6.24409 7.58845 6.60162 7.73987 6.98226C7.75025 6.91343 7.76092 6.84476 7.77188 6.77626C8.02647 5.18496 8.4487 3.62176 9.16765 2.44065C9.89976 1.23791 10.9823 0.375 12.5141 0.375C14.0321 0.375 15.1148 1.19161 15.852 2.35243C16.5736 3.48863 16.9954 4.99227 17.2488 6.52295C17.6344 8.85309 17.6513 11.4038 17.6372 13.1367L19.1208 12.0031L19.8797 12.9963L17.0169 15.1838L14.1235 12.9984L14.8769 12.001L16.3866 13.1413C16.4006 11.422 16.3863 8.96723 16.0155 6.72705C15.7724 5.25773 15.3851 3.94887 14.7968 3.02257C14.2242 2.12089 13.4961 1.625 12.5141 1.625C11.5458 1.625 10.8158 2.13709 10.2354 3.0906C9.64181 4.06574 9.25156 5.44004 9.00618 6.97374C8.58775 9.58904 8.61631 12.4739 8.63355 14.215Z', viewBox: '0 0 20 16', }, + language: { + path: + 'M10 18.3333C8.83335 18.3333 7.74308 18.1146 6.72919 17.6771C5.7153 17.2396 4.83335 16.6458 4.08335 15.8958C3.33335 15.1458 2.74308 14.2604 2.31252 13.2396C1.88196 12.2187 1.66669 11.125 1.66669 9.95832C1.66669 8.79166 1.88196 7.70485 2.31252 6.69791C2.74308 5.69096 3.33335 4.81249 4.08335 4.06249C4.83335 3.31249 5.7153 2.72568 6.72919 2.30207C7.74308 1.87846 8.83335 1.66666 10 1.66666C11.1667 1.66666 12.257 1.87846 13.2709 2.30207C14.2847 2.72568 15.1667 3.31249 15.9167 4.06249C16.6667 4.81249 17.257 5.69096 17.6875 6.69791C18.1181 7.70485 18.3334 8.79166 18.3334 9.95832C18.3334 11.125 18.1181 12.2187 17.6875 13.2396C17.257 14.2604 16.6667 15.1458 15.9167 15.8958C15.1667 16.6458 14.2847 17.2396 13.2709 17.6771C12.257 18.1146 11.1667 18.3333 10 18.3333ZM10 17.125C10.4861 16.625 10.8924 16.0521 11.2188 15.4062C11.5452 14.7604 11.8125 13.993 12.0209 13.1042H8.00002C8.19446 13.9375 8.45488 14.6875 8.78127 15.3542C9.10766 16.0208 9.51391 16.6111 10 17.125ZM8.22919 16.875C7.88196 16.3472 7.58335 15.7778 7.33335 15.1667C7.08335 14.5555 6.87502 13.868 6.70835 13.1042H3.58335C4.11113 14.0903 4.72224 14.8646 5.41669 15.4271C6.11113 15.9896 7.04863 16.4722 8.22919 16.875ZM11.7917 16.8542C12.7917 16.5347 13.691 16.0555 14.4896 15.4167C15.2882 14.7778 15.9306 14.0069 16.4167 13.1042H13.3125C13.132 13.8542 12.9202 14.5347 12.6771 15.1458C12.434 15.7569 12.1389 16.3264 11.7917 16.8542ZM3.16669 11.8542H6.47919C6.43752 11.4792 6.41321 11.1424 6.40627 10.8437C6.39933 10.5451 6.39585 10.25 6.39585 9.95832C6.39585 9.6111 6.4028 9.30207 6.41669 9.03124C6.43058 8.76041 6.45835 8.45832 6.50002 8.12499H3.16669C3.06946 8.45832 3.00349 8.75693 2.96877 9.02082C2.93405 9.28471 2.91669 9.59721 2.91669 9.95832C2.91669 10.3194 2.93405 10.6424 2.96877 10.9271C3.00349 11.2118 3.06946 11.5208 3.16669 11.8542ZM7.77085 11.8542H12.25C12.3056 11.4236 12.3403 11.0729 12.3542 10.8021C12.3681 10.5312 12.375 10.25 12.375 9.95832C12.375 9.68054 12.3681 9.41318 12.3542 9.15624C12.3403 8.89929 12.3056 8.55554 12.25 8.12499H7.77085C7.7153 8.55554 7.68058 8.89929 7.66669 9.15624C7.6528 9.41318 7.64585 9.68054 7.64585 9.95832C7.64585 10.25 7.6528 10.5312 7.66669 10.8021C7.68058 11.0729 7.7153 11.4236 7.77085 11.8542ZM13.5 11.8542H16.8334C16.9306 11.5208 16.9965 11.2118 17.0313 10.9271C17.066 10.6424 17.0834 10.3194 17.0834 9.95832C17.0834 9.59721 17.066 9.28471 17.0313 9.02082C16.9965 8.75693 16.9306 8.45832 16.8334 8.12499H13.5209C13.5625 8.6111 13.5903 8.98263 13.6042 9.23957C13.6181 9.49652 13.625 9.7361 13.625 9.95832C13.625 10.2639 13.6146 10.5521 13.5938 10.8229C13.5729 11.0937 13.5417 11.4375 13.5 11.8542ZM13.2917 6.87499H16.4167C15.9584 5.91666 15.3299 5.11805 14.5313 4.47916C13.7327 3.84027 12.8125 3.38888 11.7709 3.12499C12.1181 3.63888 12.4132 4.19443 12.6563 4.79166C12.8993 5.38888 13.1111 6.08332 13.2917 6.87499ZM8.00002 6.87499H12.0417C11.8889 6.13888 11.632 5.42707 11.2709 4.73957C10.9097 4.05207 10.4861 3.44443 10 2.91666C9.55558 3.29166 9.18058 3.78471 8.87502 4.39582C8.56946 5.00693 8.2778 5.83332 8.00002 6.87499ZM3.58335 6.87499H6.72919C6.88196 6.12499 7.07641 5.45485 7.31252 4.86457C7.54863 4.2743 7.84724 3.70138 8.20835 3.14582C7.16669 3.40971 6.25696 3.85416 5.47919 4.47916C4.70141 5.10416 4.06946 5.90277 3.58335 6.87499Z', + viewBox: '0 0 20 20', + }, 'latch-closed': { path: 'M33.6663 10H6.33301V17H10.333V14H14.167V19.166H26.667V14H29.6663V17H33.6663V10Z',