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(api-client,app,react-api-client): implement ODD anonymous localization provider #14741

Merged
merged 11 commits into from
Apr 12, 2024
Merged
11 changes: 11 additions & 0 deletions api-client/src/robot/getRobotSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { GET, request } from '../request'

import type { ResponsePromise } from '../request'
import type { HostConfig } from '../types'
import type { RobotSettingsResponse } from './types'

export function getRobotSettings(
config: HostConfig
): ResponsePromise<RobotSettingsResponse> {
return request<RobotSettingsResponse>(GET, '/settings', null, config)
}
5 changes: 5 additions & 0 deletions api-client/src/robot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ export { getEstopStatus } from './getEstopStatus'
export { acknowledgeEstopDisengage } from './acknowledgeEstopDisengage'
export { getLights } from './getLights'
export { setLights } from './setLights'
export { getRobotSettings } from './getRobotSettings'

export type {
DoorStatus,
EstopPhysicalStatus,
EstopState,
EstopStatus,
Lights,
RobotSettings,
RobotSettingsField,
RobotSettingsResponse,
SetLightsData,
} from './types'
15 changes: 15 additions & 0 deletions api-client/src/robot/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,18 @@ export interface Lights {
export interface SetLightsData {
on: boolean
}

export interface RobotSettingsField {
id: string
title: string
description: string
value: boolean | null
restart_required?: boolean
}

export type RobotSettings = RobotSettingsField[]

export interface RobotSettingsResponse {
settings: RobotSettings
links?: { restart?: string }
}
82 changes: 43 additions & 39 deletions app/src/App/DesktopApp.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react'
import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'
import { ErrorBoundary } from 'react-error-boundary'
import { I18nextProvider } from 'react-i18next'

import {
Box,
Expand All @@ -11,6 +12,7 @@ import {
import { ApiHostProvider } from '@opentrons/react-api-client'
import NiceModal from '@ebay/nice-modal-react'

import { i18n } from '../i18n'
import { Alerts } from '../organisms/Alerts'
import { Breadcrumbs } from '../organisms/Breadcrumbs'
import { ToasterOven } from '../organisms/ToasterOven'
Expand Down Expand Up @@ -101,45 +103,47 @@ export const DesktopApp = (): JSX.Element => {

return (
<NiceModal.Provider>
<ErrorBoundary FallbackComponent={DesktopAppFallback}>
<Navbar routes={desktopRoutes} />
<ToasterOven>
<EmergencyStopContext.Provider
value={{
isEmergencyStopModalDismissed,
setIsEmergencyStopModalDismissed,
}}
>
<Box width="100%">
<Alerts>
<Switch>
{desktopRoutes.map(
({ Component, exact, path }: RouteProps) => {
return (
<Route key={path} exact={exact} path={path}>
<Breadcrumbs />
<Box
position={POSITION_RELATIVE}
width="100%"
height="100%"
backgroundColor={COLORS.grey10}
overflow={OVERFLOW_AUTO}
>
<ModalPortalRoot />
<Component />
</Box>
</Route>
)
}
)}
<Redirect exact from="/" to="/protocols" />
</Switch>
<RobotControlTakeover />
</Alerts>
</Box>
</EmergencyStopContext.Provider>
</ToasterOven>
</ErrorBoundary>
<I18nextProvider i18n={i18n}>
<ErrorBoundary FallbackComponent={DesktopAppFallback}>
<Navbar routes={desktopRoutes} />
<ToasterOven>
<EmergencyStopContext.Provider
value={{
isEmergencyStopModalDismissed,
setIsEmergencyStopModalDismissed,
}}
>
<Box width="100%">
<Alerts>
<Switch>
{desktopRoutes.map(
({ Component, exact, path }: RouteProps) => {
return (
<Route key={path} exact={exact} path={path}>
<Breadcrumbs />
<Box
position={POSITION_RELATIVE}
width="100%"
height="100%"
backgroundColor={COLORS.grey10}
overflow={OVERFLOW_AUTO}
>
<ModalPortalRoot />
<Component />
</Box>
</Route>
)
}
)}
<Redirect exact from="/" to="/protocols" />
</Switch>
<RobotControlTakeover />
</Alerts>
</Box>
</EmergencyStopContext.Provider>
</ToasterOven>
</ErrorBoundary>
</I18nextProvider>
</NiceModal.Provider>
)
}
Expand Down
106 changes: 59 additions & 47 deletions app/src/App/OnDeviceDisplayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ApiHostProvider } from '@opentrons/react-api-client'
import NiceModal from '@ebay/nice-modal-react'

import { SleepScreen } from '../atoms/SleepScreen'
import { OnDeviceLocalizationProvider } from '../LocalizationProvider'
import { ToasterOven } from '../organisms/ToasterOven'
import { MaintenanceRunTakeover } from '../organisms/TakeoverModal'
import { FirmwareUpdateTakeover } from '../organisms/FirmwareUpdateModal/FirmwareUpdateTakeover'
Expand Down Expand Up @@ -151,6 +152,53 @@ export const OnDeviceDisplayApp = (): JSX.Element => {
}
const dispatch = useDispatch<Dispatch>()
const isIdle = useIdle(sleepTime, options)

React.useEffect(() => {
if (isIdle) {
dispatch(updateBrightness(TURN_OFF_BACKLIGHT))
} else {
dispatch(
updateConfigValue(
'onDeviceDisplaySettings.brightness',
userSetBrightness
)
)
}
}, [dispatch, isIdle, userSetBrightness])

// TODO (sb:6/12/23) Create a notification manager to set up preference and order of takeover modals
return (
<ApiHostProvider hostname="127.0.0.1">
<OnDeviceLocalizationProvider>
<ErrorBoundary FallbackComponent={OnDeviceDisplayAppFallback}>
<Box width="100%" css="user-select: none;">
{isIdle ? (
<SleepScreen />
) : (
<>
<EstopTakeover />
<MaintenanceRunTakeover>
<FirmwareUpdateTakeover />
<NiceModal.Provider>
<ToasterOven>
<ProtocolReceiptToasts />
<OnDeviceDisplayAppRoutes />
</ToasterOven>
</NiceModal.Provider>
</MaintenanceRunTakeover>
</>
)}
</Box>
</ErrorBoundary>
<TopLevelRedirects />
</OnDeviceLocalizationProvider>
</ApiHostProvider>
)
}

// split to a separate function because scrollRef rerenders on every route change
// this avoids rerendering parent providers as well
export function OnDeviceDisplayAppRoutes(): JSX.Element {
const [currentNode, setCurrentNode] = React.useState<null | HTMLElement>(null)
const scrollRef = React.useCallback((node: HTMLElement | null) => {
setCurrentNode(node)
Expand All @@ -176,54 +224,18 @@ export const OnDeviceDisplayApp = (): JSX.Element => {
}
`

React.useEffect(() => {
if (isIdle) {
dispatch(updateBrightness(TURN_OFF_BACKLIGHT))
} else {
dispatch(
updateConfigValue(
'onDeviceDisplaySettings.brightness',
userSetBrightness
)
)
}
}, [dispatch, isIdle, userSetBrightness])

// TODO (sb:6/12/23) Create a notification manager to set up preference and order of takeover modals
return (
<ApiHostProvider hostname="127.0.0.1">
<ErrorBoundary FallbackComponent={OnDeviceDisplayAppFallback}>
<Box width="100%" css="user-select: none;">
{isIdle ? (
<SleepScreen />
) : (
<>
<EstopTakeover />
<MaintenanceRunTakeover>
<FirmwareUpdateTakeover />
<NiceModal.Provider>
<ToasterOven>
<ProtocolReceiptToasts />
<Switch>
{ON_DEVICE_DISPLAY_PATHS.map(path => (
<Route key={path} exact path={path}>
<Box css={TOUCH_SCREEN_STYLE} ref={scrollRef}>
<ModalPortalRoot />
{getPathComponent(path)}
</Box>
</Route>
))}
<Redirect exact from="/" to={'/loading'} />
</Switch>
</ToasterOven>
</NiceModal.Provider>
</MaintenanceRunTakeover>
</>
)}
</Box>
</ErrorBoundary>
<TopLevelRedirects />
</ApiHostProvider>
<Switch>
{ON_DEVICE_DISPLAY_PATHS.map(path => (
<Route key={path} exact path={path}>
<Box css={TOUCH_SCREEN_STYLE} ref={scrollRef}>
<ModalPortalRoot />
{getPathComponent(path)}
</Box>
</Route>
))}
<Redirect exact from="/" to={'/loading'} />
</Switch>
)
}

Expand Down
6 changes: 4 additions & 2 deletions app/src/App/OnDeviceDisplayAppFallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import type { ModalHeaderBaseProps } from '../molecules/Modal/types'
export function OnDeviceDisplayAppFallback({
error,
}: FallbackProps): JSX.Element {
const { t } = useTranslation('app_settings')
const { t } = useTranslation(['app_settings', 'branded'])
const trackEvent = useTrackEvent()
const dispatch = useDispatch<Dispatch>()
const localRobot = useSelector(getLocalRobot)
Expand Down Expand Up @@ -59,7 +59,9 @@ export function OnDeviceDisplayAppFallback({
alignItems={ALIGN_CENTER}
justifyContent={JUSTIFY_CENTER}
>
<StyledText as="p">{t('error_boundary_description')}</StyledText>
<StyledText as="p">
{t('branded:error_boundary_description')}
</StyledText>
<MediumButton
width="100%"
buttonType="alert"
Expand Down
9 changes: 9 additions & 0 deletions app/src/App/__tests__/OnDeviceDisplayApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MemoryRouter } from 'react-router-dom'

import { renderWithProviders } from '../../__testing-utils__'
import { i18n } from '../../i18n'
import { OnDeviceLocalizationProvider } from '../../LocalizationProvider'
import { ConnectViaEthernet } from '../../pages/ConnectViaEthernet'
import { ConnectViaUSB } from '../../pages/ConnectViaUSB'
import { ConnectViaWifi } from '../../pages/ConnectViaWifi'
Expand All @@ -29,8 +30,10 @@ import { mockConnectedRobot } from '../../redux/discovery/__fixtures__'
import { useCurrentRunRoute, useProtocolReceiptToast } from '../hooks'
import { useNotifyCurrentMaintenanceRun } from '../../resources/maintenance_runs'

import type { OnDeviceLocalizationProviderProps } from '../../LocalizationProvider'
import type { OnDeviceDisplaySettings } from '../../redux/config/schema-types'

vi.mock('../../LocalizationProvider')
vi.mock('../../pages/Welcome')
vi.mock('../../pages/NetworkSetupMenu')
vi.mock('../../pages/ConnectViaEthernet')
Expand Down Expand Up @@ -83,6 +86,12 @@ 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) => (
<>{props.children}</>
))
})
afterEach(() => {
vi.resetAllMocks()
Expand Down
63 changes: 63 additions & 0 deletions app/src/LocalizationProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as React from 'react'
import { I18nextProvider } from 'react-i18next'
import reduce from 'lodash/reduce'

import { useRobotSettingsQuery } from '@opentrons/react-api-client'

import { resources } from './assets/localization'
import { i18n, i18nCb, i18nConfig } from './i18n'

import type { RobotSettingsField } from '@opentrons/api-client'

export interface OnDeviceLocalizationProviderProps {
children?: React.ReactNode
}

const BRANDED_RESOURCE = 'branded'
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
): JSX.Element | null {
const { settings } = useRobotSettingsQuery().data ?? {}
const oemModeSetting = (settings ?? []).find(
(setting: RobotSettingsField) => setting?.id === 'enableOEMMode'
)
const isOEMMode = oemModeSetting?.value ?? false

// iterate through language resources, nested files, substitute anonymous file for branded file for OEM mode
const anonResources = reduce(
resources,
(acc, resource, language) => {
const anonFiles = reduce(
resource,
(acc, file, fileName) => {
if (fileName === BRANDED_RESOURCE && isOEMMode) {
return acc
} else if (fileName === ANONYMOUS_RESOURCE) {
return isOEMMode ? { ...acc, [BRANDED_RESOURCE]: file } : acc
} else {
return { ...acc, [fileName]: file }
}
},
{}
)
return { ...acc, [language]: anonFiles }
},
{}
)

const anonI18n = i18n.createInstance(
{
...i18nConfig,
resources: anonResources,
},
i18nCb
)

// block render until settings are fetched
return settings != null ? (
<I18nextProvider i18n={anonI18n}>{props.children}</I18nextProvider>
) : null
}
Loading
Loading