From b697196502d1bca49989bc504da118f33dab7d1f Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Tue, 1 Oct 2024 11:04:16 -0400 Subject: [PATCH 1/3] feat(app,app-shell,app-shell-odd): add language setting to app config adds a config language value to desktop/ODD and initializes i18n language to the stored config value closes PLAT-504 --- app-shell-odd/src/config/__fixtures__/index.ts | 7 +++++++ .../src/config/__tests__/migrate.test.ts | 14 +++++++++++--- app-shell-odd/src/config/migrate.ts | 17 +++++++++++++++-- app-shell/src/__fixtures__/config.ts | 7 +++++++ app-shell/src/config/__tests__/migrate.test.ts | 14 +++++++++++--- app-shell/src/config/migrate.ts | 17 +++++++++++++++-- app/src/redux/config/schema-types.ts | 9 ++++++++- 7 files changed, 74 insertions(+), 11 deletions(-) diff --git a/app-shell-odd/src/config/__fixtures__/index.ts b/app-shell-odd/src/config/__fixtures__/index.ts index d670234ebbc..50e0bbde994 100644 --- a/app-shell-odd/src/config/__fixtures__/index.ts +++ b/app-shell-odd/src/config/__fixtures__/index.ts @@ -12,6 +12,7 @@ import type { ConfigV22, ConfigV23, ConfigV24, + ConfigV25, } from '@opentrons/app/src/redux/config/types' const PKG_VERSION: string = _PKG_VERSION_ @@ -171,3 +172,9 @@ export const MOCK_CONFIG_V24: ConfigV24 = { userId: 'MOCK_UUIDv4', }, } + +export const MOCK_CONFIG_V25: ConfigV25 = { + ...MOCK_CONFIG_V24, + version: 25, + language: null, +} diff --git a/app-shell-odd/src/config/__tests__/migrate.test.ts b/app-shell-odd/src/config/__tests__/migrate.test.ts index dcc8eb03708..7ea91ee8d53 100644 --- a/app-shell-odd/src/config/__tests__/migrate.test.ts +++ b/app-shell-odd/src/config/__tests__/migrate.test.ts @@ -16,13 +16,14 @@ import { MOCK_CONFIG_V22, MOCK_CONFIG_V23, MOCK_CONFIG_V24, + MOCK_CONFIG_V25, } from '../__fixtures__' import { migrate } from '../migrate' vi.mock('uuid/v4') -const NEWEST_VERSION = 24 -const NEWEST_MOCK_CONFIG = MOCK_CONFIG_V24 +const NEWEST_VERSION = 25 +const NEWEST_MOCK_CONFIG = MOCK_CONFIG_V25 describe('config migration', () => { beforeEach(() => { @@ -121,10 +122,17 @@ describe('config migration', () => { expect(result.version).toBe(NEWEST_VERSION) expect(result).toEqual(NEWEST_MOCK_CONFIG) }) - it('should keep version 24', () => { + it('should migrate version 24 to latest', () => { const v24Config = MOCK_CONFIG_V24 const result = migrate(v24Config) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(NEWEST_MOCK_CONFIG) + }) + it('should keep version 25', () => { + const v25Config = MOCK_CONFIG_V25 + const result = migrate(v25Config) + expect(result.version).toBe(NEWEST_VERSION) expect(result).toEqual(NEWEST_MOCK_CONFIG) }) diff --git a/app-shell-odd/src/config/migrate.ts b/app-shell-odd/src/config/migrate.ts index d1e9103d430..9876f35f5ef 100644 --- a/app-shell-odd/src/config/migrate.ts +++ b/app-shell-odd/src/config/migrate.ts @@ -17,13 +17,14 @@ import type { ConfigV22, ConfigV23, ConfigV24, + ConfigV25, } from '@opentrons/app/src/redux/config/types' // format // base config v12 defaults // any default values for later config versions are specified in the migration // functions for those version below -const CONFIG_VERSION_LATEST = 23 // update this after each config version bump +const CONFIG_VERSION_LATEST = 25 // update this after each config version bump const PKG_VERSION: string = _PKG_VERSION_ export const DEFAULTS_V12: ConfigV12 = { @@ -226,6 +227,15 @@ const toVersion24 = (prevConfig: ConfigV23): ConfigV24 => { } } +const toVersion25 = (prevConfig: ConfigV24): ConfigV25 => { + const nextConfig = { + ...prevConfig, + version: 25 as const, + language: null, + } + return nextConfig +} + const MIGRATIONS: [ (prevConfig: ConfigV12) => ConfigV13, (prevConfig: ConfigV13) => ConfigV14, @@ -238,7 +248,8 @@ const MIGRATIONS: [ (prevConfig: ConfigV20) => ConfigV21, (prevConfig: ConfigV21) => ConfigV22, (prevConfig: ConfigV22) => ConfigV23, - (prevConfig: ConfigV23) => ConfigV24 + (prevConfig: ConfigV23) => ConfigV24, + (prevConfig: ConfigV24) => ConfigV25 ] = [ toVersion13, toVersion14, @@ -252,6 +263,7 @@ const MIGRATIONS: [ toVersion22, toVersion23, toVersion24, + toVersion25, ] export const DEFAULTS: Config = migrate(DEFAULTS_V12) @@ -271,6 +283,7 @@ export function migrate( | ConfigV22 | ConfigV23 | ConfigV24 + | ConfigV25 ): Config { let result = prevConfig // loop through the migrations, skipping any migrations that are unnecessary diff --git a/app-shell/src/__fixtures__/config.ts b/app-shell/src/__fixtures__/config.ts index 23ef4f56f90..a6dff8a44df 100644 --- a/app-shell/src/__fixtures__/config.ts +++ b/app-shell/src/__fixtures__/config.ts @@ -24,6 +24,7 @@ import type { ConfigV22, ConfigV23, ConfigV24, + ConfigV25, } from '@opentrons/app/src/redux/config/types' export const MOCK_CONFIG_V0: ConfigV0 = { @@ -302,3 +303,9 @@ export const MOCK_CONFIG_V24: ConfigV24 = { userId: 'MOCK_UUIDv4', }, } + +export const MOCK_CONFIG_V25: ConfigV25 = { + ...MOCK_CONFIG_V24, + version: 25, + language: null, +} diff --git a/app-shell/src/config/__tests__/migrate.test.ts b/app-shell/src/config/__tests__/migrate.test.ts index dee16e0dae4..ddc151fc2cf 100644 --- a/app-shell/src/config/__tests__/migrate.test.ts +++ b/app-shell/src/config/__tests__/migrate.test.ts @@ -28,13 +28,14 @@ import { MOCK_CONFIG_V22, MOCK_CONFIG_V23, MOCK_CONFIG_V24, + MOCK_CONFIG_V25, } from '../../__fixtures__' import { migrate } from '../migrate' vi.mock('uuid/v4') -const NEWEST_VERSION = 24 -const NEWEST_MOCK_CONFIG = MOCK_CONFIG_V24 +const NEWEST_VERSION = 25 +const NEWEST_MOCK_CONFIG = MOCK_CONFIG_V25 describe('config migration', () => { beforeEach(() => { @@ -226,10 +227,17 @@ describe('config migration', () => { expect(result.version).toBe(NEWEST_VERSION) expect(result).toEqual(NEWEST_MOCK_CONFIG) }) - it('should keep version 24', () => { + it('should migrate version 24 to latest', () => { const v24Config = MOCK_CONFIG_V24 const result = migrate(v24Config) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(NEWEST_MOCK_CONFIG) + }) + it('should keep version 25', () => { + const v25Config = MOCK_CONFIG_V25 + const result = migrate(v25Config) + expect(result.version).toBe(NEWEST_VERSION) expect(result).toEqual(NEWEST_MOCK_CONFIG) }) diff --git a/app-shell/src/config/migrate.ts b/app-shell/src/config/migrate.ts index fa9ed4a91dd..88956f5cf17 100644 --- a/app-shell/src/config/migrate.ts +++ b/app-shell/src/config/migrate.ts @@ -28,13 +28,14 @@ import type { ConfigV22, ConfigV23, ConfigV24, + ConfigV25, } from '@opentrons/app/src/redux/config/types' // format // base config v0 defaults // any default values for later config versions are specified in the migration // functions for those version below -const CONFIG_VERSION_LATEST = 23 +const CONFIG_VERSION_LATEST = 25 export const DEFAULTS_V0: ConfigV0 = { version: 0, @@ -430,6 +431,15 @@ const toVersion24 = (prevConfig: ConfigV23): ConfigV24 => { } } +const toVersion25 = (prevConfig: ConfigV24): ConfigV25 => { + const nextConfig = { + ...prevConfig, + version: 25 as const, + language: null, + } + return nextConfig +} + const MIGRATIONS: [ (prevConfig: ConfigV0) => ConfigV1, (prevConfig: ConfigV1) => ConfigV2, @@ -454,7 +464,8 @@ const MIGRATIONS: [ (prevConfig: ConfigV20) => ConfigV21, (prevConfig: ConfigV21) => ConfigV22, (prevConfig: ConfigV22) => ConfigV23, - (prevConfig: ConfigV23) => ConfigV24 + (prevConfig: ConfigV23) => ConfigV24, + (prevConfig: ConfigV24) => ConfigV25 ] = [ toVersion1, toVersion2, @@ -480,6 +491,7 @@ const MIGRATIONS: [ toVersion22, toVersion23, toVersion24, + toVersion25, ] export const DEFAULTS: Config = migrate(DEFAULTS_V0) @@ -511,6 +523,7 @@ export function migrate( | ConfigV22 | ConfigV23 | ConfigV24 + | ConfigV25 ): Config { const prevVersion = prevConfig.version let result = prevConfig diff --git a/app/src/redux/config/schema-types.ts b/app/src/redux/config/schema-types.ts index 842fb8c3b80..cd68f5d00b1 100644 --- a/app/src/redux/config/schema-types.ts +++ b/app/src/redux/config/schema-types.ts @@ -31,6 +31,8 @@ export type QuickTransfersOnDeviceSortKey = | 'recentCreated' | 'oldCreated' +export type Language = 'en' | 'zh' + export interface OnDeviceDisplaySettings { sleepMs: number brightness: number @@ -274,4 +276,9 @@ export type ConfigV24 = Omit & { } } -export type Config = ConfigV24 +export type ConfigV25 = Omit & { + version: 25 + language: Language | null +} + +export type Config = ConfigV25 From 1a566da6045eed36f5abbe4d7aa67a5a26096a79 Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Tue, 1 Oct 2024 11:07:51 -0400 Subject: [PATCH 2/3] add LocalizationProvider to desktop app and initialize lng from config --- app/src/App/DesktopApp.tsx | 7 +++---- app/src/App/OnDeviceDisplayApp.tsx | 6 +++--- app/src/App/__tests__/OnDeviceDisplayApp.test.tsx | 12 ++++++------ app/src/LocalizationProvider.tsx | 12 ++++++++---- app/src/redux/config/selectors.ts | 6 ++++++ 5 files changed, 26 insertions(+), 17 deletions(-) diff --git a/app/src/App/DesktopApp.tsx b/app/src/App/DesktopApp.tsx index 3c9dc3ae253..27d7fd4f238 100644 --- a/app/src/App/DesktopApp.tsx +++ b/app/src/App/DesktopApp.tsx @@ -1,7 +1,6 @@ import { useState, Fragment } from 'react' import { Navigate, Route, Routes, useMatch } from 'react-router-dom' import { ErrorBoundary } from 'react-error-boundary' -import { I18nextProvider } from 'react-i18next' import { Box, @@ -12,7 +11,7 @@ import { import { ApiHostProvider } from '@opentrons/react-api-client' import NiceModal from '@ebay/nice-modal-react' -import { i18n } from '/app/i18n' +import { LocalizationProvider } from '/app/LocalizationProvider' import { Alerts } from '/app/organisms/Desktop/Alerts' import { Breadcrumbs } from '/app/organisms/Desktop/Breadcrumbs' import { ToasterOven } from '/app/organisms/ToasterOven' @@ -106,7 +105,7 @@ export const DesktopApp = (): JSX.Element => { return ( - + @@ -155,7 +154,7 @@ export const DesktopApp = (): JSX.Element => { - + ) } diff --git a/app/src/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx index 46fb91b21f4..42335754432 100644 --- a/app/src/App/OnDeviceDisplayApp.tsx +++ b/app/src/App/OnDeviceDisplayApp.tsx @@ -16,7 +16,7 @@ import { ApiHostProvider } from '@opentrons/react-api-client' import NiceModal from '@ebay/nice-modal-react' import { SleepScreen } from '/app/atoms/SleepScreen' -import { OnDeviceLocalizationProvider } from '../LocalizationProvider' +import { LocalizationProvider } from '../LocalizationProvider' import { ToasterOven } from '/app/organisms/ToasterOven' import { MaintenanceRunTakeover } from '/app/organisms/TakeoverModal' import { FirmwareUpdateTakeover } from '/app/organisms/FirmwareUpdateModal/FirmwareUpdateTakeover' @@ -180,7 +180,7 @@ export const OnDeviceDisplayApp = (): JSX.Element => { return ( - + {isIdle ? ( @@ -203,7 +203,7 @@ export const OnDeviceDisplayApp = (): JSX.Element => { - + ) diff --git a/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx b/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx index fae54eb2fed..662b2523436 100644 --- a/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx +++ b/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx @@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router-dom' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' -import { OnDeviceLocalizationProvider } from '../../LocalizationProvider' +import { LocalizationProvider } from '../../LocalizationProvider' import { ConnectViaEthernet } from '/app/pages/ODD/ConnectViaEthernet' import { ConnectViaUSB } from '/app/pages/ODD/ConnectViaUSB' import { ConnectViaWifi } from '/app/pages/ODD/ConnectViaWifi' @@ -32,7 +32,7 @@ import { ODDTopLevelRedirects } from '../ODDTopLevelRedirects' import type { UseQueryResult } from 'react-query' import type { RobotSettingsResponse } from '@opentrons/api-client' -import type { OnDeviceLocalizationProviderProps } from '../../LocalizationProvider' +import type { LocalizationProviderProps } from '../../LocalizationProvider' import type { OnDeviceDisplaySettings } from '/app/redux/config/schema-types' vi.mock('@opentrons/react-api-client', async () => { @@ -100,8 +100,8 @@ describe('OnDeviceDisplayApp', () => { } as any) // TODO(bh, 2024-03-27): implement testing of branded and anonymous i18n, but for now pass through vi.mocked( - OnDeviceLocalizationProvider - ).mockImplementation((props: OnDeviceLocalizationProviderProps) => ( + LocalizationProvider + ).mockImplementation((props: LocalizationProviderProps) => ( <>{props.children} )) }) @@ -163,14 +163,14 @@ describe('OnDeviceDisplayApp', () => { }) it('renders the localization provider and not the loading screen when app-shell is ready', () => { render('/') - expect(vi.mocked(OnDeviceLocalizationProvider)).toHaveBeenCalled() + expect(vi.mocked(LocalizationProvider)).toHaveBeenCalled() expect(screen.queryByLabelText('loading indicator')).toBeNull() }) it('renders the loading screen when app-shell is not ready', () => { vi.mocked(getIsShellReady).mockReturnValue(false) render('/') screen.getByLabelText('loading indicator') - expect(vi.mocked(OnDeviceLocalizationProvider)).not.toHaveBeenCalled() + expect(vi.mocked(LocalizationProvider)).not.toHaveBeenCalled() }) it('renders EmergencyStop component from /emergency-stop', () => { render('/emergency-stop') diff --git a/app/src/LocalizationProvider.tsx b/app/src/LocalizationProvider.tsx index 1cd676d2095..a4a87dc95a9 100644 --- a/app/src/LocalizationProvider.tsx +++ b/app/src/LocalizationProvider.tsx @@ -1,24 +1,27 @@ import type * as React from 'react' import { I18nextProvider } from 'react-i18next' +import { useSelector } from 'react-redux' import reduce from 'lodash/reduce' import { resources } from './assets/localization' +import { getLanguage } from './redux/config' import { useIsOEMMode } from './resources/robot-settings/hooks' import { i18n, i18nCb, i18nConfig } from './i18n' -export interface OnDeviceLocalizationProviderProps { +export interface LocalizationProviderProps { children?: React.ReactNode } export const BRANDED_RESOURCE = 'branded' export const ANONYMOUS_RESOURCE = 'anonymous' -// TODO(bh, 2024-03-26): anonymization limited to ODD for now, may change in future OEM phases -export function OnDeviceLocalizationProvider( - props: OnDeviceLocalizationProviderProps +export function LocalizationProvider( + props: LocalizationProviderProps ): JSX.Element | null { const isOEMMode = useIsOEMMode() + const language = useSelector(getLanguage) + // iterate through language resources, nested files, substitute anonymous file for branded file for OEM mode const anonResources = reduce( resources, @@ -44,6 +47,7 @@ export function OnDeviceLocalizationProvider( const anonI18n = i18n.createInstance( { ...i18nConfig, + lng: language ?? 'en', resources: anonResources, }, i18nCb diff --git a/app/src/redux/config/selectors.ts b/app/src/redux/config/selectors.ts index 53ba2c9d10f..744e5effee1 100644 --- a/app/src/redux/config/selectors.ts +++ b/app/src/redux/config/selectors.ts @@ -8,6 +8,7 @@ import type { ProtocolsOnDeviceSortKey, QuickTransfersOnDeviceSortKey, OnDeviceDisplaySettings, + Language, } from './types' import type { ProtocolSort } from '/app/redux/protocol-storage' @@ -155,3 +156,8 @@ export const getUserId: (state: State) => string = createSelector( getConfig, config => config?.userInfo.userId ?? '' ) + +export const getLanguage: (state: State) => Language | null = createSelector( + getConfig, + config => config?.language ?? 'en' +) From 7f3ea8166c9193096cd9264782ab81c9b3fbe6c1 Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Tue, 1 Oct 2024 11:09:56 -0400 Subject: [PATCH 3/3] update language config value from app language toggles --- .../pages/Desktop/AppSettings/AdvancedSettings.tsx | 13 ++++++++++--- .../RobotSettingsDashboard/RobotSettingsList.tsx | 8 +++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx b/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx index 59439a6a088..787b2eabe98 100644 --- a/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx +++ b/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx @@ -1,3 +1,6 @@ +import { useContext } from 'react' +import { I18nContext } from 'react-i18next' +import { useDispatch } from 'react-redux' import { css } from 'styled-components' import { @@ -10,7 +13,6 @@ import { } from '@opentrons/components' import { Divider } from '/app/atoms/structure' -import { i18n } from '/app/i18n' import { ClearUnavailableRobots, EnableDevTools, @@ -23,7 +25,9 @@ import { UpdatedChannel, AdditionalCustomLabwareSourceFolder, } from '/app/organisms/Desktop/AdvancedSettings' -import { useFeatureFlag } from '/app/redux/config' +import { updateConfigValue, useFeatureFlag } from '/app/redux/config' + +import type { Dispatch } from '/app/redux/types' export function AdvancedSettings(): JSX.Element { return ( @@ -57,6 +61,9 @@ export function AdvancedSettings(): JSX.Element { function LocalizationSetting(): JSX.Element | null { const enableLocalization = useFeatureFlag('enableLocalization') + const dispatch = useDispatch() + + const { i18n } = useContext(I18nContext) return enableLocalization ? ( <> @@ -70,7 +77,7 @@ function LocalizationSetting(): JSX.Element | null { `} value={i18n.language} onChange={(event: React.ChangeEvent) => { - void i18n.changeLanguage(event.currentTarget.value) + dispatch(updateConfigValue('language', event.currentTarget.value)) }} options={[ { name: 'EN', value: 'en' }, diff --git a/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx b/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx index 5983e957419..75473910a29 100644 --- a/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx +++ b/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx @@ -31,6 +31,7 @@ import { toggleDevInternalFlag, toggleDevtools, toggleHistoricOffsets, + updateConfigValue, useFeatureFlag, } from '/app/redux/config' import { InlineNotification } from '/app/atoms/InlineNotification' @@ -273,6 +274,7 @@ function FeatureFlags(): JSX.Element { function LanguageToggle(): JSX.Element | null { const enableLocalization = useFeatureFlag('enableLocalization') + const dispatch = useDispatch() const { i18n } = useContext(I18nContext) @@ -280,9 +282,9 @@ function LanguageToggle(): JSX.Element | null { { - void (i18n.language === 'en' - ? i18n.changeLanguage('zh') - : i18n.changeLanguage('en')) + i18n.language === 'en' + ? dispatch(updateConfigValue('language', 'zh')) + : dispatch(updateConfigValue('language', 'en')) }} rightElement={<>} />