From a3dd67a36c9e73179b619337b5567984b96f3ecf Mon Sep 17 00:00:00 2001 From: koji Date: Wed, 6 Sep 2023 09:53:53 -0400 Subject: [PATCH 01/13] fix(app): fix inputField spacing issue on Touchscreen app (#13465) *fix(app): fix inputField spacing issue on Touchscreen app --- app/src/atoms/InputField/index.tsx | 15 ++++++----- app/src/pages/OnDeviceDisplay/NameRobot.tsx | 29 ++++++++++----------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/app/src/atoms/InputField/index.tsx b/app/src/atoms/InputField/index.tsx index 80868fecd5d..58eb8945db3 100644 --- a/app/src/atoms/InputField/index.tsx +++ b/app/src/atoms/InputField/index.tsx @@ -93,10 +93,6 @@ function Input(props: InputFieldProps): JSX.Element { ${error ? COLORS.errorEnabled : COLORS.medGreyEnabled}; font-size: ${TYPOGRAPHY.fontSizeP}; - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - padding: 0; - } - &:active { border: 1px ${BORDERS.styleSolid} ${COLORS.darkGreyEnabled}; } @@ -130,6 +126,13 @@ function Input(props: InputFieldProps): JSX.Element { } ` + const FORM_BOTTOM_SPACE_STYLE = css` + padding-bottom: ${SPACING.spacing4}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + padding-bottom: 0; + } + ` + const ERROR_TEXT_STYLE = css` color: ${COLORS.errorEnabled}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { @@ -165,9 +168,9 @@ function Input(props: InputFieldProps): JSX.Element { paddingTop={SPACING.spacing4} flexDirection={DIRECTION_COLUMN} > - {props.caption} + {props.caption} {props.secondaryCaption != null ? ( - {props.secondaryCaption} + {props.secondaryCaption} ) : null} {props.error} diff --git a/app/src/pages/OnDeviceDisplay/NameRobot.tsx b/app/src/pages/OnDeviceDisplay/NameRobot.tsx index b572a40031f..fea706a90d0 100644 --- a/app/src/pages/OnDeviceDisplay/NameRobot.tsx +++ b/app/src/pages/OnDeviceDisplay/NameRobot.tsx @@ -207,29 +207,28 @@ export function NameRobot(): JSX.Element { - {isUnboxingFlowOngoing ? ( - - {t('name_your_robot_description')} - - ) : null} + {isUnboxingFlowOngoing ? ( + + {t('name_your_robot_description')} + + ) : null} Date: Wed, 6 Sep 2023 10:23:38 -0400 Subject: [PATCH 02/13] fix(app): update send protocol to Flex slideout text (#13464) * fix(app): update slideout text for sending a protocol to a Flex - change slideout title and button text * remove unused translation * removed duplicate "send" key in protocol_list translator * pull out Opentrons Flex in translation as constant * separate translation for overflow sending protocol to flex * fix test --- app/src/assets/localization/en/protocol_list.json | 3 ++- .../ProtocolsLanding/ProtocolOverflowMenu.tsx | 5 ++++- .../__tests__/SendProtocolToOT3Slideout.test.tsx | 8 ++++---- app/src/organisms/SendProtocolToOT3Slideout/index.tsx | 10 ++++++---- shared-data/js/constants.ts | 3 +++ 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/app/src/assets/localization/en/protocol_list.json b/app/src/assets/localization/en/protocol_list.json index 667e3007b17..1b54fc7885c 100644 --- a/app/src/assets/localization/en/protocol_list.json +++ b/app/src/assets/localization/en/protocol_list.json @@ -10,7 +10,8 @@ "reanalyze_or_view_error": "Reanalyze protocol or view error details", "right_mount": "right mount", "robot": "robot", - "send_to_ot3": "Send to Opentrons Flex", + "send_to_ot3_overflow": "Send to {{robot_display_name}}", + "send_to_ot3": "Send protocol to {{robot_display_name}}", "should_delete_this_protocol": "Delete this protocol?", "show_in_folder": "Show in folder", "start_setup": "Start setup", diff --git a/app/src/organisms/ProtocolsLanding/ProtocolOverflowMenu.tsx b/app/src/organisms/ProtocolsLanding/ProtocolOverflowMenu.tsx index 2aca16f3375..f98b5ef0b28 100644 --- a/app/src/organisms/ProtocolsLanding/ProtocolOverflowMenu.tsx +++ b/app/src/organisms/ProtocolsLanding/ProtocolOverflowMenu.tsx @@ -11,6 +11,7 @@ import { ALIGN_FLEX_END, useConditionalConfirm, } from '@opentrons/components' +import { FLEX_DISPLAY_NAME } from '@opentrons/shared-data' import { Portal } from '../../App/portal' import { OverflowBtn } from '../../atoms/MenuList/OverflowBtn' @@ -145,7 +146,9 @@ export function ProtocolOverflowMenu( onClick={handleClickSendToOT3} data-testid="ProtocolOverflowMenu_sendToOT3" > - {t('send_to_ot3')} + {t('protocol_list:send_to_ot3_overflow', { + robot_display_name: FLEX_DISPLAY_NAME, + })} ) : null} { onCloseClick: jest.fn(), isExpanded: true, }) - getByText('Choose Robot to Run fakeSrcFileName') - getByRole('button', { name: 'Proceed to setup' }) + getByText('Send protocol to Opentrons Flex') + getByRole('button', { name: 'Send' }) }) it('renders an available robot option for every connectable OT-3, and link for other robots', () => { @@ -246,7 +246,7 @@ describe('SendProtocolToOT3Slideout', () => { onCloseClick: jest.fn(), isExpanded: true, }) - const proceedButton = getByRole('button', { name: 'Proceed to setup' }) + const proceedButton = getByRole('button', { name: 'Send' }) expect(proceedButton).not.toBeDisabled() const otherRobot = getByText('otherRobot') otherRobot.click() // unselect default robot @@ -273,7 +273,7 @@ describe('SendProtocolToOT3Slideout', () => { onCloseClick: jest.fn(), isExpanded: true, }) - const proceedButton = getByRole('button', { name: 'Proceed to setup' }) + const proceedButton = getByRole('button', { name: 'Send' }) expect(proceedButton).toBeDisabled() expect( getByText( diff --git a/app/src/organisms/SendProtocolToOT3Slideout/index.tsx b/app/src/organisms/SendProtocolToOT3Slideout/index.tsx index a3fda2f0040..dbea4776eae 100644 --- a/app/src/organisms/SendProtocolToOT3Slideout/index.tsx +++ b/app/src/organisms/SendProtocolToOT3Slideout/index.tsx @@ -5,6 +5,8 @@ import { useSelector } from 'react-redux' import { useCreateProtocolMutation } from '@opentrons/react-api-client' +import { FLEX_DISPLAY_NAME } from '@opentrons/shared-data' + import { PrimaryButton, IconProps, StyleProps } from '@opentrons/components' import { ERROR_TOAST, INFO_TOAST, SUCCESS_TOAST } from '../../atoms/Toast' import { ChooseRobotSlideout } from '../../organisms/ChooseRobotSlideout' @@ -38,7 +40,7 @@ export function SendProtocolToOT3Slideout( srcFiles, mostRecentAnalysis, } = storedProtocolData - const { t } = useTranslation(['protocol_details', 'shared']) + const { t } = useTranslation(['protocol_details', 'protocol_list']) const [selectedRobot, setSelectedRobot] = React.useState(null) @@ -141,8 +143,8 @@ export function SendProtocolToOT3Slideout( - {t('shared:proceed_to_setup')} + {t('protocol_details:send')} } selectedRobot={selectedRobot} diff --git a/shared-data/js/constants.ts b/shared-data/js/constants.ts index 5f97caa0493..b4e556d6c8f 100644 --- a/shared-data/js/constants.ts +++ b/shared-data/js/constants.ts @@ -48,6 +48,9 @@ export const GRIPPER_V1_1: 'gripperV1.1' = 'gripperV1.1' export const GRIPPER_V1_2: 'gripperV1.2' = 'gripperV1.2' export const GRIPPER_MODELS = [GRIPPER_V1, GRIPPER_V1_1, GRIPPER_V1_2] +// robot display name +export const FLEX_DISPLAY_NAME: 'Opentrons Flex' = 'Opentrons Flex' + // pipette display categories export const FLEX: 'FLEX' = 'FLEX' export const GEN2: 'GEN2' = 'GEN2' From 238d13f0c94258de740d384cafaf6b0b3048fdce Mon Sep 17 00:00:00 2001 From: Jamey H Date: Wed, 6 Sep 2023 11:00:22 -0400 Subject: [PATCH 03/13] feat(app, app-shell): desktop: software update flow rework (#13412) * feat(app): desktop: software update flow rework Partially closes RAUT-501 Rework the desktop software update flow. Includes a new download modal, a toast to alert when a software update is available & successfully installed. Adds new download percentage shell-to-redux pipe. Reworks logic for toggling software update alerts. --- .../src/config/__fixtures__/index.ts | 10 + .../src/config/__tests__/migrate.test.ts | 39 +- app-shell-odd/src/config/migrate.ts | 18 +- app-shell-odd/src/discovery.ts | 4 +- app-shell/src/__tests__/update.test.ts | 29 +- app-shell/src/config/__fixtures__/index.ts | 10 + .../src/config/__tests__/migrate.test.ts | 88 +++-- app-shell/src/config/migrate.ts | 19 +- app-shell/src/discovery.ts | 4 +- app-shell/src/log.ts | 4 +- app-shell/src/update.ts | 26 ++ .../assets/localization/en/app_settings.json | 31 +- app/src/molecules/LegacyModal/index.tsx | 3 + .../Alerts/__tests__/Alerts.test.tsx | 45 ++- app/src/organisms/Alerts/index.tsx | 56 ++- .../__tests__/VersionInfoModal.test.tsx | 2 +- .../__tests__/UpdateAppModal.test.tsx | 332 +++--------------- app/src/organisms/UpdateAppModal/index.tsx | 318 +++++++---------- app/src/pages/AppSettings/GeneralSettings.tsx | 2 +- .../__test__/AdvancedSettings.test.tsx | 2 +- .../__tests__/RobotSettingsDashboard.test.tsx | 4 +- app/src/redux/analytics/types.ts | 4 +- .../redux/config/__tests__/selectors.test.ts | 14 + app/src/redux/config/schema-types.ts | 50 +-- app/src/redux/config/selectors.ts | 4 + app/src/redux/shell/__tests__/update.test.ts | 5 +- app/src/redux/shell/reducer.ts | 9 +- app/src/redux/shell/types.ts | 2 + 28 files changed, 553 insertions(+), 581 deletions(-) diff --git a/app-shell-odd/src/config/__fixtures__/index.ts b/app-shell-odd/src/config/__fixtures__/index.ts index 485d9494a6f..b6f6d211253 100644 --- a/app-shell-odd/src/config/__fixtures__/index.ts +++ b/app-shell-odd/src/config/__fixtures__/index.ts @@ -6,6 +6,7 @@ import type { ConfigV16, ConfigV17, ConfigV18, + ConfigV19, } from '@opentrons/app/src/redux/config/types' export const MOCK_CONFIG_V12: ConfigV12 = { @@ -108,3 +109,12 @@ export const MOCK_CONFIG_V18: ConfigV18 = { })(), version: 18, } + +export const MOCK_CONFIG_V19: ConfigV19 = { + ...MOCK_CONFIG_V18, + version: 19, + update: { + ...MOCK_CONFIG_V18.update, + hasJustUpdated: false, + }, +} diff --git a/app-shell-odd/src/config/__tests__/migrate.test.ts b/app-shell-odd/src/config/__tests__/migrate.test.ts index c9605aa4eae..bb197986621 100644 --- a/app-shell-odd/src/config/__tests__/migrate.test.ts +++ b/app-shell-odd/src/config/__tests__/migrate.test.ts @@ -7,63 +7,74 @@ import { MOCK_CONFIG_V16, MOCK_CONFIG_V17, MOCK_CONFIG_V18, + MOCK_CONFIG_V19, } from '../__fixtures__' import { migrate } from '../migrate' +const NEWEST_VERSION = 19 + describe('config migration', () => { it('should migrate version 12 to latest', () => { const v12Config = MOCK_CONFIG_V12 const result = migrate(v12Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 13 to latest', () => { const v13Config = MOCK_CONFIG_V13 const result = migrate(v13Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 14 to latest', () => { const v14Config = MOCK_CONFIG_V14 const result = migrate(v14Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 15 to latest', () => { const v15Config = MOCK_CONFIG_V15 const result = migrate(v15Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 16 to latest', () => { const v16Config = MOCK_CONFIG_V16 const result = migrate(v16Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 17 to latest', () => { const v17Config = MOCK_CONFIG_V17 const result = migrate(v17Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should keep version 18', () => { const v18Config = MOCK_CONFIG_V18 const result = migrate(v18Config) - expect(result.version).toBe(18) - expect(result).toEqual(v18Config) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) + }) + + it('should keep version 19', () => { + const v19Config = MOCK_CONFIG_V19 + const result = migrate(v19Config) + + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(v19Config) }) }) diff --git a/app-shell-odd/src/config/migrate.ts b/app-shell-odd/src/config/migrate.ts index c89d71f3371..48d45f5cc3c 100644 --- a/app-shell-odd/src/config/migrate.ts +++ b/app-shell-odd/src/config/migrate.ts @@ -12,6 +12,7 @@ import type { ConfigV16, ConfigV17, ConfigV18, + ConfigV19, } from '@opentrons/app/src/redux/config/types' // format // base config v12 defaults @@ -143,13 +144,26 @@ const toVersion18 = (prevConfig: ConfigV17): ConfigV18 => { } } +const toVersion19 = (prevConfig: ConfigV18): ConfigV19 => { + const nextConfig = { + ...prevConfig, + version: 19 as const, + update: { + ...prevConfig.update, + hasJustUpdated: false, + }, + } + return nextConfig +} + const MIGRATIONS: [ (prevConfig: ConfigV12) => ConfigV13, (prevConfig: ConfigV13) => ConfigV14, (prevConfig: ConfigV14) => ConfigV15, (prevConfig: ConfigV15) => ConfigV16, (prevConfig: ConfigV16) => ConfigV17, - (prevConfig: ConfigV17) => ConfigV18 + (prevConfig: ConfigV17) => ConfigV18, + (prevConfig: ConfigV18) => ConfigV19 ] = [ toVersion13, toVersion14, @@ -157,6 +171,7 @@ const MIGRATIONS: [ toVersion16, toVersion17, toVersion18, + toVersion19, ] export const DEFAULTS: Config = migrate(DEFAULTS_V12) @@ -170,6 +185,7 @@ export function migrate( | ConfigV16 | ConfigV17 | ConfigV18 + | ConfigV19 ): Config { let result = prevConfig // loop through the migrations, skipping any migrations that are unnecessary diff --git a/app-shell-odd/src/discovery.ts b/app-shell-odd/src/discovery.ts index 8e44554a3d6..bbe84cc14a9 100644 --- a/app-shell-odd/src/discovery.ts +++ b/app-shell-odd/src/discovery.ts @@ -28,7 +28,7 @@ import type { } from '@opentrons/discovery-client' import type { Action, Dispatch } from './types' -import type { Config } from './config' +import type { ConfigV1 } from '@opentrons/app/src/redux/config/schema-types' const log = createLogger('discovery') @@ -42,7 +42,7 @@ interface DiscoveryStore { services?: LegacyService[] } -let config: Config['discovery'] +let config: ConfigV1['discovery'] let store: Store let client: DiscoveryClient diff --git a/app-shell/src/__tests__/update.test.ts b/app-shell/src/__tests__/update.test.ts index 250aec8ae42..123a5e70c24 100644 --- a/app-shell/src/__tests__/update.test.ts +++ b/app-shell/src/__tests__/update.test.ts @@ -1,5 +1,6 @@ // app-shell self-update tests import * as ElectronUpdater from 'electron-updater' +import { UPDATE_VALUE } from '@opentrons/app/src/redux/config' import { registerUpdate } from '../update' import * as Cfg from '../config' @@ -67,20 +68,44 @@ describe('update', () => { }) it('handles shell:DOWNLOAD_UPDATE', () => { - handleAction({ type: 'shell:DOWNLOAD_UPDATE', meta: { shell: true } }) + handleAction({ + type: 'shell:DOWNLOAD_UPDATE', + meta: { shell: true }, + }) expect(autoUpdater.downloadUpdate).toHaveBeenCalledTimes(1) + const progress = { + percent: 20, + } + + autoUpdater.emit('download-progress', progress) + + expect(dispatch).toHaveBeenCalledWith({ + type: 'shell:DOWNLOAD_PERCENTAGE', + payload: { + percent: 20, + }, + }) + autoUpdater.emit('update-downloaded', { version: '1.0.0' }) expect(dispatch).toHaveBeenCalledWith({ type: 'shell:DOWNLOAD_UPDATE_RESULT', payload: {}, }) + expect(dispatch).toHaveBeenCalledWith({ + type: UPDATE_VALUE, + payload: { path: 'update.hasJustUpdated', value: true }, + meta: { shell: true }, + }) }) it('handles shell:DOWNLOAD_UPDATE with error', () => { - handleAction({ type: 'shell:DOWNLOAD_UPDATE', meta: { shell: true } }) + handleAction({ + type: 'shell:DOWNLOAD_UPDATE', + meta: { shell: true }, + }) autoUpdater.emit('error', new Error('AH')) expect(dispatch).toHaveBeenCalledWith({ diff --git a/app-shell/src/config/__fixtures__/index.ts b/app-shell/src/config/__fixtures__/index.ts index c88bbcc14c4..5225d825c74 100644 --- a/app-shell/src/config/__fixtures__/index.ts +++ b/app-shell/src/config/__fixtures__/index.ts @@ -18,6 +18,7 @@ import type { ConfigV16, ConfigV17, ConfigV18, + ConfigV19, } from '@opentrons/app/src/redux/config/types' export const MOCK_CONFIG_V0: ConfigV0 = { @@ -240,3 +241,12 @@ export const MOCK_CONFIG_V18: ConfigV18 = { })(), version: 18, } + +export const MOCK_CONFIG_V19: ConfigV19 = { + ...MOCK_CONFIG_V18, + version: 19, + update: { + ...MOCK_CONFIG_V18.update, + hasJustUpdated: false, + }, +} diff --git a/app-shell/src/config/__tests__/migrate.test.ts b/app-shell/src/config/__tests__/migrate.test.ts index 51625eec489..0287fbb9e20 100644 --- a/app-shell/src/config/__tests__/migrate.test.ts +++ b/app-shell/src/config/__tests__/migrate.test.ts @@ -19,158 +19,168 @@ import { MOCK_CONFIG_V16, MOCK_CONFIG_V17, MOCK_CONFIG_V18, + MOCK_CONFIG_V19, } from '../__fixtures__' import { migrate } from '../migrate' +const NEWEST_VERSION = 19 + describe('config migration', () => { it('should migrate version 0 to latest', () => { const v0Config = MOCK_CONFIG_V0 const result = migrate(v0Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 1 to latest', () => { const v1Config = MOCK_CONFIG_V1 const result = migrate(v1Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 2 to latest', () => { const v2Config = MOCK_CONFIG_V2 const result = migrate(v2Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 3 to latest', () => { const v3Config = MOCK_CONFIG_V3 const result = migrate(v3Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 4 to latest', () => { const v4Config = MOCK_CONFIG_V4 const result = migrate(v4Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 5 to latest', () => { const v5Config = MOCK_CONFIG_V5 const result = migrate(v5Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 6 to latest', () => { const v6Config = MOCK_CONFIG_V6 const result = migrate(v6Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 7 to latest', () => { const v7Config = MOCK_CONFIG_V7 const result = migrate(v7Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 8 to latest', () => { const v8Config = MOCK_CONFIG_V8 const result = migrate(v8Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 9 to latest', () => { const v9Config = MOCK_CONFIG_V9 const result = migrate(v9Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 10 to latest', () => { const v10Config = MOCK_CONFIG_V10 const result = migrate(v10Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 11 to latest', () => { const v11Config = MOCK_CONFIG_V11 const result = migrate(v11Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 12 to latest', () => { const v12Config = MOCK_CONFIG_V12 const result = migrate(v12Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 13 to latest', () => { const v13Config = MOCK_CONFIG_V13 const result = migrate(v13Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 14 to latest', () => { const v14Config = MOCK_CONFIG_V14 const result = migrate(v14Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 15 to latest', () => { const v15Config = MOCK_CONFIG_V15 const result = migrate(v15Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 16 to latest', () => { const v16Config = MOCK_CONFIG_V16 const result = migrate(v16Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 17 to latest', () => { const v17Config = MOCK_CONFIG_V17 const result = migrate(v17Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) - it('should keep version 18', () => { + it('should migrate version 18 to latest', () => { const v18Config = MOCK_CONFIG_V18 const result = migrate(v18Config) - expect(result.version).toBe(18) - expect(result).toEqual(v18Config) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) + }) + it('should keep version 19', () => { + const v19Config = MOCK_CONFIG_V19 + const result = migrate(v19Config) + + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(v19Config) }) }) diff --git a/app-shell/src/config/migrate.ts b/app-shell/src/config/migrate.ts index b6e1252234c..0a4616e7b14 100644 --- a/app-shell/src/config/migrate.ts +++ b/app-shell/src/config/migrate.ts @@ -24,6 +24,7 @@ import type { ConfigV16, ConfigV17, ConfigV18, + ConfigV19, } from '@opentrons/app/src/redux/config/types' // format // base config v0 defaults @@ -340,6 +341,19 @@ const toVersion18 = (prevConfig: ConfigV17): ConfigV18 => { return { ...prevConfigFields, version: 18 as const } } +// config version 19 migration and defaults +const toVersion19 = (prevConfig: ConfigV18): ConfigV19 => { + const nextConfig = { + ...prevConfig, + version: 19 as const, + update: { + ...prevConfig.update, + hasJustUpdated: false, + }, + } + return nextConfig +} + const MIGRATIONS: [ (prevConfig: ConfigV0) => ConfigV1, (prevConfig: ConfigV1) => ConfigV2, @@ -358,7 +372,8 @@ const MIGRATIONS: [ (prevConfig: ConfigV14) => ConfigV15, (prevConfig: ConfigV15) => ConfigV16, (prevConfig: ConfigV16) => ConfigV17, - (prevConfig: ConfigV17) => ConfigV18 + (prevConfig: ConfigV17) => ConfigV18, + (prevConfig: ConfigV18) => ConfigV19 ] = [ toVersion1, toVersion2, @@ -378,6 +393,7 @@ const MIGRATIONS: [ toVersion16, toVersion17, toVersion18, + toVersion19, ] export const DEFAULTS: Config = migrate(DEFAULTS_V0) @@ -403,6 +419,7 @@ export function migrate( | ConfigV16 | ConfigV17 | ConfigV18 + | ConfigV19 ): Config { const prevVersion = prevConfig.version let result = prevConfig diff --git a/app-shell/src/discovery.ts b/app-shell/src/discovery.ts index e7e23eb3c84..ed562fdd069 100644 --- a/app-shell/src/discovery.ts +++ b/app-shell/src/discovery.ts @@ -33,7 +33,7 @@ import type { } from '@opentrons/discovery-client' import type { Action, Dispatch } from './types' -import type { Config } from './config' +import type { ConfigV1 } from '@opentrons/app/src/redux/config/schema-types' const log = createLogger('discovery') @@ -47,7 +47,7 @@ interface DiscoveryStore { services?: LegacyService[] } -let config: Config['discovery'] +let config: ConfigV1['discovery'] let store: Store let client: DiscoveryClient diff --git a/app-shell/src/log.ts b/app-shell/src/log.ts index e67bf625b37..f18e0c0ea52 100644 --- a/app-shell/src/log.ts +++ b/app-shell/src/log.ts @@ -9,7 +9,7 @@ import winston from 'winston' import { getConfig } from './config' import type Transport from 'winston-transport' -import type { Config } from './config' +import type { ConfigV0 } from '@opentrons/app/src/redux/config/schema-types' export const LOG_DIR = path.join(app.getPath('userData'), 'logs') const ERROR_LOG = path.join(LOG_DIR, 'error.log') @@ -25,7 +25,7 @@ const FILE_OPTIONS = { tailable: true, } -let config: Config['log'] +let config: ConfigV0['log'] let transports: Transport[] let log: winston.Logger diff --git a/app-shell/src/update.ts b/app-shell/src/update.ts index e30e23f3734..d28e420abfa 100644 --- a/app-shell/src/update.ts +++ b/app-shell/src/update.ts @@ -4,6 +4,7 @@ import { autoUpdater as updater } from 'electron-updater' import { UI_INITIALIZED } from '@opentrons/app/src/redux/shell/actions' import { createLogger } from './log' import { getConfig } from './config' +import { UPDATE_VALUE } from '@opentrons/app/src/redux/config' import type { UpdateInfo } from '@opentrons/app/src/redux/shell/types' import type { Action, Dispatch, PlainError } from './types' @@ -63,20 +64,45 @@ function checkUpdate(dispatch: Dispatch): void { } } +interface ProgressInfo { + total: number + delta: number + transferred: number + percent: number + bytesPerSecond: number +} +interface DownloadingPayload { + progress: ProgressInfo + bytesPerSecond: number + percent: number + total: number + transferred: number +} + function downloadUpdate(dispatch: Dispatch): void { + const onDownloading = (payload: DownloadingPayload): void => + dispatch({ type: 'shell:DOWNLOAD_PERCENTAGE', payload }) const onDownloaded = (): void => done({}) const onError = (error: Error): void => { done({ error: PlainObjectError(error) }) } + updater.on('download-progress', onDownloading) updater.once('update-downloaded', onDownloaded) updater.once('error', onError) // eslint-disable-next-line @typescript-eslint/no-floating-promises updater.downloadUpdate() function done(payload: { error?: PlainError }): void { + updater.removeListener('download-progress', onDownloading) updater.removeListener('update-downloaded', onDownloaded) updater.removeListener('error', onError) + if (payload.error == null) + dispatch({ + type: UPDATE_VALUE, + payload: { path: 'update.hasJustUpdated', value: true }, + meta: { shell: true }, + }) dispatch({ type: 'shell:DOWNLOAD_UPDATE_RESULT', payload }) } } diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index 0a60daf7bd6..c779b2f4fa9 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -10,8 +10,9 @@ "additional_folder_location": "Additional Source Folder", "additional_labware_folder_title": "Additional Custom Labware Source Folder", "advanced": "Advanced", - "allow_sending_all_protocols_to_ot3_description": "Enable the \"Send to Opentrons Flex\" menu item for each imported protocol, even if protocol analysis fails or does not recognize it as designed for the Opentrons Flex.", "allow_sending_all_protocols_to_ot3": "Allow Sending All Protocols to Opentrons Flex", + "allow_sending_all_protocols_to_ot3_description": "Enable the \"Send to Opentrons Flex\" menu item for each imported protocol, even if protocol analysis fails or does not recognize it as designed for the Opentrons Flex.", + "analytics_description": "Help Opentrons improve its products and services by automatically sending anonymous diagnostics and usage data.", "app_changes": "App Changes in ", "app_settings": "App Settings", "bug_fixes": "Bug Fixes", @@ -25,13 +26,13 @@ "clear_unavailable_robots": "Clear unavailable robots?", "clearing_cannot_be_undone": "Clearing the list of unavailable robots on the Devices page cannot be undone.", "close": "Close", + "connect_ip": "Connect to a Robot via IP Address", "connect_ip_button": "Done", "connect_ip_link": "Learn more about connecting a robot manually", - "connect_ip": "Connect to a Robot via IP Address", "discovery_timeout": "Discovery timed out.", - "download_update": "Download app update", + "download_update": "Downloading update...", + "enable_dev_tools": "Enable Developer Tools", "enable_dev_tools_description": "Enabling this setting opens Developer Tools on app launch, enables additional logging and gives access to feature flags.", - "enable_dev_tools": "Developer Tools", "error_boundary_description": "You need to restart your robot. Then download the robot logs from the Opentrons App and send them to support@opentrons.com for assistance.", "error_boundary_title": "An unknown error has occurred", "feature_flags": "Feature Flags", @@ -39,6 +40,7 @@ "heater_shaker_attach_description": "Display a reminder to attach the Heater-Shaker properly before running a test shake or using it in a protocol.", "heater_shaker_attach_visible": "Confirm Heater-Shaker Module Attachment", "how_to_restore": "How to Restore a Previous Software Version", + "installing_update": "Installing update...", "ip_available": "Available", "ip_description_first": "Enter an IP address or hostname to connect to a robot.", "ip_description_second": "Opentrons recommends working with your network administrator to assign a static IP address to the robot.", @@ -49,12 +51,16 @@ "no_specified_folder": "No path specified", "no_unavail_robots_to_clear": "No unavailable robots to clear", "not_found": "Not Found", + "opentrons_app_successfully_updated": "The Opentrons App was successfully updated.", + "opentrons_app_update": "Opentrons app update", + "opentrons_app_update_available": "Opentrons App Update Available", + "opentrons_app_update_available_variation": "An Opentrons App update is available.", "opentrons_app_will_use_interpreter": "If specified, the Opentrons App will use the Python interpreter at this path instead of the default bundled Python interpreter.", "ot2_advanced_settings": "OT-2 Advanced Settings", - "override_path_to_python": "Override Path to Python", "override_path": "override path", - "prevent_robot_caching_description": "The app will immediately clear unavailable robots and will not remember unavailable robots while this is enabled. On networks with many robots, preventing caching may improve network performance at the expense of slower and less reliable robot discovery on app launch.", + "override_path_to_python": "Override Path to Python", "prevent_robot_caching": "Prevent Robot Caching", + "prevent_robot_caching_description": "The app will immediately clear unavailable robots and will not remember unavailable robots while this is enabled. On networks with many robots, preventing caching may improve network performance at the expense of slower and less reliable robot discovery on app launch.", "previous_releases": "View previous Opentrons releases", "privacy": "Privacy", "prompt": "Always show the prompt to choose calibration block or trash bin", @@ -62,17 +68,18 @@ "remind_later": "Remind me later", "reset_to_default": "Reset to default", "restart_touchscreen": "Restart touchscreen", + "restarting_app": "Download complete, restarting the app...", "restore_description": "Opentrons does not recommend reverting to previous software versions, but you can access previous releases below. For best results, uninstall the existing app and remove its configuration files before installing the previous version.", "restore_previous": "See how to restore a previous software version", "searching": "Searching for 30s", "setup_connection": "Set up connection", - "share_robot_analytics": "Share Robot Analytics with Opentrons", "share_app_analytics": "Share App Analytics with Opentrons", - "share_app_analytics_short": "Share App Analytics", "share_app_analytics_description": "Help Opentrons improve its products and services by automatically sending anonymous diagnostics and usage data.", "share_app_analytics_description_short": "Share anonymous app usage data with Opentrons.", - "show_labware_offset_snippets_description": "Only for users who need to apply Labware Offset data outside of the Opentrons App. When enabled, code snippets for Jupyter Notebook and SSH are available during protocol setup.", + "share_app_analytics_short": "Share App Analytics", + "share_robot_analytics": "Share Robot Analytics with Opentrons", "show_labware_offset_snippets": "Show Labware Offset data code snippets", + "show_labware_offset_snippets_description": "Only for users who need to apply Labware Offset data outside of the Opentrons App. When enabled, code snippets for Jupyter Notebook and SSH are available during protocol setup.", "software_update_available": "Software Update Available", "software_version": "App Software Version", "successfully_deleted_unavail_robots": "Successfully deleted unavailable robots", @@ -81,13 +88,15 @@ "turn_off_updates": "Turn off software update notifications in App Settings.", "up_to_date": "Up to date", "update_alerts": "Software Update Alerts", - "update_available": "update available", + "update_app_now": "Update app now", + "update_available": "Update available", "update_channel": "Update Channel", "update_description": "Stable receives the latest stable releases. Beta allows you to try out new in-progress features before they launch in Stable channel, but they have not completed testing yet.", + "update_requires_restarting": "Updating requires restarting the Opentrons App.", "usb_to_ethernet_adapter_description": "Description", "usb_to_ethernet_adapter_driver_version": "Driver Version", - "usb_to_ethernet_adapter_info_description": "Some OT-2s have an internal USB-to-Ethernet adapter. If your OT-2 uses this adapter, it will be added to your computer’s device list when you make a wired connection. If you have a Realtek adapter, it is essential that the driver is up to date.", "usb_to_ethernet_adapter_info": "USB-to-Ethernet Adapter Information", + "usb_to_ethernet_adapter_info_description": "Some OT-2s have an internal USB-to-Ethernet adapter. If your OT-2 uses this adapter, it will be added to your computer’s device list when you make a wired connection. If you have a Realtek adapter, it is essential that the driver is up to date.", "usb_to_ethernet_adapter_link": "go to Realtek.com", "usb_to_ethernet_adapter_manufacturer": "Manufacturer", "usb_to_ethernet_adapter_no_driver_version": "Unknown", diff --git a/app/src/molecules/LegacyModal/index.tsx b/app/src/molecules/LegacyModal/index.tsx index 9ab0a5f12e2..e73367f84df 100644 --- a/app/src/molecules/LegacyModal/index.tsx +++ b/app/src/molecules/LegacyModal/index.tsx @@ -16,6 +16,7 @@ export interface LegacyModalProps extends StyleProps { fullPage?: boolean childrenPadding?: string | number children?: React.ReactNode + footer?: React.ReactNode } export const LegacyModal = (props: LegacyModalProps): JSX.Element => { @@ -26,6 +27,7 @@ export const LegacyModal = (props: LegacyModalProps): JSX.Element => { title, childrenPadding = `${SPACING.spacing16} ${SPACING.spacing24} ${SPACING.spacing24}`, children, + footer, ...styleProps } = props @@ -67,6 +69,7 @@ export const LegacyModal = (props: LegacyModalProps): JSX.Element => { // center within viewport aside from nav marginLeft="7.125rem" {...props} + footer={footer} > {children} diff --git a/app/src/organisms/Alerts/__tests__/Alerts.test.tsx b/app/src/organisms/Alerts/__tests__/Alerts.test.tsx index 1a3d378ef99..64241dd347c 100644 --- a/app/src/organisms/Alerts/__tests__/Alerts.test.tsx +++ b/app/src/organisms/Alerts/__tests__/Alerts.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { mountWithStore } from '@opentrons/components' import * as AppAlerts from '../../../redux/alerts' +import { TOAST_ANIMATION_DURATION } from '../../../atoms/Toast' import { Alerts } from '..' import { AnalyticsSettingsModal } from '../../AnalyticsSettingsModal' import { U2EDriverOutdatedAlert } from '../U2EDriverOutdatedAlert' @@ -73,19 +74,51 @@ describe('app-wide Alerts component', () => { AppAlerts.alertDismissed(AppAlerts.ALERT_U2E_DRIVER_OUTDATED, true) ) }) + it('should render a software update toast if a software update is available that is dismissed when clicked', () => { + const { wrapper, refresh } = render() + expect(wrapper.exists(UpdateAppModal)).toBe(false) + + stubActiveAlerts([AppAlerts.ALERT_APP_UPDATE_AVAILABLE]) + refresh() - it('should render an UpdateAppModal if appUpdateAvailable alert is triggered', () => { + setTimeout(() => { + expect(wrapper.contains('View Update')).toBe(true) + wrapper.findWhere(node => node.text() === 'View Update').simulate('click') + setTimeout( + () => expect(wrapper.contains('View Update')).toBe(false), + TOAST_ANIMATION_DURATION + ) + }, TOAST_ANIMATION_DURATION) + }) + it('should render an UpdateAppModal if the app update toast is clicked', () => { const { wrapper, store, refresh } = render() expect(wrapper.exists(UpdateAppModal)).toBe(false) stubActiveAlerts([AppAlerts.ALERT_APP_UPDATE_AVAILABLE]) refresh() - expect(wrapper.exists(UpdateAppModal)).toBe(true) - wrapper.find(UpdateAppModal).invoke('dismissAlert')?.(true) + setTimeout(() => { + expect(wrapper.contains('View Update')).toBe(true) + wrapper.findWhere(node => node.text() === 'View Update').simulate('click') - expect(store.dispatch).toHaveBeenCalledWith( - AppAlerts.alertDismissed(AppAlerts.ALERT_APP_UPDATE_AVAILABLE, true) - ) + expect(wrapper.exists(UpdateAppModal)).toBe(true) + + wrapper.find(UpdateAppModal).invoke('closeModal')?.(true) + + expect(store.dispatch).toHaveBeenCalledWith( + AppAlerts.alertDismissed(AppAlerts.ALERT_APP_UPDATE_AVAILABLE, true) + ) + }, TOAST_ANIMATION_DURATION) + }) + it('should render a success toast if the software update was successful', () => { + const { wrapper } = render() + const updatedState = { + hasJustUpdated: true, + } + + wrapper.setProps({ initialState: updatedState }) + setTimeout(() => { + expect(wrapper.contains('successfully updated')).toBe(true) + }, TOAST_ANIMATION_DURATION) }) }) diff --git a/app/src/organisms/Alerts/index.tsx b/app/src/organisms/Alerts/index.tsx index 30c1f252295..7cc8101f8cc 100644 --- a/app/src/organisms/Alerts/index.tsx +++ b/app/src/organisms/Alerts/index.tsx @@ -1,8 +1,12 @@ import * as React from 'react' import { useSelector, useDispatch } from 'react-redux' +import { useTranslation } from 'react-i18next' import head from 'lodash/head' import * as AppAlerts from '../../redux/alerts' +import { getHasJustUpdated, toggleConfigValue } from '../../redux/config' +import { SUCCESS_TOAST, WARNING_TOAST } from '../../atoms/Toast' +import { useToaster } from '../ToasterOven' import { AnalyticsSettingsModal } from '../AnalyticsSettingsModal' import { UpdateAppModal } from '../UpdateAppModal' import { U2EDriverOutdatedAlert } from './U2EDriverOutdatedAlert' @@ -12,17 +16,56 @@ import type { AlertId } from '../../redux/alerts/types' export function Alerts(): JSX.Element { const dispatch = useDispatch() + const [showUpdateModal, setShowUpdateModal] = React.useState(false) + const { t } = useTranslation('app_settings') + const { makeToast, eatToast } = useToaster() + const toastRef = React.useRef(null) // TODO(mc, 2020-05-07): move head logic to selector with alert priorities - const activeAlert: AlertId | null = useSelector((state: State) => { + const activeAlertId: AlertId | null = useSelector((state: State) => { return head(AppAlerts.getActiveAlerts(state)) ?? null }) const dismissAlert = (remember?: boolean): void => { - if (activeAlert != null) { - dispatch(AppAlerts.alertDismissed(activeAlert, remember)) + if (activeAlertId != null) { + dispatch(AppAlerts.alertDismissed(activeAlertId, remember)) } } + const isAppUpdateIgnored = useSelector((state: State) => { + return AppAlerts.getAlertIsPermanentlyIgnored( + state, + AppAlerts.ALERT_APP_UPDATE_AVAILABLE + ) + }) + + const hasJustUpdated = useSelector(getHasJustUpdated) + + // Only run this hook on app startup + React.useEffect(() => { + if (hasJustUpdated) { + makeToast(t('opentrons_app_successfully_updated'), SUCCESS_TOAST, { + closeButton: true, + disableTimeout: true, + }) + dispatch(toggleConfigValue('update.hasJustUpdated')) + } + }, []) + + React.useEffect(() => { + if (activeAlertId === AppAlerts.ALERT_APP_UPDATE_AVAILABLE) + toastRef.current = makeToast( + t('opentrons_app_update_available_variation'), + WARNING_TOAST, + { + closeButton: true, + disableTimeout: true, + linkText: t('view_update'), + onLinkClick: () => setShowUpdateModal(true), + } + ) + if (isAppUpdateIgnored && toastRef.current != null) + eatToast(toastRef.current) + }, [activeAlertId]) return ( <> @@ -30,10 +73,11 @@ export function Alerts(): JSX.Element { own render; move its logic into `state.alerts` */} - {activeAlert === AppAlerts.ALERT_U2E_DRIVER_OUTDATED ? ( + {activeAlertId === AppAlerts.ALERT_U2E_DRIVER_OUTDATED ? ( - ) : activeAlert === AppAlerts.ALERT_APP_UPDATE_AVAILABLE ? ( - + ) : null} + {showUpdateModal ? ( + setShowUpdateModal(false)} /> ) : null} ) diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/VersionInfoModal.test.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/VersionInfoModal.test.tsx index 6254700718b..8c93da5cb17 100644 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/VersionInfoModal.test.tsx +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/VersionInfoModal.test.tsx @@ -208,7 +208,7 @@ describe('VersionInfoModal', () => { expect(handleClose).not.toHaveBeenCalled() - wrapper.find(UpdateAppModal).invoke('closeModal')?.() + wrapper.find(UpdateAppModal).invoke('closeModal')?.(true) expect(handleClose).toHaveBeenCalled() }) diff --git a/app/src/organisms/UpdateAppModal/__tests__/UpdateAppModal.test.tsx b/app/src/organisms/UpdateAppModal/__tests__/UpdateAppModal.test.tsx index f7675c7b4ca..2bcc5ffa162 100644 --- a/app/src/organisms/UpdateAppModal/__tests__/UpdateAppModal.test.tsx +++ b/app/src/organisms/UpdateAppModal/__tests__/UpdateAppModal.test.tsx @@ -1,48 +1,49 @@ import * as React from 'react' -import { Link as InternalLink } from 'react-router-dom' -import { mountWithStore, BaseModal, Flex, Icon } from '@opentrons/components' +import { i18n } from '../../../i18n' +import { fireEvent } from '@testing-library/react' import * as Shell from '../../../redux/shell' -import { ErrorModal } from '../../../molecules/modals' -import { ReleaseNotes } from '../../../molecules/ReleaseNotes' -import { UpdateAppModal } from '..' +import { renderWithProviders } from '@opentrons/components' +import { UpdateAppModal, UpdateAppModalProps } from '..' -import type { State, Action } from '../../../redux/types' +import type { State } from '../../../redux/types' import type { ShellUpdateState } from '../../../redux/shell/types' -import type { UpdateAppModalProps } from '..' -import type { HTMLAttributes, ReactWrapper } from 'enzyme' -// TODO(mc, 2020-10-06): this is a partial mock because shell/update -// needs some reorg to split actions and selectors jest.mock('../../../redux/shell/update', () => ({ ...jest.requireActual<{}>('../../../redux/shell/update'), getShellUpdateState: jest.fn(), })) -jest.mock('react-router-dom', () => ({ Link: () => <> })) +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: jest.fn(), + }), +})) const getShellUpdateState = Shell.getShellUpdateState as jest.MockedFunction< typeof Shell.getShellUpdateState > -const MOCK_STATE: State = { mockState: true } as any +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} describe('UpdateAppModal', () => { - const closeModal = jest.fn() - const dismissAlert = jest.fn() - - const render = (props: UpdateAppModalProps) => { - return mountWithStore( - , - { - initialState: MOCK_STATE, - } - ) - } + let props: React.ComponentProps beforeEach(() => { + props = { + closeModal: jest.fn(), + } as UpdateAppModalProps getShellUpdateState.mockImplementation((state: State) => { - expect(state).toBe(MOCK_STATE) return { + downloading: false, + available: true, + downloaded: false, + downloadPercentage: 0, + error: null, info: { version: '1.2.3', releaseNotes: 'this is a release', @@ -55,275 +56,52 @@ describe('UpdateAppModal', () => { jest.resetAllMocks() }) - it('should render an BaseModal using available version from state', () => { - const { wrapper } = render({ closeModal }) - const modal = wrapper.find(BaseModal) - const title = modal.find('h2') - const titleIcon = title.closest(Flex).find(Icon) - - expect(title.text()).toBe('App Version 1.2.3 Available') - expect(titleIcon.prop('name')).toBe('alert') - }) - - it('should render a component with the release notes', () => { - const { wrapper } = render({ closeModal }) - const releaseNotes = wrapper.find(ReleaseNotes) - - expect(releaseNotes.prop('source')).toBe('this is a release') + it('renders update available title and release notes when update is available', () => { + const [{ getByText }] = render(props) + expect(getByText('Opentrons App Update Available')).toBeInTheDocument() + expect(getByText('this is a release')).toBeInTheDocument() }) - - it('should render a "Not Now" button that closes the modal', () => { - const { wrapper } = render({ closeModal }) - const notNowButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /not now/i.test(b.text()) - ) - - expect(closeModal).not.toHaveBeenCalled() - notNowButton.invoke('onClick')?.({} as React.MouseEvent) + it('closes modal when "remind me later" button is clicked', () => { + const closeModal = jest.fn() + const [{ getByText }] = render({ ...props, closeModal }) + fireEvent.click(getByText('Remind me later')) expect(closeModal).toHaveBeenCalled() }) - - it('should render a "Download" button that starts the update', () => { - const { wrapper, store } = render({ closeModal }) - const downloadButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => /download/i.test(b.text())) - - downloadButton.invoke('onClick')?.({} as React.MouseEvent) - - expect(store.dispatch).toHaveBeenCalledWith(Shell.downloadShellUpdate()) - }) - - it('should render a spinner if update is downloading', () => { - getShellUpdateState.mockReturnValue({ - downloading: true, - } as ShellUpdateState) - const { wrapper } = render({ closeModal }) - const spinner = wrapper - .find(Icon) - .filterWhere( - (i: ReactWrapper>) => - i.prop('name') === 'ot-spinner' - ) - const spinnerParent = spinner.closest(Flex) - - expect(spinnerParent.text()).toMatch(/download in progress/i) - }) - - it('should render a instructional copy instead of release notes if update is downloaded', () => { - getShellUpdateState.mockReturnValue({ - downloaded: true, - info: { - version: '1.2.3', - releaseNotes: 'this is a release', - }, - } as ShellUpdateState) - - const { wrapper } = render({ closeModal }) - const title = wrapper.find('h2') - - expect(title.text()).toBe('App Version 1.2.3 Downloaded') - expect(wrapper.exists(ReleaseNotes)).toBe(false) - expect(wrapper.text()).toMatch(/Restart your app to complete the update/i) - }) - - it('should render a "Restart App" button if update is downloaded', () => { - getShellUpdateState.mockReturnValue({ - downloaded: true, - } as ShellUpdateState) - const { wrapper, store } = render({ closeModal }) - const restartButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /restart/i.test(b.text()) - ) - - restartButton.invoke('onClick')?.({} as React.MouseEvent) - expect(store.dispatch).toHaveBeenCalledWith(Shell.applyShellUpdate()) - }) - - it('should render a "Not Now" button if update is downloaded', () => { - getShellUpdateState.mockReturnValue({ - downloaded: true, - } as ShellUpdateState) - const { wrapper } = render({ closeModal }) - const notNowButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /not now/i.test(b.text()) - ) - - notNowButton.invoke('onClick')?.({} as React.MouseEvent) - expect(closeModal).toHaveBeenCalled() - }) - - it('should render an ErrorModal if the update errors', () => { + it('shows error modal on error', () => { getShellUpdateState.mockReturnValue({ error: { message: 'Could not get code signature for running application', name: 'Error', }, } as ShellUpdateState) - - const { wrapper } = render({ closeModal }) - const errorModal = wrapper.find(ErrorModal) - - expect(errorModal.prop('heading')).toBe('Update Error') - expect(errorModal.prop('description')).toBe( - 'Something went wrong while updating your app' - ) - expect(errorModal.prop('error')).toEqual({ - message: 'Could not get code signature for running application', - name: 'Error', - }) - - errorModal.invoke('close')?.() - - expect(closeModal).toHaveBeenCalled() - }) - - it('should call props.dismissAlert via the "Not Now" button', () => { - const { wrapper } = render({ dismissAlert }) - const notNowButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /not now/i.test(b.text()) - ) - - expect(dismissAlert).not.toHaveBeenCalled() - notNowButton.invoke('onClick')?.({} as React.MouseEvent) - expect(dismissAlert).toHaveBeenCalledWith(false) + const [{ getByText }] = render(props) + expect(getByText('Update Error')).toBeInTheDocument() }) - - it('should call props.dismissAlert via the Error modal "close" button', () => { + it('shows a download progress bar when downloading', () => { getShellUpdateState.mockReturnValue({ - error: { - message: 'Could not get code signature for running application', - name: 'Error', - }, + downloading: true, + downloadPercentage: 50, } as ShellUpdateState) - - const { wrapper } = render({ dismissAlert }) - const errorModal = wrapper.find(ErrorModal) - - errorModal.invoke('close')?.() - - expect(dismissAlert).toHaveBeenCalledWith(false) + const [{ getByText, getByRole }] = render(props) + expect(getByText('Downloading update...')).toBeInTheDocument() + expect(getByRole('progressbar')).toBeInTheDocument() }) - - it('should have a button to allow the user to dismiss alerts permanently', () => { - const { wrapper } = render({ dismissAlert }) - const ignoreButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /turn off update notifications/i.test(b.text()) - ) - - ignoreButton.invoke('onClick')?.({} as React.MouseEvent) - - const title = wrapper.find('h2') - - expect(wrapper.exists(ReleaseNotes)).toBe(false) - expect(title.text()).toMatch(/turned off update notifications/i) - expect(wrapper.text()).toMatch( - /You've chosen to not be notified when an app update is available/ - ) - }) - - it('should not show the "ignore" button if modal was not alert triggered', () => { - const { wrapper } = render({ closeModal }) - const ignoreButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /turn off update notifications/i.test(b.text()) - ) - - expect(ignoreButton.exists()).toBe(false) - }) - - it('should not show the "ignore" button if the user has proceeded with the update', () => { + it('renders download complete text when download is finished', () => { getShellUpdateState.mockReturnValue({ + downloading: false, downloaded: true, } as ShellUpdateState) - - const { wrapper } = render({ dismissAlert }) - const ignoreButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /turn off update notifications/i.test(b.text()) - ) - - expect(ignoreButton.exists()).toBe(false) - }) - - it('should dismiss the alert permanently once the user clicks "OK"', () => { - const { wrapper } = render({ dismissAlert }) - - wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /turn off update notifications/i.test(b.text()) - ) - .invoke('onClick')?.({} as React.MouseEvent) - - wrapper - .find('button') - .filterWhere((b: ReactWrapper) => /ok/i.test(b.text())) - .invoke('onClick')?.({} as React.MouseEvent) - - expect(dismissAlert).toHaveBeenCalledWith(true) - }) - - it('should dismiss the alert permanently if the component unmounts, for safety', () => { - const { wrapper } = render({ dismissAlert }) - - wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /turn off update notifications/i.test(b.text()) - ) - .invoke('onClick')?.({} as React.MouseEvent) - - wrapper.unmount() - - expect(dismissAlert).toHaveBeenCalledWith(true) - }) - - it('should have a link to /more/app that also dismisses alert permanently', () => { - const { wrapper } = render({ dismissAlert }) - - wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /turn off update notifications/i.test(b.text()) - ) - .invoke('onClick')?.({} as React.MouseEvent) - - wrapper - .find(InternalLink) - .filterWhere( - (b: ReactWrapper>) => - b.prop('to') === '/more/app' - ) - .invoke('onClick')?.({} as React.MouseEvent) - - expect(dismissAlert).toHaveBeenCalledWith(true) + const [{ getByText, getByRole }] = render(props) + expect( + getByText('Download complete, restarting the app...') + ).toBeInTheDocument() + expect(getByRole('progressbar')).toBeInTheDocument() }) - - it('should not send dismissal via unmount if button is close button clicked', () => { - const { wrapper } = render({ dismissAlert }) - const notNowButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /not now/i.test(b.text()) - ) - - notNowButton.invoke('onClick')?.({} as React.MouseEvent) - wrapper.unmount() - - expect(dismissAlert).toHaveBeenCalledTimes(1) - expect(dismissAlert).toHaveBeenCalledWith(false) + it('renders an error message when an error occurs', () => { + getShellUpdateState.mockReturnValue({ + error: { name: 'Update Error' }, + } as ShellUpdateState) + const [{ getByTitle }] = render(props) + expect(getByTitle('Update Error')).toBeInTheDocument() }) }) diff --git a/app/src/organisms/UpdateAppModal/index.tsx b/app/src/organisms/UpdateAppModal/index.tsx index 573a58f0404..43a46c8e63b 100644 --- a/app/src/organisms/UpdateAppModal/index.tsx +++ b/app/src/organisms/UpdateAppModal/index.tsx @@ -1,31 +1,19 @@ import * as React from 'react' import { useSelector, useDispatch } from 'react-redux' -import { Link as InternalLink } from 'react-router-dom' +import styled, { css } from 'styled-components' +import { useHistory } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { ALIGN_CENTER, - C_BLUE, - C_TRANSPARENT, - C_WHITE, + COLORS, DIRECTION_COLUMN, - DISPLAY_FLEX, - FONT_SIZE_BODY_1, - FONT_SIZE_HEADER, - FONT_STYLE_ITALIC, - FONT_WEIGHT_REGULAR, JUSTIFY_FLEX_END, - SIZE_4, - SIZE_6, SPACING, - useMountEffect, - BaseModal, - Btn, - Box, Flex, - Icon, - SecondaryBtn, - Text, - TYPOGRAPHY, + NewPrimaryBtn, + NewSecondaryBtn, + BORDERS, } from '@opentrons/components' import { @@ -34,194 +22,148 @@ import { applyShellUpdate, } from '../../redux/shell' -import { ErrorModal } from '../../molecules/modals' import { ReleaseNotes } from '../../molecules/ReleaseNotes' +import { LegacyModal } from '../../molecules/LegacyModal' +import { Banner } from '../../atoms/Banner' +import { ProgressBar } from '../../atoms/ProgressBar' import type { Dispatch } from '../../redux/types' +import { StyledText } from '../../atoms/text' -export interface UpdateAppModalProps { - dismissAlert?: (remember: boolean) => unknown - closeModal?: () => unknown +interface PlaceHolderErrorProps { + errorMessage?: string } -// TODO(mc, 2020-10-06): i18n -const APP_VERSION = 'App Version' -const AVAILABLE = 'Available' -const DOWNLOADED = 'Downloaded' -const DOWNLOAD_IN_PROGRESS = 'Download in progress' -const DOWNLOAD = 'Download' -const RESTART_APP = 'Restart App' -const NOT_NOW = 'Not Now' -const OK = 'OK' -const UPDATE_ERROR = 'Update Error' -const SOMETHING_WENT_WRONG = 'Something went wrong while updating your app' -const TURN_OFF_UPDATE_NOTIFICATIONS = 'Turn off update notifications' -const YOUVE_TURNED_OFF_NOTIFICATIONS = "You've Turned Off Update Notifications" -const VIEW_APP_SOFTWARE_SETTINGS = 'View App Software Settings' -const NOTIFICATIONS_DISABLED_DESCRIPTION = ( - <> - You{"'"}ve chosen to not be notified when an app update is available. You - can change this setting under More {'>'} App {'>'}{' '} - App Software Settings. - -) +// TODO(jh, 2023-08-25): refactor default error handling into LegacyModal +const PlaceholderError = ({ + errorMessage, +}: PlaceHolderErrorProps): JSX.Element => { + const SOMETHING_WENT_WRONG = 'Something went wrong while updating your app.' + const AN_UNKNOWN_ERROR_OCCURRED = 'An unknown error occurred.' + const FALLBACK_ERROR_MESSAGE = `If you keep getting this message, try restarting your app and/or + robot. If this does not resolve the issue please contact Opentrons + Support.` -const FINISH_UPDATE_INSTRUCTIONS = ( - <> - - Restart your app to complete the update. Please note the following: - - -
  • - - After updating the Opentrons App, update your robot{' '} - to ensure the app and robot software is in sync. - -
  • -
  • - - You should update the Opentrons App on all computers{' '} - that you use with your robot. - -
  • -
    - -) + return ( + <> + {SOMETHING_WENT_WRONG} +
    +
    + {errorMessage ?? AN_UNKNOWN_ERROR_OCCURRED} +
    +
    + {FALLBACK_ERROR_MESSAGE} + + ) +} -const SPINNER = ( - - - - {DOWNLOAD_IN_PROGRESS} - - -) +const UPDATE_ERROR = 'Update Error' +const FOOTER_BUTTON_STYLE = css` + text-transform: lowercase; + padding-left: ${SPACING.spacing16}; + padding-right: ${SPACING.spacing16}; + border-radius: ${BORDERS.borderRadiusSize1}; + margin-top: ${SPACING.spacing16}; + margin-bottom: ${SPACING.spacing16}; + + &:first-letter { + text-transform: uppercase; + } +` +const UpdateAppBanner = styled(Banner)` + border: none; +` +const UPDATE_PROGRESS_BAR_STYLE = css` + margin-top: 1.5rem; + border-radius: ${BORDERS.borderRadiusSize3}; + background: ${COLORS.medGreyEnabled}; +` + +export interface UpdateAppModalProps { + closeModal: (arg0: boolean) => void +} export function UpdateAppModal(props: UpdateAppModalProps): JSX.Element { - const { dismissAlert, closeModal } = props - const [updatesIgnored, setUpdatesIgnored] = React.useState(false) + const { closeModal } = props const dispatch = useDispatch() const updateState = useSelector(getShellUpdateState) - const { downloaded, downloading, error, info: updateInfo } = updateState - const version = updateInfo?.version ?? '' + const { + downloaded, + downloading, + downloadPercentage, + error, + info: updateInfo, + } = updateState const releaseNotes = updateInfo?.releaseNotes + const { t } = useTranslation('app_settings') + const history = useHistory() - const handleUpdateClick = (): void => { - dispatch(downloaded ? applyShellUpdate() : downloadShellUpdate()) - } - - // ensure close handlers are called on close button click or on component - // unmount (for safety), but not both - const latestHandleClose = React.useRef<(() => void) | null>(null) + if (downloaded) setTimeout(() => dispatch(applyShellUpdate()), 5000) - React.useEffect(() => { - latestHandleClose.current = () => { - if (typeof dismissAlert === 'function') dismissAlert(updatesIgnored) - if (typeof closeModal === 'function') closeModal() - latestHandleClose.current = null - } - }) - - const handleCloseClick = (): void => { - latestHandleClose.current && latestHandleClose.current() + const handleRemindMeLaterClick = (): void => { + history.push('/app-settings/general') + closeModal(true) } - useMountEffect(() => { - return () => { - latestHandleClose.current && latestHandleClose.current() - } - }) - - if (error) { - return ( - - ) - } - - if (downloading) return SPINNER + const appUpdateFooter = ( + + + {t('remind_later')} + + dispatch(downloadShellUpdate())} + marginRight={SPACING.spacing12} + css={FOOTER_BUTTON_STYLE} + > + {t('update_app_now')} + + + ) - // TODO(mc, 2020-10-08): refactor most of this back into a new AlertModal - // component built with BaseModal return ( - + {error != null ? ( + closeModal(true)}> + + + ) : null} + {(downloading || downloaded) && error == null ? ( + + + + {downloading ? t('download_update') : t('restarting_app')} + + + + + ) : null} + {!downloading && !downloaded && error == null ? ( + closeModal(true)} + closeOnOutsideClick={true} + footer={appUpdateFooter} + maxHeight="80%" > - - {updatesIgnored - ? YOUVE_TURNED_OFF_NOTIFICATIONS - : `${APP_VERSION} ${version} ${ - downloaded ? DOWNLOADED : AVAILABLE - }`} - - } - footer={ - - {updatesIgnored ? ( - <> - - {VIEW_APP_SOFTWARE_SETTINGS} - - {OK} - - ) : ( - <> - {dismissAlert != null && !downloaded ? ( - setUpdatesIgnored(true)} - > - {TURN_OFF_UPDATE_NOTIFICATIONS} - - ) : null} - - {NOT_NOW} - - - {downloaded ? RESTART_APP : DOWNLOAD} - - - )} - - } - > - - {updatesIgnored ? ( - NOTIFICATIONS_DISABLED_DESCRIPTION - ) : downloaded ? ( - FINISH_UPDATE_INSTRUCTIONS - ) : ( - - )} - - + + + {t('update_requires_restarting')} + + + + + ) : null} + ) } diff --git a/app/src/pages/AppSettings/GeneralSettings.tsx b/app/src/pages/AppSettings/GeneralSettings.tsx index ae88539c16f..046226a9af5 100644 --- a/app/src/pages/AppSettings/GeneralSettings.tsx +++ b/app/src/pages/AppSettings/GeneralSettings.tsx @@ -113,7 +113,7 @@ export function GeneralSettings(): JSX.Element { type="warning" onCloseClick={() => setShowUpdateBanner(false)} > - {t('update_available')} + {t('opentrons_app_update_available_variation')} { getByText('Additional Custom Labware Source Folder') getByText('Prevent Robot Caching') getByText('Clear Unavailable Robots') - getByText('Developer Tools') + getByText('Enable Developer Tools') getByText('OT-2 Advanced Settings') getByText('Tip Length Calibration Method') getByText('USB-to-Ethernet Adapter Information') diff --git a/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx b/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx index a9719ce1ff7..74856e9e15d 100644 --- a/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx +++ b/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx @@ -138,7 +138,7 @@ describe('RobotSettingsDashboard', () => { getByText('Update Channel') getByText('Apply labware offsets') getByText('Use stored data when setting up a protocol.') - getByText('Developer Tools') + getByText('Enable Developer Tools') getByText('Access additional logging and feature flags.') getByText('Share App Analytics') getByText('Share Robot Logs') @@ -236,7 +236,7 @@ describe('RobotSettingsDashboard', () => { it('should call a mock function when tapping enable dev tools', () => { const [{ getByText }] = render() - const button = getByText('Developer Tools') + const button = getByText('Enable Developer Tools') fireEvent.click(button) expect(mockToggleDevtools).toHaveBeenCalled() }) diff --git a/app/src/redux/analytics/types.ts b/app/src/redux/analytics/types.ts index 8bd8c3fb208..d5b96a2dd8c 100644 --- a/app/src/redux/analytics/types.ts +++ b/app/src/redux/analytics/types.ts @@ -1,4 +1,3 @@ -import type { Config } from '../config/types' import { ANALYTICS_PIPETTE_OFFSET_STARTED, ANALYTICS_TIP_LENGTH_STARTED, @@ -7,8 +6,9 @@ import { import type { CalibrationCheckComparisonsPerCalibration } from '../sessions/types' import type { DeckCalibrationStatus } from '../calibration/types' import type { Mount } from '@opentrons/components' +import type { ConfigV0 } from '../config/types' -export type AnalyticsConfig = Config['analytics'] +export type AnalyticsConfig = ConfigV0['analytics'] export interface ProtocolAnalyticsData { protocolType: string diff --git a/app/src/redux/config/__tests__/selectors.test.ts b/app/src/redux/config/__tests__/selectors.test.ts index f752098f4c4..0c79c83a316 100644 --- a/app/src/redux/config/__tests__/selectors.test.ts +++ b/app/src/redux/config/__tests__/selectors.test.ts @@ -43,6 +43,20 @@ describe('shell selectors', () => { }) }) + describe('getHasJustUpdated', () => { + it('should return false if config is unknown', () => { + const state: State = { config: null } as any + expect(Selectors.getHasJustUpdated(state)).toEqual(false) + }) + + it('should return config.update.hasJustUpdated if config is known', () => { + const state: State = { + config: { update: { hasJustUpdated: false } }, + } as any + expect(Selectors.getHasJustUpdated(state)).toEqual(false) + }) + }) + describe('getUpdateChannelOptions', () => { it('should return "latest" and "beta" options if config is unknown', () => { const state: State = { config: null } as any diff --git a/app/src/redux/config/schema-types.ts b/app/src/redux/config/schema-types.ts index f859a4c3c2c..fe2230a5fcc 100644 --- a/app/src/redux/config/schema-types.ts +++ b/app/src/redux/config/schema-types.ts @@ -96,7 +96,7 @@ export interface ConfigV0 { devInternal?: FeatureFlags } -export interface ConfigV1 extends Omit { +export type ConfigV1 = Omit & { version: 1 discovery: { candidates: DiscoveryCandidates @@ -104,15 +104,14 @@ export interface ConfigV1 extends Omit { } } -export interface ConfigV2 extends Omit { +export type ConfigV2 = Omit & { version: 2 calibration: { useTrashSurfaceForTipCal: boolean | null } } -// v3 config changes default values but does not change schema -export interface ConfigV3 extends Omit { +export type ConfigV3 = Omit & { version: 3 support: ConfigV2['support'] & { name: string | null @@ -120,77 +119,77 @@ export interface ConfigV3 extends Omit { } } -export interface ConfigV4 extends Omit { +export type ConfigV4 = Omit & { version: 4 labware: ConfigV3['labware'] & { showLabwareOffsetCodeSnippets: boolean } } -export interface ConfigV5 extends Omit { +export type ConfigV5 = Omit & { version: 5 python: { pathToPythonOverride: string | null } } -export interface ConfigV6 extends Omit { +export type ConfigV6 = Omit & { version: 6 modules: { heaterShaker: { isAttached: boolean } } } -export interface ConfigV7 extends Omit { +export type ConfigV7 = Omit & { version: 7 ui: ConfigV6['ui'] & { minWidth: number } } -export interface ConfigV8 extends Omit { +export type ConfigV8 = Omit & { version: 8 } -export interface ConfigV9 extends Omit { +export type ConfigV9 = Omit & { version: 9 isOnDevice: boolean } -export interface ConfigV10 extends Omit { +export type ConfigV10 = Omit & { version: 10 protocols: { sendAllProtocolsToOT3: boolean } } -export interface ConfigV11 extends Omit { +export type ConfigV11 = Omit & { version: 11 protocols: ConfigV10['protocols'] & { protocolsStoredSortKey: ProtocolSort | null } } -export interface ConfigV12 extends Omit { +export type ConfigV12 = Omit & { version: 12 robotSystemUpdate: { manifestUrls: { OT2: string; OT3: string } } } -export interface ConfigV13 extends Omit { +export type ConfigV13 = Omit & { version: 13 protocols: ConfigV12['protocols'] & { protocolsOnDeviceSortKey: ProtocolsOnDeviceSortKey | null } } -export interface ConfigV14 extends Omit { +export type ConfigV14 = Omit & { version: 14 protocols: ConfigV13['protocols'] & { pinnedProtocolIds: string[] } } -export interface ConfigV15 extends Omit { +export type ConfigV15 = Omit & { version: 15 onDeviceDisplaySettings: { sleepMs: number @@ -199,23 +198,32 @@ export interface ConfigV15 extends Omit { } } -export interface ConfigV16 extends Omit { +export type ConfigV16 = Omit & { version: 16 onDeviceDisplaySettings: ConfigV15['onDeviceDisplaySettings'] & { unfinishedUnboxingFlowRoute: string | null } } -export interface ConfigV17 extends Omit { +export type ConfigV17 = Omit & { version: 17 protocols: ConfigV15['protocols'] & { applyHistoricOffsets: boolean } } -export interface ConfigV18 - extends Omit { +export type ConfigV18 = Omit & { version: 18 } -export type Config = ConfigV18 +export type ConfigV19 = Omit & { + version: 19 + devtools: boolean + reinstallDevtools: boolean + update: { + channel: UpdateChannel + hasJustUpdated: boolean + } +} + +export type Config = ConfigV19 diff --git a/app/src/redux/config/selectors.ts b/app/src/redux/config/selectors.ts index f42e2952f20..007d474f7d1 100644 --- a/app/src/redux/config/selectors.ts +++ b/app/src/redux/config/selectors.ts @@ -32,6 +32,10 @@ export const getUpdateChannel = (state: State): UpdateChannel => { return state.config?.update.channel ?? 'latest' } +export const getHasJustUpdated = (state: State): boolean => { + return state.config?.update.hasJustUpdated ?? false +} + export const getUseTrashSurfaceForTipCal = (state: State): boolean | null => { return state.config?.calibration.useTrashSurfaceForTipCal ?? null } diff --git a/app/src/redux/shell/__tests__/update.test.ts b/app/src/redux/shell/__tests__/update.test.ts index 1d7916830b5..080be9cb79b 100644 --- a/app/src/redux/shell/__tests__/update.test.ts +++ b/app/src/redux/shell/__tests__/update.test.ts @@ -23,7 +23,10 @@ describe('shell/update', () => { name: 'shell:DOWNLOAD_UPDATE', creator: ShellUpdate.downloadShellUpdate, args: [], - expected: { type: 'shell:DOWNLOAD_UPDATE', meta: { shell: true } }, + expected: { + type: 'shell:DOWNLOAD_UPDATE', + meta: { shell: true }, + }, }, { name: 'shell:APPLY_UPDATE', diff --git a/app/src/redux/shell/reducer.ts b/app/src/redux/shell/reducer.ts index ad1ab6729b6..79ead262f46 100644 --- a/app/src/redux/shell/reducer.ts +++ b/app/src/redux/shell/reducer.ts @@ -10,6 +10,7 @@ const INITIAL_STATE: ShellUpdateState = { downloading: false, available: false, downloaded: false, + downloadPercentage: 0, info: null, error: null, } @@ -37,7 +38,13 @@ export function shellUpdateReducer( ...state, downloading: false, error: action.payload.error || null, - downloaded: !action.payload.error, + downloaded: action.payload.error == null, + } + } + case 'shell:DOWNLOAD_PERCENTAGE': { + return { + ...state, + downloadPercentage: action.payload.percent, } } } diff --git a/app/src/redux/shell/types.ts b/app/src/redux/shell/types.ts index af6fb1fb753..f9833a8aedc 100644 --- a/app/src/redux/shell/types.ts +++ b/app/src/redux/shell/types.ts @@ -25,6 +25,7 @@ export interface ShellUpdateState { downloading: boolean available: boolean downloaded: boolean + downloadPercentage: number error: Error | null | undefined info: UpdateInfo | null | undefined } @@ -38,6 +39,7 @@ export type ShellUpdateAction = | { type: 'shell:DOWNLOAD_UPDATE'; meta: { shell: true } } | { type: 'shell:DOWNLOAD_UPDATE_RESULT'; payload: { error?: Error } } | { type: 'shell:APPLY_UPDATE'; meta: { shell: true } } + | { type: 'shell:DOWNLOAD_PERCENTAGE'; payload: { percent: number } } export interface ShellState { update: ShellUpdateState From 816eaa7f29b317442b46407f3cfe120b82fafb96 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Wed, 6 Sep 2023 11:26:10 -0400 Subject: [PATCH 04/13] fix(robot-server): Pin pickle protocol version to v4 (#13466) --- .../robot_server/persistence/_tables.py | 5 ++-- .../persistence/pickle_protocol_version.py | 23 +++++++++++++++++++ .../protocols/completed_analysis_store.py | 5 +++- 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 robot-server/robot_server/persistence/pickle_protocol_version.py diff --git a/robot-server/robot_server/persistence/_tables.py b/robot-server/robot_server/persistence/_tables.py index ab30c512e05..7de9fa60465 100644 --- a/robot-server/robot_server/persistence/_tables.py +++ b/robot-server/robot_server/persistence/_tables.py @@ -2,6 +2,7 @@ import sqlalchemy from . import legacy_pickle +from .pickle_protocol_version import PICKLE_PROTOCOL_VERSION from ._utc_datetime import UTCDateTime _metadata = sqlalchemy.MetaData() @@ -94,13 +95,13 @@ # column added in schema v1 sqlalchemy.Column( "state_summary", - sqlalchemy.PickleType(pickler=legacy_pickle), + sqlalchemy.PickleType(pickler=legacy_pickle, protocol=PICKLE_PROTOCOL_VERSION), nullable=True, ), # column added in schema v1 sqlalchemy.Column( "commands", - sqlalchemy.PickleType(pickler=legacy_pickle), + sqlalchemy.PickleType(pickler=legacy_pickle, protocol=PICKLE_PROTOCOL_VERSION), nullable=True, ), # column added in schema v1 diff --git a/robot-server/robot_server/persistence/pickle_protocol_version.py b/robot-server/robot_server/persistence/pickle_protocol_version.py new file mode 100644 index 00000000000..a4d9702bf07 --- /dev/null +++ b/robot-server/robot_server/persistence/pickle_protocol_version.py @@ -0,0 +1,23 @@ +# noqa: D100 + + +from typing_extensions import Final + + +PICKLE_PROTOCOL_VERSION: Final = 4 +"""The version of Python's pickle protocol that we should use for serializing new objects. + +We set this to v4 because it's the least common denominator between all of our environments. +At the time of writing (2023-09-05): + +* Flex: Python 3.8, pickle protocol v5 by default +* OT-2: Python 3.7, pickle protocol v4 by default +* Typical local dev environments: Python 3.7, pickle protocol v4 by default + +For troubleshooting, we want our dev environments be able to read pickles created by any robot. +""" + + +# TODO(mm, 2023-09-05): Delete this when robot-server stops pickling new objects +# (https://opentrons.atlassian.net/browse/RSS-98), or when we upgrade the Python version +# in our dev environments. diff --git a/robot-server/robot_server/protocols/completed_analysis_store.py b/robot-server/robot_server/protocols/completed_analysis_store.py index 77f4eea682f..29ee571d08c 100644 --- a/robot-server/robot_server/protocols/completed_analysis_store.py +++ b/robot-server/robot_server/protocols/completed_analysis_store.py @@ -11,6 +11,7 @@ from robot_server.persistence import analysis_table, sqlite_rowid from robot_server.persistence import legacy_pickle +from robot_server.persistence.pickle_protocol_version import PICKLE_PROTOCOL_VERSION from .analysis_models import CompletedAnalysis from .analysis_memcache import MemoryCache @@ -289,7 +290,9 @@ async def add(self, completed_analysis_resource: CompletedAnalysisResource) -> N def _serialize_completed_analysis_to_pickle( completed_analysis: CompletedAnalysis, ) -> bytes: - return legacy_pickle.dumps(completed_analysis.dict()) + return legacy_pickle.dumps( + completed_analysis.dict(), protocol=PICKLE_PROTOCOL_VERSION + ) def _serialize_completed_analysis_to_json(completed_analysis: CompletedAnalysis) -> str: From 2234b7481dd1d5b37339f2288e06d0112515253e Mon Sep 17 00:00:00 2001 From: Jamey H Date: Wed, 6 Sep 2023 12:14:16 -0400 Subject: [PATCH 05/13] fix(app): fix home gantry on restart setting (#13470) Closes RQA-1458 Button was not properly toggling although state was updating. Change default display to "on", since this matches default behavior. --- .../RobotSettingsDashboard/RobotSettingsList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/RobotSettingsList.tsx b/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/RobotSettingsList.tsx index 1a2f9c4f141..0c14f7269e8 100644 --- a/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/RobotSettingsList.tsx +++ b/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/RobotSettingsList.tsx @@ -77,7 +77,7 @@ export function RobotSettingsList(props: RobotSettingsListProps): JSX.Element { const isHomeGantryIsOn = allRobotSettings.find(({ id }) => id === HOME_GANTRY_SETTING_ID)?.value ?? - false + true const robotUpdateType = useSelector((state: State) => { return localRobot != null && localRobot.status !== UNREACHABLE @@ -202,7 +202,7 @@ export function RobotSettingsList(props: RobotSettingsListProps): JSX.Element { dataTestId="RobotSettingButton_home_gantry_on_restart" settingInfo={t('home_gantry_subtext')} iconName="gantry-homing" - rightElement={} + rightElement={} onClick={() => dispatch( updateSetting( From 96834e6d7b6094c2139ea617a02a38a65b73a14f Mon Sep 17 00:00:00 2001 From: Laura Cox <31892318+Laura-Danielle@users.noreply.github.com> Date: Wed, 6 Sep 2023 19:28:35 +0300 Subject: [PATCH 06/13] fix(shared-data): Migrate units for mutable configs (#13471) --- .../python/opentrons_shared_data/pipette/types.py | 2 ++ .../tests/pipette/test_mutable_configurations.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/shared-data/python/opentrons_shared_data/pipette/types.py b/shared-data/python/opentrons_shared_data/pipette/types.py index dba0b62c8ac..be86999c4ac 100644 --- a/shared-data/python/opentrons_shared_data/pipette/types.py +++ b/shared-data/python/opentrons_shared_data/pipette/types.py @@ -140,6 +140,8 @@ def build( max: float, name: str, ) -> "MutableConfig": + if units == "mm/sec": + units = "mm/s" return cls( value=value, default=default, diff --git a/shared-data/python/tests/pipette/test_mutable_configurations.py b/shared-data/python/tests/pipette/test_mutable_configurations.py index 0ab38dd9fe5..ef4ad54740c 100644 --- a/shared-data/python/tests/pipette/test_mutable_configurations.py +++ b/shared-data/python/tests/pipette/test_mutable_configurations.py @@ -249,3 +249,18 @@ def test_load_with_overrides( assert updated_configurations_dict == dict_loaded_configs else: assert updated_configurations == loaded_base_configurations + + +def test_build_mutable_config_using_old_units() -> None: + """Test that MutableConfigs can build with old units.""" + old_units_config = { + "value": 5, + "default": 5.0, + "units": "mm/s", + "type": "float", + "min": 0.01, + "max": 30, + } + assert ( + types.MutableConfig.build(**old_units_config, name="dropTipSpeed") is not None + ) From 9eaa1f727b97bf02e04cc0b14dc03bd73a22cad0 Mon Sep 17 00:00:00 2001 From: Shlok Amin Date: Wed, 6 Sep 2023 14:13:26 -0400 Subject: [PATCH 07/13] fix(app): do not check estop status if robot is not a Flex (#13460) closes RQA-1406 --- .../Devices/ProtocolRun/ProtocolRunHeader.tsx | 55 +------------------ .../Devices/ProtocolRun/RunFailedModal.tsx | 2 +- .../__tests__/ProtocolRunHeader.test.tsx | 35 +----------- .../__tests__/ViewUpdateModal.test.tsx | 7 +++ .../hooks/__tests__/useIsRobotBusy.test.ts | 46 ++++++++++++++-- .../organisms/Devices/hooks/useIsRobotBusy.ts | 12 +++- .../DeviceDetails/DeviceDetailsComponent.tsx | 10 ++-- 7 files changed, 69 insertions(+), 98 deletions(-) diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index 7bfeed69f28..014841be972 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -16,11 +16,7 @@ import { RUN_STATUS_BLOCKED_BY_OPEN_DOOR, RunStatus, } from '@opentrons/api-client' -import { - useRunQuery, - useModulesQuery, - useEstopQuery, -} from '@opentrons/react-api-client' +import { useRunQuery, useModulesQuery } from '@opentrons/react-api-client' import { HEATERSHAKER_MODULE_TYPE } from '@opentrons/shared-data' import { Box, @@ -83,14 +79,12 @@ import { useIsRobotViewable, useTrackProtocolRunEvent, useRobotAnalyticsData, - useIsOT3, } from '../hooks' import { formatTimestamp } from '../utils' import { RunTimer } from './RunTimer' import { EMPTY_TIMESTAMP } from '../constants' import { getHighestPriorityError } from '../../OnDeviceDisplay/RunningProtocol' import { RunFailedModal } from './RunFailedModal' -import { DISENGAGED } from '../../EmergencyStop' import type { Run, RunError } from '@opentrons/api-client' import type { State } from '../../../redux/types' @@ -98,7 +92,6 @@ import type { HeaterShakerModule } from '../../../redux/modules/types' import { RunProgressMeter } from '../../RunProgressMeter' const EQUIPMENT_POLL_MS = 5000 -const ESTOP_POLL_MS = 5000 const CANCELLABLE_STATUSES = [ RUN_STATUS_RUNNING, RUN_STATUS_PAUSED, @@ -142,21 +135,7 @@ export function ProtocolRunHeader({ const highestPriorityError = runRecord?.data.errors?.[0] != null ? getHighestPriorityError(runRecord?.data?.errors) - : undefined - const { data: estopStatus, error: estopError } = useEstopQuery({ - refetchInterval: ESTOP_POLL_MS, - }) - const [ - showEmergencyStopRunBanner, - setShowEmergencyStopRunBanner, - ] = React.useState(false) - const isOT3 = useIsOT3(robotName) - - React.useEffect(() => { - if (estopStatus?.data.status !== DISENGAGED && estopError == null) { - setShowEmergencyStopRunBanner(true) - } - }, [estopStatus?.data.status]) + : null React.useEffect(() => { if (protocolData != null && !isRobotViewable) { @@ -290,14 +269,6 @@ export function ProtocolRunHeader({ }} /> ) : null} - {estopStatus?.data.status !== DISENGAGED && - estopError == null && - isOT3 && - showEmergencyStopRunBanner ? ( - - ) : null} void isClosingCurrentRun: boolean setShowRunFailedModal: (showRunFailedModal: boolean) => void - highestPriorityError?: RunError + highestPriorityError?: RunError | null } function TerminalRunBanner(props: TerminalRunProps): JSX.Element | null { const { @@ -699,23 +670,3 @@ function TerminalRunBanner(props: TerminalRunProps): JSX.Element | null { } return null } - -interface EmergencyStopRunPropsBanner { - setShowEmergencyStopRunBanner: (showEmergencyStopRunBanner: boolean) => void -} - -function EmergencyStopRunBanner({ - setShowEmergencyStopRunBanner, -}: EmergencyStopRunPropsBanner): JSX.Element { - const { t } = useTranslation('run_details') - return ( - setShowEmergencyStopRunBanner(false)} - > - - {t('run_failed')} - - - ) -} diff --git a/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx b/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx index ba7c7b19f81..e779e339092 100644 --- a/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx @@ -40,7 +40,7 @@ interface RunFailedModalProps { robotName: string runId: string setShowRunFailedModal: (showRunFailedModal: boolean) => void - highestPriorityError?: RunError + highestPriorityError?: RunError | null } export function RunFailedModal({ diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index b657b7889b2..e64ba577562 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -75,13 +75,7 @@ import { formatTimestamp } from '../../utils' import { ProtocolRunHeader } from '../ProtocolRunHeader' import { HeaterShakerIsRunningModal } from '../../HeaterShakerIsRunningModal' import { RunFailedModal } from '../RunFailedModal' -import { - DISENGAGED, - ENGAGED, - LOGICALLY_ENGAGED, - NOT_PRESENT, - PHYSICALLY_ENGAGED, -} from '../../../EmergencyStop' +import { DISENGAGED, NOT_PRESENT } from '../../../EmergencyStop' import type { UseQueryResult } from 'react-query' import type { Run } from '@opentrons/api-client' @@ -843,31 +837,4 @@ describe('ProtocolRunHeader', () => { getByText('Run completed.') getByLabelText('ot-spinner') }) - - it('renders banner when estop pressed - physicallyEngaged', () => { - mockEstopStatus.data.status = PHYSICALLY_ENGAGED - mockEstopStatus.data.leftEstopPhysicalStatus = ENGAGED - - mockUseEstopQuery({ data: mockEstopStatus } as any) - const [{ getByText }] = render() - getByText('Run failed.') - }) - - it('renders banner when estop pressed - logicallyEngaged', () => { - mockEstopStatus.data.status = LOGICALLY_ENGAGED - mockEstopStatus.data.leftEstopPhysicalStatus = ENGAGED - - mockUseEstopQuery({ data: mockEstopStatus } as any) - const [{ getByText }] = render() - getByText('Run failed.') - }) - - it('renders banner when estop pressed - notPresent', () => { - mockEstopStatus.data.status = NOT_PRESENT - mockEstopStatus.data.leftEstopPhysicalStatus = NOT_PRESENT - - mockUseEstopQuery({ data: mockEstopStatus } as any) - const [{ getByText }] = render() - getByText('Run failed.') - }) }) diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/ViewUpdateModal.test.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/ViewUpdateModal.test.tsx index 0c0825ed1db..2f7a7562ee0 100644 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/ViewUpdateModal.test.tsx +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/ViewUpdateModal.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { QueryClient, QueryClientProvider } from 'react-query' import { mountWithStore } from '@opentrons/components' +import { useIsRobotBusy } from '../../../hooks' import * as RobotUpdate from '../../../../../redux/robot-update' import { DownloadUpdateModal } from '../DownloadUpdateModal' @@ -11,6 +12,7 @@ import { ViewUpdateModal } from '../ViewUpdateModal' import type { State } from '../../../../../redux/types' jest.mock('../../../../../redux/robot-update') +jest.mock('../../../hooks') const getRobotUpdateInfo = RobotUpdate.getRobotUpdateInfo as jest.MockedFunction< typeof RobotUpdate.getRobotUpdateInfo @@ -22,6 +24,10 @@ const getRobotUpdateDownloadError = RobotUpdate.getRobotUpdateDownloadError as j typeof RobotUpdate.getRobotUpdateDownloadError > +const mockUseIsRobotBusy = useIsRobotBusy as jest.MockedFunction< + typeof useIsRobotBusy +> + const MOCK_STATE: State = { mockState: true } as any const MOCK_ROBOT_NAME = 'robot-name' const queryClient = new QueryClient() @@ -55,6 +61,7 @@ describe('ViewUpdateModal', () => { getRobotUpdateInfo.mockReturnValue(null) getRobotUpdateDownloadProgress.mockReturnValue(50) getRobotUpdateDownloadError.mockReturnValue(null) + mockUseIsRobotBusy.mockReturnValue(false) }) afterEach(() => { diff --git a/app/src/organisms/Devices/hooks/__tests__/useIsRobotBusy.test.ts b/app/src/organisms/Devices/hooks/__tests__/useIsRobotBusy.test.ts index 47b849d815d..458e9fc97a1 100644 --- a/app/src/organisms/Devices/hooks/__tests__/useIsRobotBusy.test.ts +++ b/app/src/organisms/Devices/hooks/__tests__/useIsRobotBusy.test.ts @@ -8,15 +8,16 @@ import { DISENGAGED, NOT_PRESENT, PHYSICALLY_ENGAGED, - ENGAGED, } from '../../../EmergencyStop' import { useIsRobotBusy } from '../useIsRobotBusy' +import { useIsOT3 } from '../useIsOT3' import type { Sessions, Runs } from '@opentrons/api-client' jest.mock('@opentrons/react-api-client') jest.mock('../../../ProtocolUpload/hooks') +jest.mock('../useIsOT3') const mockEstopStatus = { data: { @@ -35,6 +36,7 @@ const mockUseAllRunsQuery = useAllRunsQuery as jest.MockedFunction< const mockUseEstopQuery = useEstopQuery as jest.MockedFunction< typeof useEstopQuery > +const mockUseIsOT3 = useIsOT3 as jest.MockedFunction describe('useIsRobotBusy', () => { beforeEach(() => { @@ -49,6 +51,7 @@ describe('useIsRobotBusy', () => { }, } as UseQueryResult) mockUseEstopQuery.mockReturnValue({ data: mockEstopStatus } as any) + mockUseIsOT3.mockReturnValue(false) }) afterEach(() => { @@ -113,7 +116,8 @@ describe('useIsRobotBusy', () => { expect(result).toBe(false) }) - it('returns true when Estop status is not disengaged', () => { + it('returns true when robot is a Flex and Estop status is engaged', () => { + mockUseIsOT3.mockReturnValue(true) mockUseAllRunsQuery.mockReturnValue({ data: { links: { @@ -134,9 +138,41 @@ describe('useIsRobotBusy', () => { links: {}, } as unknown) as UseQueryResult) const mockEngagedStatus = { - ...mockEstopStatus, - status: PHYSICALLY_ENGAGED, - leftEstopPhysicalStatus: ENGAGED, + data: { + ...mockEstopStatus.data, + status: PHYSICALLY_ENGAGED, + }, + } + mockUseEstopQuery.mockReturnValue({ data: mockEngagedStatus } as any) + const result = useIsRobotBusy() + expect(result).toBe(true) + }) + it('returns false when robot is NOT a Flex and Estop status is engaged', () => { + mockUseIsOT3.mockReturnValue(false) + mockUseAllRunsQuery.mockReturnValue({ + data: { + links: { + current: null, + }, + }, + } as any) + mockUseAllSessionsQuery.mockReturnValue(({ + data: [ + { + id: 'test', + createdAt: '2019-08-24T14:15:22Z', + details: {}, + sessionType: 'calibrationCheck', + createParams: {}, + }, + ], + links: {}, + } as unknown) as UseQueryResult) + const mockEngagedStatus = { + data: { + ...mockEstopStatus.data, + status: PHYSICALLY_ENGAGED, + }, } mockUseEstopQuery.mockReturnValue({ data: mockEngagedStatus } as any) const result = useIsRobotBusy() diff --git a/app/src/organisms/Devices/hooks/useIsRobotBusy.ts b/app/src/organisms/Devices/hooks/useIsRobotBusy.ts index db3f675aeee..7271e21f3f6 100644 --- a/app/src/organisms/Devices/hooks/useIsRobotBusy.ts +++ b/app/src/organisms/Devices/hooks/useIsRobotBusy.ts @@ -2,8 +2,10 @@ import { useAllSessionsQuery, useAllRunsQuery, useEstopQuery, + useHost, } from '@opentrons/react-api-client' import { DISENGAGED } from '../../EmergencyStop' +import { useIsOT3 } from './useIsOT3' const ROBOT_STATUS_POLL_MS = 30000 @@ -18,12 +20,18 @@ export function useIsRobotBusy( const robotHasCurrentRun = useAllRunsQuery({}, queryOptions)?.data?.links?.current != null const allSessionsQueryResponse = useAllSessionsQuery(queryOptions) - const { data: estopStatus, error: estopError } = useEstopQuery(queryOptions) + const host = useHost() + const robotName = host?.robotName + const isOT3 = useIsOT3(robotName ?? '') + const { data: estopStatus, error: estopError } = useEstopQuery({ + ...queryOptions, + enabled: isOT3, + }) return ( robotHasCurrentRun || (allSessionsQueryResponse?.data?.data != null && allSessionsQueryResponse?.data?.data?.length !== 0) || - (estopStatus?.data.status !== DISENGAGED && estopError == null) + (isOT3 && estopStatus?.data.status !== DISENGAGED && estopError == null) ) } diff --git a/app/src/pages/Devices/DeviceDetails/DeviceDetailsComponent.tsx b/app/src/pages/Devices/DeviceDetails/DeviceDetailsComponent.tsx index 7f435eb1ae6..a500ca8bd5e 100644 --- a/app/src/pages/Devices/DeviceDetails/DeviceDetailsComponent.tsx +++ b/app/src/pages/Devices/DeviceDetails/DeviceDetailsComponent.tsx @@ -23,9 +23,11 @@ interface DeviceDetailsComponentProps { export function DeviceDetailsComponent({ robotName, }: DeviceDetailsComponentProps): JSX.Element { - const { data: estopStatus, error: estopError } = useEstopQuery() - const { isEmergencyStopModalDismissed } = useEstopContext() const isOT3 = useIsOT3(robotName) + const { data: estopStatus, error: estopError } = useEstopQuery({ + enabled: isOT3, + }) + const { isEmergencyStopModalDismissed } = useEstopContext() return ( - {estopStatus?.data.status !== DISENGAGED && + {isOT3 && + estopStatus?.data.status !== DISENGAGED && estopError == null && - isOT3 && isEmergencyStopModalDismissed ? ( From 121841fc27f8b852578e8a15bdea359154905810 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Wed, 6 Sep 2023 14:21:10 -0400 Subject: [PATCH 08/13] fix(shared-data): add type ignore in test (#13476) This is testing a splat that loses types and just doesn't really work with type annotations sadly. Removing the types fixes the issue. --- shared-data/python/tests/pipette/test_mutable_configurations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared-data/python/tests/pipette/test_mutable_configurations.py b/shared-data/python/tests/pipette/test_mutable_configurations.py index ef4ad54740c..e70520fb05f 100644 --- a/shared-data/python/tests/pipette/test_mutable_configurations.py +++ b/shared-data/python/tests/pipette/test_mutable_configurations.py @@ -262,5 +262,5 @@ def test_build_mutable_config_using_old_units() -> None: "max": 30, } assert ( - types.MutableConfig.build(**old_units_config, name="dropTipSpeed") is not None + types.MutableConfig.build(**old_units_config, name="dropTipSpeed") is not None # type: ignore ) From cc32b4a921e575ee2bfc7773a54a011ec5e324e3 Mon Sep 17 00:00:00 2001 From: Brian Arthur Cooper Date: Wed, 6 Sep 2023 16:16:29 -0400 Subject: [PATCH 09/13] fix(app): remove hover from empty state in slideouts (#13433) Remove border color change on hover from the empty state message of the choose robot/protocol slideouts in the desktop app Closes RQA-1247 --- app/src/organisms/ChooseProtocolSlideout/index.tsx | 7 ++++++- app/src/organisms/ChooseRobotSlideout/index.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/organisms/ChooseProtocolSlideout/index.tsx b/app/src/organisms/ChooseProtocolSlideout/index.tsx index dd39fcc664a..c2653596cc9 100644 --- a/app/src/organisms/ChooseProtocolSlideout/index.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/index.tsx @@ -290,7 +290,12 @@ function StoredProtocolList(props: StoredProtocolListProps): JSX.Element { width="100%" minHeight="11rem" padding={SPACING.spacing16} - css={BORDERS.cardOutlineBorder} + css={css` + ${BORDERS.cardOutlineBorder} + &:hover { + border-color: ${COLORS.medGreyEnabled}; + } + `} > {!isScanning && healthyReachableRobots.length === 0 ? ( Date: Wed, 6 Sep 2023 17:47:07 -0400 Subject: [PATCH 10/13] feat(app): acknowledge animation when Flex receives a new protocol (#13474) * Add schemaV7 definition for StatusBarAnimation control * When ODD detects a new protocol, run status bar Confirm animation * Update app/src/App/hooks.ts typo Co-authored-by: Jethary Rader <66035149+jerader@users.noreply.github.com> --------- Co-authored-by: Jethary Rader <66035149+jerader@users.noreply.github.com> --- app/src/App/hooks.ts | 14 +++++++++++++ shared-data/js/types.ts | 9 ++++++++ .../types/schemaV7/command/incidental.ts | 21 +++++++++++++++++++ .../protocol/types/schemaV7/command/index.ts | 6 ++++++ 4 files changed, 50 insertions(+) create mode 100644 shared-data/protocol/types/schemaV7/command/incidental.ts diff --git a/app/src/App/hooks.ts b/app/src/App/hooks.ts index 3b54951798e..4ac5e2441a5 100644 --- a/app/src/App/hooks.ts +++ b/app/src/App/hooks.ts @@ -9,6 +9,7 @@ import { useAllRunsQuery, useHost, useRunQuery, + useCreateLiveCommandMutation, } from '@opentrons/react-api-client' import { getProtocol, @@ -21,6 +22,7 @@ import { } from '@opentrons/api-client' import { checkShellUpdate } from '../redux/shell' import { useToaster } from '../organisms/ToasterOven' +import type { SetStatusBarCreateCommand } from '@opentrons/shared-data/protocol/types/schemaV7/command/incidental' import type { Dispatch } from '../redux/types' @@ -50,6 +52,11 @@ export function useProtocolReceiptToast(): void { const protocolIds = protocolIdsQuery.data?.data ?? [] const protocolIdsRef = React.useRef(protocolIds) const hasRefetched = React.useRef(true) + const { createLiveCommand } = useCreateLiveCommandMutation() + const animationCommand: SetStatusBarCreateCommand = { + commandType: 'setStatusBar', + params: { animation: 'confirm' }, + } if (protocolIdsQuery.isRefetching) { hasRefetched.current = false @@ -98,6 +105,13 @@ export function useProtocolReceiptToast(): void { console.error(`error invalidating protocols query: ${e.message}`) ) }) + .then(() => { + createLiveCommand({ + command: animationCommand, + }).catch((e: Error) => + console.warn(`cannot run status bar animation: ${e.message}`) + ) + }) .catch((e: Error) => { console.error(e) }) diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index e887f600a3a..39ffc596d05 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -510,3 +510,12 @@ export interface GripperDefinition { jawWidth: { min: number; max: number } } } + +export type StatusBarAnimation = + | 'idle' + | 'confirm' + | 'updating' + | 'disco' + | 'off' + +export type StatusBarAnimations = StatusBarAnimation[] diff --git a/shared-data/protocol/types/schemaV7/command/incidental.ts b/shared-data/protocol/types/schemaV7/command/incidental.ts new file mode 100644 index 00000000000..aa3c6857d9b --- /dev/null +++ b/shared-data/protocol/types/schemaV7/command/incidental.ts @@ -0,0 +1,21 @@ +import type { CommonCommandRunTimeInfo, CommonCommandCreateInfo } from '.' +import type { StatusBarAnimation } from '../../../../js/types' + +export type IncidentalCreateCommand = SetStatusBarCreateCommand + +export type IncidentalRunTimeCommand = SetStatusBarRunTimeCommand + +export interface SetStatusBarCreateCommand extends CommonCommandCreateInfo { + commandType: 'setStatusBar' + params: SetStatusBarParams +} + +export interface SetStatusBarRunTimeCommand + extends CommonCommandRunTimeInfo, + SetStatusBarCreateCommand { + result?: any +} + +interface SetStatusBarParams { + animation: StatusBarAnimation +} diff --git a/shared-data/protocol/types/schemaV7/command/index.ts b/shared-data/protocol/types/schemaV7/command/index.ts index a9dec62350f..ae4c39ad209 100644 --- a/shared-data/protocol/types/schemaV7/command/index.ts +++ b/shared-data/protocol/types/schemaV7/command/index.ts @@ -6,6 +6,10 @@ import type { GantryRunTimeCommand, GantryCreateCommand } from './gantry' import type { ModuleRunTimeCommand, ModuleCreateCommand } from './module' import type { SetupRunTimeCommand, SetupCreateCommand } from './setup' import type { TimingRunTimeCommand, TimingCreateCommand } from './timing' +import type { + IncidentalCreateCommand, + IncidentalRunTimeCommand, +} from './incidental' import type { AnnotationRunTimeCommand, AnnotationCreateCommand, @@ -51,6 +55,7 @@ export type CreateCommand = | TimingCreateCommand // effecting the timing of command execution | CalibrationCreateCommand // for automatic pipette calibration | AnnotationCreateCommand // annotating command execution + | IncidentalCreateCommand // command with only incidental effects (status bar animations) // commands will be required to have a key, but will not be created with one export type RunTimeCommand = @@ -61,6 +66,7 @@ export type RunTimeCommand = | TimingRunTimeCommand // effecting the timing of command execution | CalibrationRunTimeCommand // for automatic pipette calibration | AnnotationRunTimeCommand // annotating command execution + | IncidentalRunTimeCommand // command with only incidental effects (status bar animations) interface RunCommandError { id: string From bd7f541d586de0a3d571bbbce6bee2d29baeb4c7 Mon Sep 17 00:00:00 2001 From: Jeremy Leon Date: Thu, 7 Sep 2023 09:46:12 -0400 Subject: [PATCH 11/13] fix(engine): fix labware gripperOffsets not allowing only a default offset (#13472) --- .../protocol_engine/state/labware.py | 10 ++--- .../state/test_labware_view.py | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index 80d2c2b6725..80d8a3b6b09 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -751,8 +751,11 @@ def get_labware_gripper_offsets( """Get the labware's gripper offsets of the specified type.""" parsed_offsets = self.get_definition(labware_id).gripperOffsets offset_key = slot_name.name if slot_name else "default" - return ( - LabwareMovementOffsetData( + + if parsed_offsets is None or offset_key not in parsed_offsets: + return None + else: + return LabwareMovementOffsetData( pickUpOffset=cast( LabwareOffsetVector, parsed_offsets[offset_key].pickUpOffset ), @@ -760,9 +763,6 @@ def get_labware_gripper_offsets( LabwareOffsetVector, parsed_offsets[offset_key].dropOffset ), ) - if parsed_offsets - else None - ) def get_grip_force(self, labware_id: str) -> float: """Get the recommended grip force for gripping labware using gripper.""" diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_view.py b/api/tests/opentrons/protocol_engine/state/test_labware_view.py index 535c2c68694..7d277d93b5a 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_view.py @@ -10,6 +10,8 @@ Parameters, LabwareRole, OverlapOffset as SharedDataOverlapOffset, + GripperOffsets, + OffsetVector, ) from opentrons.protocols.models import LabwareDefinition from opentrons.types import DeckSlotName, Point, MountType @@ -1346,6 +1348,49 @@ def test_get_labware_gripper_offsets( ) +def test_get_labware_gripper_offsets_default_no_slots( + well_plate_def: LabwareDefinition, + adapter_plate_def: LabwareDefinition, +) -> None: + """It should get the labware's gripper offsets with only a default gripper offset entry.""" + subject = get_labware_view( + labware_by_id={ + "labware-id": LoadedLabware( + id="labware-id", + loadName="labware-load-name", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1), + definitionUri="some-labware-uri", + offsetId=None, + displayName="Fancy Labware Name", + ) + }, + definitions_by_uri={ + "some-labware-uri": LabwareDefinition.construct( # type: ignore[call-arg] + gripperOffsets={ + "default": GripperOffsets( + pickUpOffset=OffsetVector(x=1, y=2, z=3), + dropOffset=OffsetVector(x=4, y=5, z=6), + ) + } + ), + }, + ) + + assert ( + subject.get_labware_gripper_offsets( + labware_id="labware-id", slot_name=DeckSlotName.SLOT_D1 + ) + is None + ) + + assert subject.get_labware_gripper_offsets( + labware_id="labware-id", slot_name=None + ) == LabwareMovementOffsetData( + pickUpOffset=LabwareOffsetVector(x=1, y=2, z=3), + dropOffset=LabwareOffsetVector(x=4, y=5, z=6), + ) + + def test_get_grip_force( flex_50uL_tiprack: LabwareDefinition, reservoir_def: LabwareDefinition, From f8a29c63bbc15ce435b751306af8b1c604552ec8 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Thu, 7 Sep 2023 09:54:08 -0400 Subject: [PATCH 12/13] feat(app): create module instruction modal and delete H-S wizard (#13477) closes RQA-1299 --- .../assets/images/heater_shaker-key-parts.png | Bin 79558 -> 0 bytes .../heater_shaker_adapter_alignment.png | Bin 1373 -> 0 bytes .../heater_shaker_adapter_screwdriver.png | Bin 6244 -> 0 bytes app/src/assets/images/heater_shaker_empty.png | Bin 3484 -> 0 bytes .../images/heater_shaker_module_diagram.png | Bin 23489 -> 0 bytes .../assets/images/module_instruction_code.png | Bin 0 -> 779 bytes .../assets/localization/en/heater_shaker.json | 60 +-- .../localization/en/protocol_setup.json | 3 + .../HeaterShakerWizard/AttachAdapter.tsx | 168 -------- .../HeaterShakerWizard/AttachModule.tsx | 237 ---------- .../HeaterShakerWizard/Introduction.tsx | 227 ---------- .../Devices/HeaterShakerWizard/KeyParts.tsx | 101 ----- .../Devices/HeaterShakerWizard/PowerOn.tsx | 73 ---- .../Devices/HeaterShakerWizard/TestShake.tsx | 249 ----------- .../__tests__/AttachAdapter.test.tsx | 107 ----- .../__tests__/AttachModule.test.tsx | 87 ---- .../__tests__/HeaterShakerWizard.test.tsx | 167 ------- .../__tests__/Introduction.test.tsx | 90 ---- .../__tests__/KeyParts.test.tsx | 45 -- .../__tests__/PowerOn.test.tsx | 51 --- .../__tests__/TestShake.test.tsx | 408 ------------------ .../Devices/HeaterShakerWizard/index.tsx | 155 ------- .../SetupModules/SetupModulesList.tsx | 24 +- .../__tests__/SetupModulesList.test.tsx | 14 +- .../organisms/ModuleCard/ModuleSetupModal.tsx | 74 ++++ .../ModuleCard/TestShakeSlideout.tsx | 17 +- .../__tests__/ModuleSetupModal.test.tsx | 44 ++ .../__tests__/TestShakeSlideout.test.tsx | 12 +- app/src/organisms/ModuleCard/index.tsx | 8 +- 29 files changed, 158 insertions(+), 2263 deletions(-) delete mode 100644 app/src/assets/images/heater_shaker-key-parts.png delete mode 100644 app/src/assets/images/heater_shaker_adapter_alignment.png delete mode 100644 app/src/assets/images/heater_shaker_adapter_screwdriver.png delete mode 100644 app/src/assets/images/heater_shaker_empty.png delete mode 100644 app/src/assets/images/heater_shaker_module_diagram.png create mode 100644 app/src/assets/images/module_instruction_code.png delete mode 100644 app/src/organisms/Devices/HeaterShakerWizard/AttachAdapter.tsx delete mode 100644 app/src/organisms/Devices/HeaterShakerWizard/AttachModule.tsx delete mode 100644 app/src/organisms/Devices/HeaterShakerWizard/Introduction.tsx delete mode 100644 app/src/organisms/Devices/HeaterShakerWizard/KeyParts.tsx delete mode 100644 app/src/organisms/Devices/HeaterShakerWizard/PowerOn.tsx delete mode 100644 app/src/organisms/Devices/HeaterShakerWizard/TestShake.tsx delete mode 100644 app/src/organisms/Devices/HeaterShakerWizard/__tests__/AttachAdapter.test.tsx delete mode 100644 app/src/organisms/Devices/HeaterShakerWizard/__tests__/AttachModule.test.tsx delete mode 100644 app/src/organisms/Devices/HeaterShakerWizard/__tests__/HeaterShakerWizard.test.tsx delete mode 100644 app/src/organisms/Devices/HeaterShakerWizard/__tests__/Introduction.test.tsx delete mode 100644 app/src/organisms/Devices/HeaterShakerWizard/__tests__/KeyParts.test.tsx delete mode 100644 app/src/organisms/Devices/HeaterShakerWizard/__tests__/PowerOn.test.tsx delete mode 100644 app/src/organisms/Devices/HeaterShakerWizard/__tests__/TestShake.test.tsx delete mode 100644 app/src/organisms/Devices/HeaterShakerWizard/index.tsx create mode 100644 app/src/organisms/ModuleCard/ModuleSetupModal.tsx create mode 100644 app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx diff --git a/app/src/assets/images/heater_shaker-key-parts.png b/app/src/assets/images/heater_shaker-key-parts.png deleted file mode 100644 index 06b7f9ced1cdb5128705fba984d1c9f8601ae85f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 79558 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 diff --git a/app/src/assets/images/heater_shaker_adapter_alignment.png b/app/src/assets/images/heater_shaker_adapter_alignment.png deleted file mode 100644 index 1d5e8dc13987f4d47b57717eeba6681ca4a35248..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1373 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
    syWY4NLo^e#Smm!A^k?N}XawVga zRH1+e->u1*AUt4Wq^)Hf?~Nw-Y>cBFF{lrCZi2^)>p{cs0qeKBVvL_M_=ev9MuPIK0FRH};NC)l3@#4b)pkGonnvF(T zMi6wP97+trbhDu#=Fuc)RSP|CNf7<==ic7l$$nqBb5|DO3o{(_0jMT;M6brq#XMOL z-uLUU}e~DuQ@BiL%nU*}He_*Jy@iqI5j@#Ye zm|t110b^gw@XfVfVWK=wJMhjmco-*}JP7P#8vWf!Sv3)W)n@}Ai+Bw4z!y6x?%odc z@yzuB2jZ4X0{4gI|hyOM}jnBXvZq&gf9qKE>+hY&B2%i8tLL6=R~ zaa1fG&h_=>AJBBw8c^(#eG(I=wJck~!am95WUaa>E@Jn9759K}4VV|fm_8jms zstqp8n1^E^xpeX9EYh9t10lS=dA1OE`?DX{>^3zE-$49u`pV_}X@6s7eH&`r>D-w7 z9Osx|(CcL+pcy#gQ$#JOqI*E}uLsd|8Pf+Zj2Jt(dw)OBNAg@@-Q-UD?WN?P(GrK# zlfOFryg1t8+x%f+WgU$=@2c6q@GH@vHx$$i8__|1w#38wmJ+s{^30u5 zd$YAG!_h#onPf8g?p@BddvqGPmd_@URI>-nL(Ww zGI=CYx_Eq9=m(S)VdJ_WNFbEi+~CQ(cPCll?)~oB#qHMIP(NOd>%&tAXCIuCi^mMh zGMrf+*t{%bgZ9vLVRO)rd}8eG=>2_HpASsvfu#9)!j9JtSrcTBpHTzJ!DLcp9O>fw z%j=J5Ir`9YOm(h#Rr}feDhO$$10D=lWf@*o6FIbYVec~KaU31gUralFrS$ob62E`0 zu+tIuaB!H?*~DP-B%pdZIeuK+y}UXsEOxc@!E)?O^Fh0fF7hlZa0+Z)2y08)GMSv$ zX7lpEL-msQn~LenLOWpoAkP=}b26+#krv>vf(`@OsK!wa2~^GqPT(_+P=gTpD`*VFhsVRK#++;Eo30v*Z#anQ!mtSEWX z2gTy$)&1)DRR3sDg=0m{sZloL)$pHWFp{#h*u=l~eo7tIZD-=Eczi9B;OuF!*jzkOdgEdo6uby!`QyX9O_vaI6@A z2`a1mNCeOE!O_)XH(~VShyH%}03P76df|c$tI$NksChx5vq@Ew1*p+ce*{|wZuO9n z@j~FrXYfe;>4W;qLJLr^2D`o%us&C~BI`OMl*kG)rPu;qiW6X0$Bv5cZE#=8GJ-~X zy=ZTg`{3L?Dt;V&sA$DO@8Q)9F+NjRmy$7$#4j+4n-a5}htgow)XcFR|BaX|9(1&f zM|