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, 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/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/app/src/assets/images/heater_shaker-key-parts.png b/app/src/assets/images/heater_shaker-key-parts.png deleted file mode 100644 index 06b7f9ced1c..00000000000 Binary files a/app/src/assets/images/heater_shaker-key-parts.png and /dev/null differ 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 1d5e8dc1398..00000000000 Binary files a/app/src/assets/images/heater_shaker_adapter_alignment.png and /dev/null differ diff --git a/app/src/assets/images/heater_shaker_adapter_screwdriver.png b/app/src/assets/images/heater_shaker_adapter_screwdriver.png deleted file mode 100644 index 948988f6a9d..00000000000 Binary files a/app/src/assets/images/heater_shaker_adapter_screwdriver.png and /dev/null differ diff --git a/app/src/assets/images/heater_shaker_empty.png b/app/src/assets/images/heater_shaker_empty.png deleted file mode 100644 index 9540b6d75f5..00000000000 Binary files a/app/src/assets/images/heater_shaker_empty.png and /dev/null differ diff --git a/app/src/assets/images/heater_shaker_module_diagram.png b/app/src/assets/images/heater_shaker_module_diagram.png deleted file mode 100644 index 9ba8b76fa11..00000000000 Binary files a/app/src/assets/images/heater_shaker_module_diagram.png and /dev/null differ diff --git a/app/src/assets/images/module_instruction_code.png b/app/src/assets/images/module_instruction_code.png new file mode 100644 index 00000000000..95e83abea0e Binary files /dev/null and b/app/src/assets/images/module_instruction_code.png differ 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/assets/localization/en/heater_shaker.json b/app/src/assets/localization/en/heater_shaker.json index 7c1aed4b300..532f7ee125d 100644 --- a/app/src/assets/localization/en/heater_shaker.json +++ b/app/src/assets/localization/en/heater_shaker.json @@ -1,32 +1,7 @@ { - "1a": "1a", - "1b": "1b", - "1c": "1c", - "3a": "3a", - "3b": "3b", - "3c": "3c", - "a_properly_attached_adapter": "A properly attached adapter will sit evenly on the module.", - "about_screwdriver": "Provided with module. Note: using another screwdriver size can strip the module’s screws.", - "adapter_name_and_screw": "{{adapter}} + Screw", - "attach_adapter_to_module": "Attach your adapter to the module.", - "attach_heater_shaker_to_deck": "Attach {{name}} to deck before proceeding to run", - "attach_module_anchor_not_extended": "Before placing the module on the deck, make sure the anchors are not extended and are level with the module’s base.", - "attach_module_check_attachment": "Check attachment by gently pulling up and rocking the module.", - "attach_module_extend_anchors": "Hold the module flat against the deck and turn screws clockwise to extend the anchors.", - "attach_module_turn_screws": "Turn screws counterclockwise to retract the anchors. The screws should not come out of the module.", - "attach_screwdriver_and_screw_explanation": "Using a different screwdriver can strip the screws. Using a different screw than the one provided can damage the module", - "attach_screwdriver_and_screw": "Please use T10 Torx Screwdriver and provided screw ", - "attach_to_deck_to_prevent_shaking": "Attachment prevents the module from shaking out of a deck slot.", "back": "Back", - "btn_begin_attachment": "Begin attachment", - "btn_continue_attachment_guide": "Continue to attachment guide", - "btn_power_module": "Continue to power on module", - "btn_test_shake": "Continue to test shake", - "btn_thermal_adapter": "Continue to attach thermal adapter", "cannot_open_latch": "Cannot open labware latch while module is shaking.", "cannot_shake": "Cannot shake when labware latch is open", - "check_alignment_instructions": "Check attachment by rocking the adapter back and forth.", - "check_alignment": "Check alignment.", "close_labware_latch": "Close Labware Latch", "close_latch": "Close latch", "closed_and_locked": "Closed and Locked", @@ -39,53 +14,24 @@ "deactivate_heater": "Deactivate heater", "deactivate_shaker": "Deactivate shaker", "deactivate": "Deactivate", - "go_to_step_1": "Go to Step 1", - "go_to_step_3": "Go to Step 3", - "heater_shaker_anchor_description": "The 2 Anchors keep the module attached to the deck while it is shaking. To extend and retract each anchor, turn the screw above it. See animation below. Extending the anchors increases the module’s footprint, which more firmly attaches it to the slot.", "heater_shaker_in_slot": "Attach {{moduleName}} in Slot {{slotName}} before proceeding", "heater_shaker_is_shaking": "Heater-Shaker Module is currently shaking", - "heater_shaker_key_parts": "Key Heater-Shaker parts and terminology", - "heater_shaker_latch_description": "The Labware Latch keeps labware secure while the module is shaking. It can be opened or closed manually and with software but is closed and locked while the module is shaking.", - "heater_shaker_orient_module": "Orient the module so its power ports face away from you.", - "heater_shaker_setup_description": "{{name}} - Attach Heater-Shaker Module", - "how_to_attach_module": "See how to attach module to the deck", - "how_to_attach_to_deck": "See how to attach to deck", - "improperly_fastened_description": "An improperly fastened Heater-Shaker module can shake itself out of a deck slot.", - "intro_adapter_body": "Screw may already be in the center of the module.", - "intro_adapter_known": "{{adapter}} + Screw", - "intro_adapter_unknown": "Thermal Adapter + Screw", - "intro_heater_shaker_mod": "Heater-Shaker Module", - "intro_labware": "Labware", - "intro_screwdriver_body": "Provided with module. Note: using another screwdriver size can strip the module’s screws.", - "intro_screwdriver": "T10 Torx Screwdriver", - "intro_subtitle": "You will need:", - "intro_title": "Use this guide to attach the Heater-Shaker Module to your robot’s deck for secure shaking.", "keep_shaking_start_run": "Keep shaking and start run", "labware_latch": "Labware Latch", "labware": "Labware", "min_max_rpm": "{{min}} - {{max}} rpm", "module_anchors_extended": "Before the run begins, module should have both anchors fully extended for a firm attachment to the deck.", "module_in_slot": "{{moduleName}} in Slot {{slotName}}", - "module_is_not_connected": "Module is not connected", "module_should_have_anchors": "Module should have both anchors fully extended for a firm attachment to the deck.", "open_labware_latch": "Open Labware Latch", "open_latch": "Open latch", "open": "Open", "opening": "Opening...", - "orient_heater_shaker_module": "Orient your module such that the power and USB ports are facing outward.", - "place_the_module_slot_number": "Place the module in Slot {{slot}}.", - "place_the_module_slot": "Place the module in a Slot.", "proceed_to_run": "Proceed to run", - "screw_may_be_in_module": "Screw may already be in the center of the module.", "set_shake_speed": "Set shake speed", "set_temperature": "Set module temperature", "shake_speed": "Shake speed", "show_attachment_instructions": "Show attachment instructions", - "start_shaking": "Start Shaking", - "step_1_of_4_attach_module": "Step 1 of 4: Attach module to deck", - "step_2_power_on": "Step 2 of 4: Power on the moduleConnect your module to the robot and and power it on.", - "step_3_of_4_attach_adapter": "Step 3 of 4: Attach Thermal Adapter", - "step_4_of_4": "Step 4 of 4: Test shake", "stop_shaking_start_run": "Stop shaking and start run", "stop_shaking": "Stop Shaking", "t10_torx_screwdriver": "{{name}} Screwdriver", @@ -97,9 +43,5 @@ "thermal_adapter_attached_to_module": "The thermal adapter should be attached to the module.", "troubleshoot_step_1": "Return to Step 1 to see instructions for securing the module to the deck.", "troubleshoot_step_3": "Return to Step 3 to see instructions for securing the thermal adapter to the module.", - "troubleshooting": "Troubleshooting", - "unknown_adapter_and_screw": "Thermal Adapter + Screw", - "use_this_heater_shaker_guide": "Use this guide to attach the Heater-Shaker Module to your robot’s deck for secure shaking.", - "view_instructions": "View instructions", - "you_will_need": "You will need:" + "troubleshooting": "Troubleshooting" } 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/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index 89c8e12e5e0..ec5569be7e2 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -102,8 +102,11 @@ "magnetic_module_extra_attention": "Opentrons recommends securing labware with the module’s bracket", "map_view": "Map View", "missing": "Missing", + "modal_instructions_title": "{{moduleName}} Setup Instructions", + "modal_instructions": "For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box. You can also click the link below or scan the QR code to visit the modules section of the Opentrons Help Center.", "module_connected": "Connected", "module_disconnected": "Disconnected", + "module_instructions_link": "{{moduleName}} setup instructions", "module_mismatch_body": "Check that the modules connected to this robot are of the right type and generation", "module_name": "Module Name", "module_not_connected": "Not connected", 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/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/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 ? ( - {t('step_3_of_4_attach_adapter')} - - - {t('3a')} - - - - screw_in_adapter - - - - {t('attach_adapter_to_module')} - - - - - - - - {t('attach_screwdriver_and_screw')} - - - {t('attach_screwdriver_and_screw_explanation')} - - - - - {isLatchClosed - ? t('open_labware_latch') - : t('close_labware_latch')} - - {isShaking ? ( - - {t('cannot_open_latch')} - - ) : null} - - - - - - {t('3b')} - - - - heater_shaker_adapter_alignment - - - {t('check_alignment')} - - {t('a_properly_attached_adapter')} - - - - - - - {t('3c')} - - - {t('check_alignment_instructions')} - - - - ) -} diff --git a/app/src/organisms/Devices/HeaterShakerWizard/AttachModule.tsx b/app/src/organisms/Devices/HeaterShakerWizard/AttachModule.tsx deleted file mode 100644 index 3c1c2dd2f97..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/AttachModule.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import React from 'react' -import { Trans, useTranslation } from 'react-i18next' -import { - COLORS, - Flex, - DIRECTION_COLUMN, - DIRECTION_ROW, - Icon, - TYPOGRAPHY, - SPACING, - Box, - RobotWorkSpace, - Module, -} from '@opentrons/components' -import { - getModuleDef2, - inferModuleOrientationFromXCoordinate, -} from '@opentrons/shared-data' -import { StyledText } from '../../../atoms/text' -import attachHeaterShakerModule from '../../../assets/images/heater_shaker_module_diagram.png' -import standardDeckDef from '@opentrons/shared-data/deck/definitions/3/ot2_standard.json' -import screwdriverOrientedLeft from '../../../assets/images/screwdriver_oriented_left.png' -import { ProtocolModuleInfo } from '../../Devices/ProtocolRun/utils/getProtocolModulesInfo' -interface AttachModuleProps { - moduleFromProtocol?: ProtocolModuleInfo -} - -export function AttachModule(props: AttachModuleProps): JSX.Element { - const { moduleFromProtocol } = props - const { t } = useTranslation('heater_shaker') - - const moduleDef = getModuleDef2('heaterShakerModuleV1') - const DECK_MAP_VIEWBOX = '-80 -20 550 460' - const DECK_LAYER_BLOCKLIST = [ - 'calibrationMarkings', - 'fixedBase', - 'doorStops', - 'metalFrame', - 'removalHandle', - 'removableDeckOutline', - 'screwHoles', - ] - - // ToDo kj 10/10/2022 the current hardcoded sizes will be removed - // when we make this wizard responsible - return ( - - - {t('step_1_of_4_attach_module')} - - - - Attach Module to Deck - - screwdriver_1a - - - - , - block: ( - - ), - }} - /> - , - block: ( - - ), - icon: , - }} - /> - - - - - - - {moduleFromProtocol != null ? ( - - {() => ( - - - - )} - - ) : ( - - )} - - - screwdriver_1b - - - , - block: ( - - ), - }} - /> - , - block: ( - - ), - }} - /> - , - block: ( - - ), - icon: , - }} - /> - - - - - - {t('attach_module_check_attachment')} - - - - ) -} - -interface AttachedModuleItemProps { - step: string - children?: React.ReactNode -} - -function AttachedModuleItem(props: AttachedModuleItemProps): JSX.Element { - const { step } = props - return ( - - - {step} - - - {props.children} - - - ) -} diff --git a/app/src/organisms/Devices/HeaterShakerWizard/Introduction.tsx b/app/src/organisms/Devices/HeaterShakerWizard/Introduction.tsx deleted file mode 100644 index 79bd302324e..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/Introduction.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import React from 'react' -import { useTranslation } from 'react-i18next' -import { css } from 'styled-components' -import { - Flex, - DIRECTION_COLUMN, - TYPOGRAPHY, - COLORS, - JUSTIFY_CENTER, - DIRECTION_ROW, - LabwareRender, - SPACING, - ALIGN_CENTER, - BORDERS, - RobotWorkSpace, -} from '@opentrons/components' -import { getModuleDisplayName } from '@opentrons/shared-data' - -import heaterShaker from '../../../assets/images/heater_shaker_empty.png' -import flatBottom from '../../../assets/images/flatbottom_thermal_adapter.png' -import deepwell from '../../../assets/images/deepwell_thermal_adapter.png' -import pcr from '../../../assets/images/pcr_thermal_adapter.png' -import universal from '../../../assets/images/universal_thermal_adapter.png' -import screwdriver from '../../../assets/images/t10_torx_screwdriver.png' -import { StyledText } from '../../../atoms/text' - -import type { - LabwareDefinition2, - ModuleModel, - ThermalAdapterName, -} from '@opentrons/shared-data' - -const VIEW_BOX = '-20 -10 160 100' - -interface IntroContainerProps { - text: string - image?: JSX.Element - subtext?: string -} - -const IntroItem = (props: IntroContainerProps): JSX.Element => { - let multiText: JSX.Element =
- const leftPadding = props.image != null ? SPACING.spacing24 : SPACING.spacing8 - - if (props.subtext != null) { - multiText = ( - - - {props.text} - - - {props.subtext} - - - ) - } else { - multiText = ( - - {props.text} - - ) - } - return ( - - {props.image != null ? ( - <> - - {props.image} - - {multiText} - - ) : ( - {multiText} - )} - - ) -} -interface IntroductionProps { - labwareDefinition: LabwareDefinition2 | null - thermalAdapterName: ThermalAdapterName | null - moduleModel: ModuleModel -} - -const THERMAL_ADAPTER_TRANSFORM = css` - transform: scale(1.4); - transform-origin: 90% 50%; -` - -export function Introduction(props: IntroductionProps): JSX.Element { - const { labwareDefinition, thermalAdapterName } = props - const { t } = useTranslation('heater_shaker') - - let adapterImage: string = '' - switch (thermalAdapterName) { - case 'PCR Adapter': - adapterImage = pcr - break - case 'Universal Flat Adapter': - adapterImage = universal - break - case 'Deep Well Adapter': - adapterImage = deepwell - break - case '96 Flat Bottom Adapter': - adapterImage = flatBottom - break - } - - return ( - - - {t('use_this_heater_shaker_guide')} - - - - - {t('you_will_need')} - - - - - {`${String(thermalAdapterName)}`} - - ) : undefined - } - /> - - - - - {() => { - return ( - - - - ) - }} - - - ) : undefined - } - /> - - - } - text={getModuleDisplayName(props.moduleModel)} - /> - - - } - text={t('t10_torx_screwdriver', { name: 'T10 Torx' })} - subtext={t('about_screwdriver')} - /> - - - - ) -} diff --git a/app/src/organisms/Devices/HeaterShakerWizard/KeyParts.tsx b/app/src/organisms/Devices/HeaterShakerWizard/KeyParts.tsx deleted file mode 100644 index 295448710b3..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/KeyParts.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React from 'react' -import { Trans, useTranslation } from 'react-i18next' -import { css } from 'styled-components' -import { - ALIGN_FLEX_START, - DIRECTION_COLUMN, - DIRECTION_ROW, - Flex, - JUSTIFY_SPACE_BETWEEN, - SPACING, - TYPOGRAPHY, -} from '@opentrons/components' -import { StyledText } from '../../../atoms/text' -import HeaterShakerKeyParts from '../../../assets/images/heater_shaker-key-parts.png' -import HeaterShakerDeckLock from '../../../assets/videos/heater-shaker-setup/HS_Deck_Lock_Anim.webm' - -export function KeyParts(): JSX.Element { - const { t } = useTranslation('heater_shaker') - return ( - <> - - {t('heater_shaker_key_parts')} - - - , - }} - /> - - - Heater Shaker Key Parts - - - , - block: ( - - ), - }} - /> - , - block: ( - - ), - }} - /> - - - - - ) -} diff --git a/app/src/organisms/Devices/HeaterShakerWizard/PowerOn.tsx b/app/src/organisms/Devices/HeaterShakerWizard/PowerOn.tsx deleted file mode 100644 index f0637a14e2b..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/PowerOn.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react' -import { Trans, useTranslation } from 'react-i18next' -import { - getModuleDef2, - inferModuleOrientationFromXCoordinate, -} from '@opentrons/shared-data' -import { - DIRECTION_COLUMN, - Flex, - Module, - RobotWorkSpace, - SPACING, - TYPOGRAPHY, -} from '@opentrons/components' -import { StyledText } from '../../../atoms/text' -import { ModuleInfo } from '../ModuleInfo' - -import type { HeaterShakerModule } from '../../../redux/modules/types' - -const VIEW_BOX = '-150 -38 440 128' -interface PowerOnProps { - attachedModule: HeaterShakerModule | null -} - -export function PowerOn(props: PowerOnProps): JSX.Element { - const { t } = useTranslation('heater_shaker') - const moduleDef = getModuleDef2('heaterShakerModuleV1') - - return ( - <> - - - ), - block: , - }} - /> - - - {() => ( - - - - - - )} - - - ) -} diff --git a/app/src/organisms/Devices/HeaterShakerWizard/TestShake.tsx b/app/src/organisms/Devices/HeaterShakerWizard/TestShake.tsx deleted file mode 100644 index 90f0096a9c6..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/TestShake.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import React from 'react' -import { Trans, useTranslation } from 'react-i18next' -import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' -import { - ALIGN_CENTER, - ALIGN_FLEX_START, - COLORS, - DIRECTION_COLUMN, - DIRECTION_ROW, - Flex, - Icon, - SIZE_AUTO, - SPACING, - TYPOGRAPHY, - useHoverTooltip, -} from '@opentrons/components' -import { - RPM, - HS_RPM_MAX, - HS_RPM_MIN, - CreateCommand, -} from '@opentrons/shared-data' -import { TertiaryButton } from '../../../atoms/buttons' -import { Tooltip } from '../../../atoms/Tooltip' -import { StyledText } from '../../../atoms/text' -import { Divider } from '../../../atoms/structure' -import { InputField } from '../../../atoms/InputField' -import { Collapsible } from '../../ModuleCard/Collapsible' -import { useLatchControls } from '../../ModuleCard/hooks' -import { HeaterShakerModuleCard } from './HeaterShakerModuleCard' - -import type { HeaterShakerModule } from '../../../redux/modules/types' -import type { - HeaterShakerSetAndWaitForShakeSpeedCreateCommand, - HeaterShakerDeactivateShakerCreateCommand, - HeaterShakerCloseLatchCreateCommand, -} from '@opentrons/shared-data/protocol/types/schemaV7/command/module' -import type { ProtocolModuleInfo } from '../../Devices/ProtocolRun/utils/getProtocolModulesInfo' - -interface TestShakeProps { - module: HeaterShakerModule - setCurrentPage: React.Dispatch> - moduleFromProtocol?: ProtocolModuleInfo -} - -export function TestShake(props: TestShakeProps): JSX.Element { - const { module, setCurrentPage, moduleFromProtocol } = props - const { t } = useTranslation(['heater_shaker', 'device_details']) - const { createLiveCommand } = useCreateLiveCommandMutation() - const [isExpanded, setExpanded] = React.useState(false) - const [shakeValue, setShakeValue] = React.useState(null) - const [targetProps, tooltipProps] = useHoverTooltip() - const { toggleLatch, isLatchClosed } = useLatchControls(module) - const isShaking = module.data.speedStatus !== 'idle' - - const closeLatchCommand: HeaterShakerCloseLatchCreateCommand = { - commandType: 'heaterShaker/closeLabwareLatch', - params: { - moduleId: module.id, - }, - } - - const setShakeCommand: HeaterShakerSetAndWaitForShakeSpeedCreateCommand = { - commandType: 'heaterShaker/setAndWaitForShakeSpeed', - params: { - moduleId: module.id, - rpm: shakeValue !== null ? shakeValue : 0, - }, - } - - const stopShakeCommand: HeaterShakerDeactivateShakerCreateCommand = { - commandType: 'heaterShaker/deactivateShaker', - params: { - moduleId: module.id, - }, - } - - const sendCommands = async (): Promise => { - const commands: CreateCommand[] = isShaking - ? [stopShakeCommand] - : [closeLatchCommand, setShakeCommand] - - for (const command of commands) { - // await each promise to make sure the server receives requests in the right order - await createLiveCommand({ - command, - }).catch((e: Error) => { - console.error( - `error setting module status with command type ${String( - command.commandType - )}: ${e.message}` - ) - }) - } - - setShakeValue(null) - } - - const errorMessage = - shakeValue != null && (shakeValue < HS_RPM_MIN || shakeValue > HS_RPM_MAX) - ? t('device_details:input_out_of_range') - : null - - return ( - - - {t('step_4_of_4')} - - - - - - - - , - block: ( - - ), - }} - /> - - - - - - - {isLatchClosed ? t('open_labware_latch') : t('close_labware_latch')} - - - - - - {t('set_shake_speed')} - - setShakeValue(e.target.valueAsNumber)} - type="number" - caption={t('min_max_rpm', { - min: HS_RPM_MIN, - max: HS_RPM_MAX, - })} - error={errorMessage} - disabled={isShaking} - /> - - - {isShaking ? t('stop_shaking') : t('start_shaking')} - - {!isLatchClosed ? ( - {t('cannot_shake')} - ) : null} - - - - setExpanded(!isExpanded)} - > - - {t('troubleshoot_step_1')} - setCurrentPage(2)} - > - {t('go_to_step_1')} - - - - {t('troubleshoot_step_3')} - setCurrentPage(4)} - > - {t('go_to_step_3')} - - - - - - ) -} diff --git a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/AttachAdapter.test.tsx b/app/src/organisms/Devices/HeaterShakerWizard/__tests__/AttachAdapter.test.tsx deleted file mode 100644 index cfb717edd31..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/AttachAdapter.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import * as React from 'react' -import { fireEvent } from '@testing-library/react' -import { renderWithProviders } from '@opentrons/components' -import { i18n } from '../../../../i18n' -import { mockHeaterShaker } from '../../../../redux/modules/__fixtures__' -import { AttachAdapter } from '../AttachAdapter' -import { useLatchControls } from '../../../ModuleCard/hooks' -import type { HeaterShakerModule } from '../../../../redux/modules/types' - -jest.mock('../../../ModuleCard/hooks') - -const mockUseLatchControls = useLatchControls as jest.MockedFunction< - typeof useLatchControls -> - -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -const mockHeaterShakeShaking: HeaterShakerModule = { - id: 'heatershaker_id', - moduleModel: 'heaterShakerModuleV1', - moduleType: 'heaterShakerModuleType', - serialNumber: 'jkl123', - hardwareRevision: 'heatershaker_v4.0', - firmwareVersion: 'v2.0.0', - hasAvailableUpdate: true, - data: { - labwareLatchStatus: 'idle_closed', - speedStatus: 'speeding up', - temperatureStatus: 'idle', - currentSpeed: 300, - currentTemperature: null, - targetSpeed: 800, - targetTemperature: null, - errorDetails: null, - status: 'idle', - }, - usbPort: { - path: '/dev/ot_module_heatershaker0', - port: 1, - hub: false, - portGroup: 'unknown', - }, -} - -describe('AttachAdapter', () => { - let props: React.ComponentProps - const mockToggleLatch = jest.fn() - beforeEach(() => { - props = { - module: mockHeaterShaker, - } - mockUseLatchControls.mockReturnValue({ - toggleLatch: mockToggleLatch, - isLatchClosed: true, - }) - }) - afterEach(() => { - jest.resetAllMocks() - }) - it('renders all the Attach adapter component text and images', () => { - const { getByText, getByAltText, getByLabelText } = render(props) - - getByText('Step 3 of 4: Attach Thermal Adapter') - getByText('Attach your adapter to the module.') - getByText('Please use T10 Torx Screwdriver and provided screw') - getByText( - 'Using a different screwdriver can strip the screws. Using a different screw than the one provided can damage the module' - ) - getByText('Check alignment.') - getByText('A properly attached adapter will sit evenly on the module.') - getByText('3a') - getByText('Check attachment by rocking the adapter back and forth.') - getByText('3b') - getByText('3c') - getByAltText('heater_shaker_adapter_alignment') - getByAltText('screw_in_adapter') - getByLabelText('information') - }) - it('renders button and clicking on it sends latch command to open', () => { - const { getByRole } = render(props) - const btn = getByRole('button', { name: 'Open Labware Latch' }) - fireEvent.click(btn) - expect(mockToggleLatch).toHaveBeenCalled() - }) - it('renders button and clicking on it sends latch command to close', () => { - mockUseLatchControls.mockReturnValue({ - toggleLatch: mockToggleLatch, - isLatchClosed: false, - }) - const { getByRole } = render(props) - const btn = getByRole('button', { name: 'Close Labware Latch' }) - fireEvent.click(btn) - expect(mockToggleLatch).toHaveBeenCalled() - }) - it('renders button and it is disabled when heater-shaker is shaking', () => { - props = { - module: mockHeaterShakeShaking, - } - const { getByRole } = render(props) - const btn = getByRole('button', { name: 'Open Labware Latch' }) - expect(btn).toBeDisabled() - }) -}) diff --git a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/AttachModule.test.tsx b/app/src/organisms/Devices/HeaterShakerWizard/__tests__/AttachModule.test.tsx deleted file mode 100644 index 7295a436d1c..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/AttachModule.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import * as React from 'react' -import { nestedTextMatcher, renderWithProviders } from '@opentrons/components' -import { i18n } from '../../../../i18n' -import { AttachModule } from '../AttachModule' -import { mockHeaterShaker } from '../../../../redux/modules/__fixtures__' -import heaterShakerCommands from '@opentrons/shared-data/protocol/fixtures/6/heaterShakerCommands.json' -import { ProtocolModuleInfo } from '../../../Devices/ProtocolRun/utils/getProtocolModulesInfo' - -const HEATER_SHAKER_PROTOCOL_MODULE_INFO = { - moduleId: 'heater_shaker_id', - x: 0, - y: 0, - z: 0, - moduleDef: mockHeaterShaker as any, - nestedLabwareDef: heaterShakerCommands.labwareDefinitions['example/plate/1'], - nestedLabwareDisplayName: 'Source Plate', - nestedLabwareId: null, - protocolLoadOrder: 1, - slotName: '1', -} as ProtocolModuleInfo - -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -describe('AttachModule', () => { - let props: React.ComponentProps - beforeEach(() => { - props = { - moduleFromProtocol: HEATER_SHAKER_PROTOCOL_MODULE_INFO, - } - }) - it('renders the correct title', () => { - const { getByText } = render(props) - - getByText('Step 1 of 4: Attach module to deck') - }) - - it('renders the content and images correctly when page is not launched from a protocol', () => { - props = { - moduleFromProtocol: undefined, - } - const { getByText, getByAltText, getByTestId } = render(props) - - getByText( - nestedTextMatcher( - 'Before placing the module on the deck, make sure the anchors are not extended.' - ) - ) - getByText( - nestedTextMatcher( - 'Turn screws counterclockwise to retract the anchors. The screws should not come out of the module.' - ) - ) - getByText( - nestedTextMatcher( - 'Orient your module such that the power and USB ports are facing outward.' - ) - ) - getByText( - nestedTextMatcher( - 'Hold the module flat against the deck and turn screws clockwise to extend the anchors.' - ) - ) - getByText( - nestedTextMatcher( - 'Check attachment by gently pulling up and rocking the module.' - ) - ) - getByText('Place the module in a Slot.') - getByText('1a') - getByText('1b') - getByText('1c') - getByAltText('Attach Module to Deck') - getByAltText('screwdriver_1a') - getByAltText('screwdriver_1b') - getByTestId('HeaterShakerWizard_deckMap') - }) - - it('renders the correct slot number when a protocol with a heater shaker is provided', () => { - const { getByText } = render(props) - - getByText(nestedTextMatcher('Place the module in Slot 1.')) - }) -}) diff --git a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/HeaterShakerWizard.test.tsx b/app/src/organisms/Devices/HeaterShakerWizard/__tests__/HeaterShakerWizard.test.tsx deleted file mode 100644 index 8ad2220c517..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/HeaterShakerWizard.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import * as React from 'react' -import { fireEvent } from '@testing-library/react' -import { renderWithProviders } from '@opentrons/components' -import { MemoryRouter } from 'react-router-dom' -import { i18n } from '../../../../i18n' -import { mockHeaterShaker } from '../../../../redux/modules/__fixtures__' -import heaterShakerCommands from '@opentrons/shared-data/protocol/fixtures/6/heaterShakerCommands.json' -import { HeaterShakerWizard } from '..' -import { Introduction } from '../Introduction' -import { KeyParts } from '../KeyParts' -import { AttachModule } from '../AttachModule' -import { AttachAdapter } from '../AttachAdapter' -import { PowerOn } from '../PowerOn' -import { TestShake } from '../TestShake' - -import type { ProtocolModuleInfo } from '../../../Devices/ProtocolRun/utils/getProtocolModulesInfo' - -jest.mock('../Introduction') -jest.mock('../KeyParts') -jest.mock('../AttachModule') -jest.mock('../AttachAdapter') -jest.mock('../PowerOn') -jest.mock('../TestShake') - -const mockIntroduction = Introduction as jest.MockedFunction< - typeof Introduction -> -const mockKeyParts = KeyParts as jest.MockedFunction -const mockAttachModule = AttachModule as jest.MockedFunction< - typeof AttachModule -> -const mockAttachAdapter = AttachAdapter as jest.MockedFunction< - typeof AttachAdapter -> -const mockPowerOn = PowerOn as jest.MockedFunction -const mockTestShake = TestShake as jest.MockedFunction - -const render = ( - props: React.ComponentProps, - path = '/' -) => { - return renderWithProviders( - - - , - { - i18nInstance: i18n, - } - )[0] -} - -describe('HeaterShakerWizard', () => { - let props: React.ComponentProps - beforeEach(() => { - props = { - onCloseClick: jest.fn(), - attachedModule: mockHeaterShaker, - } - mockIntroduction.mockReturnValue(
Mock Introduction
) - mockKeyParts.mockReturnValue(
Mock Key Parts
) - mockAttachModule.mockReturnValue(
Mock Attach Module
) - mockAttachAdapter.mockReturnValue(
Mock Attach Adapter
) - mockPowerOn.mockReturnValue(
Mock Power On
) - mockTestShake.mockReturnValue(
Mock Test Shake
) - }) - - it('renders the main modal component of the wizard and exit button is clickable', () => { - const { getByText, getByLabelText } = render(props) - getByText(/Attach Heater-Shaker Module/i) - getByText('Mock Introduction') - const close = getByLabelText('close') - fireEvent.click(close) - expect(props.onCloseClick).toHaveBeenCalled() - }) - - it('renders wizard and returns the correct pages when the buttons are clicked', () => { - const { getByText, getByRole } = render(props) - - let button = getByRole('button', { name: 'Continue to attachment guide' }) - fireEvent.click(button) - getByText('Mock Key Parts') - - button = getByRole('button', { name: 'Begin attachment' }) - fireEvent.click(button) - getByText('Mock Attach Module') - - button = getByRole('button', { name: 'Continue to power on module' }) - fireEvent.click(button) - getByText('Mock Power On') - - button = getByRole('button', { name: 'Continue to attach thermal adapter' }) - fireEvent.click(button) - getByText('Mock Attach Adapter') - - button = getByRole('button', { name: 'Continue to test shake' }) - fireEvent.click(button) - getByText('Mock Test Shake') - - getByRole('button', { name: 'Complete' }) - }) - - it('renders wizard and returns the correct pages when the buttons are clicked and protocol is known', () => { - props = { - onCloseClick: jest.fn(), - moduleFromProtocol: { - moduleId: 'heater_shaker_id', - x: 0, - y: 0, - z: 0, - moduleDef: mockHeaterShaker as any, - nestedLabwareDef: - heaterShakerCommands.labwareDefinitions['example/plate/1'], - nestedLabwareDisplayName: null, - nestedLabwareId: null, - protocolLoadOrder: 1, - slotName: '1', - } as ProtocolModuleInfo, - attachedModule: mockHeaterShaker, - } - const { getByText, getByRole } = render(props) - - let button = getByRole('button', { name: 'Continue to attachment guide' }) - fireEvent.click(button) - getByText('Mock Key Parts') - - button = getByRole('button', { name: 'Begin attachment' }) - fireEvent.click(button) - getByText('Mock Attach Module') - - button = getByRole('button', { name: 'Continue to power on module' }) - fireEvent.click(button) - getByText('Mock Power On') - - button = getByRole('button', { name: 'Continue to attach thermal adapter' }) - fireEvent.click(button) - getByText('Mock Attach Adapter') - - button = getByRole('button', { name: 'Continue to test shake' }) - fireEvent.click(button) - getByText('Mock Test Shake') - - getByRole('button', { name: 'Complete' }) - }) - - it('renders power on component and the test shake button is disabled', () => { - props = { - ...props, - attachedModule: null, - } - const { getByText, getByRole } = render(props) - - let button = getByRole('button', { name: 'Continue to attachment guide' }) - fireEvent.click(button) - getByText('Mock Key Parts') - - button = getByRole('button', { name: 'Begin attachment' }) - fireEvent.click(button) - getByText('Mock Attach Module') - - button = getByRole('button', { name: 'Continue to power on module' }) - fireEvent.click(button) - getByText('Mock Power On') - - button = getByRole('button', { name: 'Continue to attach thermal adapter' }) - expect(button).toBeDisabled() - }) -}) diff --git a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/Introduction.test.tsx b/app/src/organisms/Devices/HeaterShakerWizard/__tests__/Introduction.test.tsx deleted file mode 100644 index c9c797b31d7..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/Introduction.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import * as React from 'react' -import { renderWithProviders } from '@opentrons/components' -import { i18n } from '../../../../i18n' -import { mockDefinition } from '../../../../redux/custom-labware/__fixtures__' -import { Introduction } from '../Introduction' -import type { ThermalAdapterName } from '@opentrons/shared-data' - -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -describe('Introduction', () => { - let props: React.ComponentProps - beforeEach(() => { - props = { - labwareDefinition: null, - thermalAdapterName: null, - moduleModel: 'heaterShakerModuleV1', - } - }) - afterEach(() => { - jest.resetAllMocks() - }) - - it('renders correct title and body when protocol has not been uploaded', () => { - const { getByText, getByAltText } = render(props) - - getByText( - 'Use this guide to attach the Heater-Shaker Module to your robot’s deck for secure shaking.' - ) - getByText('You will need:') - getByText('Thermal Adapter + Screw') - getByText('Screw may already be in the center of the module.') - getByText('Labware') - getByText('Heater-Shaker Module GEN1') - getByText('T10 Torx Screwdriver') - getByText( - 'Provided with module. Note: using another screwdriver size can strip the module’s screws.' - ) - getByAltText('heater_shaker_image') - getByAltText('screwdriver_image') - }) - it('renders the correct body when protocol has been uploaded with PCR adapter', () => { - props = { - labwareDefinition: mockDefinition, - thermalAdapterName: 'PCR Adapter' as ThermalAdapterName, - moduleModel: 'heaterShakerModuleV1', - } - - const { getByText, getByAltText } = render(props) - getByText('Mock Definition') - getByText('PCR Adapter + Screw') - getByAltText('PCR Adapter') - }) - it('renders the correct thermal adapter info when name is Universal Flat Adapter', () => { - props = { - labwareDefinition: null, - thermalAdapterName: 'Universal Flat Adapter', - moduleModel: 'heaterShakerModuleV1', - } - - const { getByText, getByAltText } = render(props) - getByText('Universal Flat Adapter + Screw') - getByAltText('Universal Flat Adapter') - }) - it('renders the correct thermal adapter info when name is Deep Well Adapter', () => { - props = { - labwareDefinition: null, - thermalAdapterName: 'Deep Well Adapter', - moduleModel: 'heaterShakerModuleV1', - } - - const { getByText, getByAltText } = render(props) - getByText('Deep Well Adapter + Screw') - getByAltText('Deep Well Adapter') - }) - it('renders the correct thermal adapter info when name is 96 Flat Bottom Adapter', () => { - props = { - labwareDefinition: null, - thermalAdapterName: '96 Flat Bottom Adapter', - moduleModel: 'heaterShakerModuleV1', - } - - const { getByText, getByAltText } = render(props) - getByText('96 Flat Bottom Adapter + Screw') - getByAltText('96 Flat Bottom Adapter') - }) -}) diff --git a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/KeyParts.test.tsx b/app/src/organisms/Devices/HeaterShakerWizard/__tests__/KeyParts.test.tsx deleted file mode 100644 index 459b1f88229..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/KeyParts.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import * as React from 'react' -import { nestedTextMatcher, renderWithProviders } from '@opentrons/components' -import { i18n } from '../../../../i18n' -import { KeyParts } from '../KeyParts' - -const render = () => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -describe('KeyParts', () => { - it('renders correct title, image and body', () => { - const { getByText, getByAltText, getByTestId } = render() - - getByText('Key Heater-Shaker parts and terminology') - getByText( - nestedTextMatcher( - 'Orient the module so its power ports face away from you.' - ) - ) - getByText( - nestedTextMatcher( - 'The Labware Latch keeps labware secure while the module is shaking.' - ) - ) - getByText( - 'It can be opened or closed manually and with software but is closed and locked while the module is shaking.' - ) - getByText( - nestedTextMatcher( - 'The 2 Anchors keep the module attached to the deck while it is shaking.' - ) - ) - getByText( - 'To extend and retract each anchor, turn the screw above it. See animation below.' - ) - getByText( - 'Extending the anchors increases the module’s footprint, which more firmly attaches it to the slot.' - ) - getByAltText('Heater Shaker Key Parts') - - getByTestId('heater_shaker_deck_lock') - }) -}) diff --git a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/PowerOn.test.tsx b/app/src/organisms/Devices/HeaterShakerWizard/__tests__/PowerOn.test.tsx deleted file mode 100644 index c1d57620ba2..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/PowerOn.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from 'react' -import { renderWithProviders } from '@opentrons/components' -import { i18n } from '../../../../i18n' -import { mockHeaterShaker } from '../../../../redux/modules/__fixtures__' -import { PowerOn } from '../PowerOn' - -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -describe('PowerOn', () => { - let props: React.ComponentProps - - beforeEach(() => { - props = { - attachedModule: mockHeaterShaker, - } - }) - afterEach(() => { - jest.resetAllMocks() - }) - - it('renders correct title and body when protocol has not been uploaded', () => { - const { getByText } = render(props) - - getByText('Step 2 of 4: Power on the module') - getByText('Connect your module to the robot and and power it on.') - }) - - it('renders heater shaker SVG with info with module connected', () => { - if (props.attachedModule != null && props.attachedModule.usbPort != null) { - props.attachedModule.usbPort.port = 1 - } - const { getByText } = render(props) - getByText('Connected') - getByText('Heater-Shaker Module GEN1') - getByText('USB Port 1') - }) - - it('renders heater shaker SVG with info with module not connected', () => { - props = { - attachedModule: null, - } - const { getByText } = render(props) - getByText('Not connected') - getByText('Heater-Shaker Module GEN1') - getByText('No USB Port Yet') - }) -}) diff --git a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/TestShake.test.tsx b/app/src/organisms/Devices/HeaterShakerWizard/__tests__/TestShake.test.tsx deleted file mode 100644 index 52fd08d7724..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/TestShake.test.tsx +++ /dev/null @@ -1,408 +0,0 @@ -import * as React from 'react' -import { nestedTextMatcher, renderWithProviders } from '@opentrons/components' -import { fireEvent, waitFor } from '@testing-library/react' -import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' -import { i18n } from '../../../../i18n' -import { useLatchControls } from '../../../ModuleCard/hooks' -import heaterShakerCommands from '@opentrons/shared-data/protocol/fixtures/6/heaterShakerCommands.json' -import { mockHeaterShaker } from '../../../../redux/modules/__fixtures__' -import { useRunStatuses } from '../../hooks' -import { TestShake } from '../TestShake' -import { HeaterShakerModuleCard } from '../HeaterShakerModuleCard' - -import type { ProtocolModuleInfo } from '../../../Devices/ProtocolRun/utils/getProtocolModulesInfo' - -jest.mock('@opentrons/react-api-client') -jest.mock('../HeaterShakerModuleCard') -jest.mock('../../../ModuleCard/hooks') -jest.mock('../../hooks') - -const mockUseLiveCommandMutation = useCreateLiveCommandMutation as jest.MockedFunction< - typeof useCreateLiveCommandMutation -> -const mockUseLatchControls = useLatchControls as jest.MockedFunction< - typeof useLatchControls -> -const mockHeaterShakerModuleCard = HeaterShakerModuleCard as jest.MockedFunction< - typeof HeaterShakerModuleCard -> -const mockUseRunStatuses = useRunStatuses as jest.MockedFunction< - typeof useRunStatuses -> - -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -const HEATER_SHAKER_PROTOCOL_MODULE_INFO = { - moduleId: 'heater_shaker_id', - x: 0, - y: 0, - z: 0, - moduleDef: mockHeaterShaker as any, - nestedLabwareDef: heaterShakerCommands.labwareDefinitions['example/plate/1'], - nestedLabwareDisplayName: 'Source Plate', - nestedLabwareId: null, - protocolLoadOrder: 1, - slotName: '1', -} as ProtocolModuleInfo - -const mockOpenLatchHeaterShaker = { - id: 'heatershaker_id', - moduleModel: 'heaterShakerModuleV1', - moduleType: 'heaterShakerModuleType', - serialNumber: 'jkl123', - hardwareRevision: 'heatershaker_v4.0', - firmwareVersion: 'v2.0.0', - hasAvailableUpdate: true, - data: { - labwareLatchStatus: 'idle_open', - speedStatus: 'idle', - temperatureStatus: 'idle', - currentSpeed: null, - currentTemperature: null, - targetSpeed: null, - targetTemp: null, - errorDetails: null, - status: 'idle', - }, - usbPort: { path: '/dev/ot_module_heatershaker0', port: 1 }, -} as any - -const mockCloseLatchHeaterShaker = { - id: 'heatershaker_id', - moduleModel: 'heaterShakerModuleV1', - moduleType: 'heaterShakerModuleType', - serialNumber: 'jkl123', - hardwareRevision: 'heatershaker_v4.0', - firmwareVersion: 'v2.0.0', - hasAvailableUpdate: true, - data: { - labwareLatchStatus: 'idle_closed', - speedStatus: 'idle', - temperatureStatus: 'idle', - currentSpeed: null, - currentTemperature: null, - targetSpeed: null, - targetTemp: null, - errorDetails: null, - status: 'idle', - }, - usbPort: { path: '/dev/ot_module_heatershaker0', port: 1, hub: null }, -} as any - -const mockMovingHeaterShaker = { - id: 'heatershaker_id', - moduleModel: 'heaterShakerModuleV1', - moduleType: 'heaterShakerModuleType', - serialNumber: 'jkl123', - hardwareRevision: 'heatershaker_v4.0', - firmwareVersion: 'v2.0.0', - hasAvailableUpdate: true, - data: { - labwareLatchStatus: 'idle_closed', - speedStatus: 'speeding up', - temperatureStatus: 'idle', - currentSpeed: null, - currentTemperature: null, - targetSpeed: null, - targetTemp: null, - errorDetails: null, - status: 'idle', - }, - usbPort: { path: '/dev/ot_module_heatershaker0', port: 1 }, -} as any - -describe('TestShake', () => { - let props: React.ComponentProps - let mockCreateLiveCommand = jest.fn() - const mockToggleLatch = jest.fn() - beforeEach(() => { - props = { - setCurrentPage: jest.fn(), - module: mockHeaterShaker, - moduleFromProtocol: undefined, - } - mockCreateLiveCommand = jest.fn() - mockCreateLiveCommand.mockResolvedValue(null) - mockUseLiveCommandMutation.mockReturnValue({ - createLiveCommand: mockCreateLiveCommand, - } as any) - mockHeaterShakerModuleCard.mockReturnValue( -
Mock Heater Shaker Module Card
- ) - mockUseLatchControls.mockReturnValue({ - toggleLatch: jest.fn(), - isLatchClosed: true, - } as any) - mockUseRunStatuses.mockReturnValue({ - isRunRunning: false, - isRunStill: false, - isRunTerminal: false, - isRunIdle: false, - }) - }) - it('renders the correct title', () => { - const { getByText } = render(props) - getByText('Step 4 of 4: Test shake') - }) - - it('renders the information banner icon and description', () => { - const { getByText, getByLabelText } = render(props) - getByLabelText('information') - getByText( - 'If you want to add labware to the module before doing a test shake, you can use the labware latch controls to hold the latches open.' - ) - }) - - it('renders labware name in the banner description when there is a protocol', () => { - props = { - setCurrentPage: jest.fn(), - module: mockHeaterShaker, - moduleFromProtocol: HEATER_SHAKER_PROTOCOL_MODULE_INFO, - } - const { getByText } = render(props) - getByText( - nestedTextMatcher( - 'If you want to add the Source Plate to the module before doing a test shake, you can use the labware latch controls.' - ) - ) - }) - - it('renders a heater shaker module card', () => { - const { getByText } = render(props) - - getByText('Mock Heater Shaker Module Card') - }) - - it('renders the close labware latch button and is enabled when latch status is open', () => { - props = { - module: mockHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - mockUseLatchControls.mockReturnValue({ - toggleLatch: mockToggleLatch, - isLatchClosed: false, - }) - - const { getByRole } = render(props) - const button = getByRole('button', { name: /Close Labware Latch/i }) - expect(button).toBeEnabled() - }) - - it('renders the start shaking button and is disabled', () => { - props = { - module: mockCloseLatchHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - const { getByRole } = render(props) - const button = getByRole('button', { name: /Start Shaking/i }) - expect(button).toBeDisabled() - }) - - it('renders an input field for speed setting', () => { - const { getByText, getByRole } = render(props) - - getByText('Set shake speed') - getByRole('spinbutton') - }) - - it('renders troubleshooting accordion and contents', () => { - const { getByText, getByRole } = render(props) - - const troubleshooting = getByText('Troubleshooting') - fireEvent.click(troubleshooting) - - getByText( - 'Return to Step 1 to see instructions for securing the module to the deck.' - ) - const buttonStep1 = getByRole('button', { name: /Go to Step 1/i }) - expect(buttonStep1).toBeEnabled() - - getByText( - 'Return to Step 3 to see instructions for securing the thermal adapter to the module.' - ) - const buttonStep2 = getByRole('button', { name: /Go to Step 3/i }) - expect(buttonStep2).toBeEnabled() - }) - - it('start shake button should be disabled if the labware latch is open', () => { - props = { - module: mockOpenLatchHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - mockUseLatchControls.mockReturnValue({ - toggleLatch: mockToggleLatch, - isLatchClosed: false, - }) - - const { getByRole } = render(props) - const button = getByRole('button', { name: /Start/i }) - expect(button).toBeDisabled() - }) - - it('start shake button should be disabled if the input is out of range', () => { - props = { - module: mockOpenLatchHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - mockUseLatchControls.mockReturnValue({ - toggleLatch: mockToggleLatch, - isLatchClosed: false, - }) - - const { getByRole } = render(props) - const input = getByRole('spinbutton') - fireEvent.change(input, { target: { value: '0' } }) - const button = getByRole('button', { name: /Start/i }) - expect(button).toBeDisabled() - }) - - it('clicking the open latch button should open the heater shaker latch', () => { - props = { - module: mockCloseLatchHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - mockUseLatchControls.mockReturnValue({ - toggleLatch: mockToggleLatch, - isLatchClosed: true, - }) - - const { getByRole } = render(props) - const button = getByRole('button', { name: /Open Labware Latch/i }) - fireEvent.click(button) - expect(mockToggleLatch).toHaveBeenCalled() - }) - - it('clicking the close latch button should close the heater shaker latch', () => { - props = { - module: mockOpenLatchHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - mockUseLatchControls.mockReturnValue({ - toggleLatch: mockToggleLatch, - isLatchClosed: false, - }) - - const { getByRole } = render(props) - const button = getByRole('button', { name: /Close Labware Latch/i }) - fireEvent.click(button) - expect(mockToggleLatch).toHaveBeenCalled() - }) - - it('entering an input for shake speed and clicking start should begin shaking', async () => { - props = { - module: mockCloseLatchHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - const { getByRole } = render(props) - const button = getByRole('button', { name: /Start Shaking/i }) - const input = getByRole('spinbutton') - fireEvent.change(input, { target: { value: '300' } }) - fireEvent.click(button) - - await waitFor(() => { - expect(mockCreateLiveCommand).toHaveBeenCalledWith({ - command: { - commandType: 'heaterShaker/closeLabwareLatch', - params: { - moduleId: 'heatershaker_id', - }, - }, - }) - - expect(mockCreateLiveCommand).toHaveBeenCalledWith({ - command: { - commandType: 'heaterShaker/setAndWaitForShakeSpeed', - params: { - moduleId: 'heatershaker_id', - rpm: 300, - }, - }, - }) - }) - }) - - it('when the heater shaker is shaking clicking stop should deactivate the shaking', () => { - props = { - module: mockMovingHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - const { getByRole } = render(props) - const input = getByRole('spinbutton') - expect(input).toBeDisabled() - const button = getByRole('button', { name: /Stop Shaking/i }) - fireEvent.change(input, { target: { value: '200' } }) - fireEvent.click(button) - - expect(mockCreateLiveCommand).toHaveBeenCalledWith({ - command: { - commandType: 'heaterShaker/deactivateShaker', - params: { - moduleId: mockHeaterShaker.id, - }, - }, - }) - }) - - // next test is sending module commands when run is terminal and through module controls - it('entering an input for shake speed and clicking start should close the latch and begin shaking when run is terminal', async () => { - mockUseRunStatuses.mockReturnValue({ - isRunRunning: false, - isRunStill: false, - isRunTerminal: true, - isRunIdle: false, - }) - - props = { - module: mockHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: HEATER_SHAKER_PROTOCOL_MODULE_INFO, - } - - const { getByRole } = render(props) - const button = getByRole('button', { name: /Start Shaking/i }) - const input = getByRole('spinbutton') - fireEvent.change(input, { target: { value: '300' } }) - fireEvent.click(button) - - await waitFor(() => { - expect(mockCreateLiveCommand).toHaveBeenCalledWith({ - command: { - commandType: 'heaterShaker/closeLabwareLatch', - params: { - moduleId: 'heatershaker_id', - }, - }, - }) - - expect(mockCreateLiveCommand).toHaveBeenCalledWith({ - command: { - commandType: 'heaterShaker/setAndWaitForShakeSpeed', - params: { - moduleId: 'heatershaker_id', - rpm: 300, - }, - }, - }) - }) - }) -}) diff --git a/app/src/organisms/Devices/HeaterShakerWizard/index.tsx b/app/src/organisms/Devices/HeaterShakerWizard/index.tsx deleted file mode 100644 index 1c82eb30e05..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/index.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import * as React from 'react' -import { useParams } from 'react-router-dom' -import { useTranslation } from 'react-i18next' -import { getAdapterName } from '@opentrons/shared-data' -import { Portal } from '../../../App/portal' -import { Interstitial } from '../../../atoms/Interstitial/Interstitial' -import { Tooltip } from '../../../atoms/Tooltip' -import { Introduction } from './Introduction' -import { KeyParts } from './KeyParts' -import { AttachModule } from './AttachModule' -import { AttachAdapter } from './AttachAdapter' -import { PowerOn } from './PowerOn' -import { TestShake } from './TestShake' -import { - DIRECTION_ROW, - Flex, - JUSTIFY_SPACE_BETWEEN, - JUSTIFY_FLEX_END, - useHoverTooltip, - PrimaryButton, - SecondaryButton, -} from '@opentrons/components' - -import type { ModuleModel } from '@opentrons/shared-data' -import type { DesktopRouteParams } from '../../../App/types' -import type { HeaterShakerModule } from '../../../redux/modules/types' -import type { ProtocolModuleInfo } from '../../Devices/ProtocolRun/utils/getProtocolModulesInfo' - -interface HeaterShakerWizardProps { - onCloseClick: () => unknown - moduleFromProtocol?: ProtocolModuleInfo - attachedModule: HeaterShakerModule | null -} - -export const HeaterShakerWizard = ( - props: HeaterShakerWizardProps -): JSX.Element | null => { - const { onCloseClick, moduleFromProtocol, attachedModule } = props - const { t } = useTranslation(['heater_shaker', 'shared']) - const [currentPage, setCurrentPage] = React.useState(0) - const { robotName } = useParams() - const [targetProps, tooltipProps] = useHoverTooltip() - - let isPrimaryCTAEnabled: boolean = true - if (currentPage === 3) { - isPrimaryCTAEnabled = Boolean(attachedModule) - } - const labwareDef = - moduleFromProtocol != null ? moduleFromProtocol.nestedLabwareDef : null - - let heaterShakerModel: ModuleModel - if (attachedModule != null) { - heaterShakerModel = attachedModule.moduleModel - } else if (moduleFromProtocol != null) { - heaterShakerModel = moduleFromProtocol.moduleDef.model - } - - let buttonContent = null - const getWizardDisplayPage = (): JSX.Element | null => { - switch (currentPage) { - case 0: - buttonContent = t('btn_continue_attachment_guide') - return ( - - ) - case 1: - buttonContent = t('btn_begin_attachment') - return - case 2: - buttonContent = t('btn_power_module') - return - case 3: - buttonContent = t('btn_thermal_adapter') - return - case 4: - buttonContent = t('btn_test_shake') - return ( - // attachedModule should never be null because isPrimaryCTAEnabled would be disabled otherwise - attachedModule != null ? ( - - ) : null - ) - case 5: - buttonContent = t('complete') - return attachedModule != null ? ( - - ) : null - default: - return null - } - } - - return ( - - onCloseClick(), - title: t('shared:exit'), - children: t('shared:exit'), - }, - }} - > - {getWizardDisplayPage()} - - {currentPage > 0 ? ( - setCurrentPage(currentPage => currentPage - 1)} - > - {t('back')} - - ) : null} - {currentPage <= 5 ? ( - onCloseClick() - : () => setCurrentPage(currentPage => currentPage + 1) - } - > - {buttonContent} - {!isPrimaryCTAEnabled ? ( - - {t('module_is_not_connected')} - - ) : null} - - ) : null} - - - - ) -} 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/SetupModules/SetupModulesList.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModules/SetupModulesList.tsx index 853e458a7c6..81e4412c001 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModules/SetupModulesList.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModules/SetupModulesList.tsx @@ -40,7 +40,7 @@ import { useUnmatchedModulesForProtocol, useRunCalibrationStatus, } from '../../hooks' -import { HeaterShakerWizard } from '../../HeaterShakerWizard' +import { ModuleSetupModal } from '../../../ModuleCard/ModuleSetupModal' import { ModuleWizardFlows } from '../../../ModuleWizardFlows' import { getModuleImage } from './utils' @@ -209,14 +209,9 @@ export function ModulesListItem({ ? t('module_connected') : t('module_not_connected') const [ - showHeaterShakerFlow, - setShowHeaterShakerFlow, + showModuleSetupModal, + setShowModuleSetupModal, ] = React.useState(false) - const heaterShakerAttachedModule = - attachedModuleMatch != null && - attachedModuleMatch.moduleType === HEATERSHAKER_MODULE_TYPE - ? attachedModuleMatch - : null const [showModuleWizard, setShowModuleWizard] = React.useState(false) const [targetProps, tooltipProps] = useHoverTooltip({ placement: TOOLTIP_LEFT, @@ -235,7 +230,7 @@ export function ModulesListItem({ } `} marginTop={SPACING.spacing4} - onClick={() => setShowHeaterShakerFlow(true)} + onClick={() => setShowModuleSetupModal(true)} > - {showHeaterShakerFlow && heaterShakerModuleFromProtocol != null ? ( - setShowHeaterShakerFlow(false)} - moduleFromProtocol={heaterShakerModuleFromProtocol} - attachedModule={heaterShakerAttachedModule} + {showModuleSetupModal && heaterShakerModuleFromProtocol != null ? ( + setShowModuleSetupModal(false)} + moduleDisplayName={ + heaterShakerModuleFromProtocol.moduleDef.displayName + } /> ) : null} -const mockHeaterShakerWizard = HeaterShakerWizard as jest.MockedFunction< - typeof HeaterShakerWizard +const mockModuleSetupModal = ModuleSetupModal as jest.MockedFunction< + typeof ModuleSetupModal > const mockUseUnmatchedModulesForProtocol = useUnmatchedModulesForProtocol as jest.MockedFunction< typeof useUnmatchedModulesForProtocol @@ -108,9 +108,7 @@ describe('SetupModulesList', () => { robotName: ROBOT_NAME, runId: RUN_ID, } - when(mockHeaterShakerWizard).mockReturnValue( -
mockHeaterShakerWizard
- ) + when(mockModuleSetupModal).mockReturnValue(
mockModuleSetupModal
) when(mockUnMatchedModuleWarning).mockReturnValue(
mock unmatched module Banner
) @@ -418,7 +416,7 @@ describe('SetupModulesList', () => { const { getByText } = render(props) const moduleSetup = getByText('View module setup instructions') fireEvent.click(moduleSetup) - getByText('mockHeaterShakerWizard') + getByText('mockModuleSetupModal') }) it('shoulde render a magnetic block', () => { mockUseModuleRenderInfoForProtocolById.mockReturnValue({ 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 94053729402..53717fb6685 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 { RobotUpdateProgressModal } from '../RobotUpdateProgressModal' @@ -12,6 +13,7 @@ import type { State } from '../../../../../redux/types' jest.mock('../../../../../redux/robot-update') jest.mock('../../../../../redux/shell') +jest.mock('../../../hooks') const getRobotUpdateInfo = RobotUpdate.getRobotUpdateInfo as jest.MockedFunction< typeof RobotUpdate.getRobotUpdateInfo @@ -26,6 +28,10 @@ const getRobotUpdateDisplayInfo = RobotUpdate.getRobotUpdateDisplayInfo as jest. typeof RobotUpdate.getRobotUpdateDisplayInfo > +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() @@ -62,6 +68,7 @@ describe('ViewUpdateModal', () => { autoUpdateDisabledReason: null, updateFromFileDisabledReason: 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/organisms/ModuleCard/ModuleSetupModal.tsx b/app/src/organisms/ModuleCard/ModuleSetupModal.tsx new file mode 100644 index 00000000000..d6c3dceda2d --- /dev/null +++ b/app/src/organisms/ModuleCard/ModuleSetupModal.tsx @@ -0,0 +1,74 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { StyledText } from '../../atoms/text' +import code from '../../assets/images/module_instruction_code.png' +import { + ALIGN_FLEX_END, + Flex, + DIRECTION_COLUMN, + TYPOGRAPHY, + SPACING, + PrimaryButton, + Icon, + DIRECTION_ROW, + Link, +} from '@opentrons/components' +import { LegacyModal } from '../../molecules/LegacyModal' +import { Portal } from '../../App/portal' + +const MODULE_SETUP_URL = 'https://support.opentrons.com/s/modules' + +interface ModuleSetupModalProps { + close: () => void + moduleDisplayName: string +} + +export const ModuleSetupModal = (props: ModuleSetupModalProps): JSX.Element => { + const { moduleDisplayName } = props + const { t, i18n } = useTranslation(['protocol_setup', 'shared']) + + return ( + + + + + + + {t('modal_instructions')} + + + {t('module_instructions_link', { + moduleName: moduleDisplayName, + })} + + + + + + + {i18n.format(t('shared:close'), 'capitalize')} + + + + + ) +} diff --git a/app/src/organisms/ModuleCard/TestShakeSlideout.tsx b/app/src/organisms/ModuleCard/TestShakeSlideout.tsx index 1724d8d948e..02ed2417fcb 100644 --- a/app/src/organisms/ModuleCard/TestShakeSlideout.tsx +++ b/app/src/organisms/ModuleCard/TestShakeSlideout.tsx @@ -34,9 +34,9 @@ import { Divider } from '../../atoms/structure' import { InputField } from '../../atoms/InputField' import { Tooltip } from '../../atoms/Tooltip' import { StyledText } from '../../atoms/text' -import { HeaterShakerWizard } from '../Devices/HeaterShakerWizard' import { ConfirmAttachmentModal } from './ConfirmAttachmentModal' import { useLatchControls } from './hooks' +import { ModuleSetupModal } from './ModuleSetupModal' import type { HeaterShakerModule, LatchStatus } from '../../redux/modules/types' import type { @@ -64,7 +64,10 @@ export const TestShakeSlideout = ( const { toggleLatch, isLatchClosed } = useLatchControls(module) const configHasHeaterShakerAttached = useSelector(getIsHeaterShakerAttached) const [shakeValue, setShakeValue] = React.useState(null) - const [showWizard, setShowWizard] = React.useState(false) + const [ + showModuleSetupModal, + setShowModuleSetupModal, + ] = React.useState(false) const isShaking = module.data.speedStatus !== 'idle' const setShakeCommand: HeaterShakerSetAndWaitForShakeSpeedCreateCommand = { @@ -286,10 +289,10 @@ export const TestShakeSlideout = ( ) : null}
- {showWizard && ( - setShowWizard(false)} - attachedModule={module} + {showModuleSetupModal && ( + setShowModuleSetupModal(false)} + moduleDisplayName={getModuleDisplayName(module.moduleModel)} /> )} setShowWizard(true)} + onClick={() => setShowModuleSetupModal(true)} > {t('show_attachment_instructions')} diff --git a/app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx b/app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx new file mode 100644 index 00000000000..cfc407e2fd1 --- /dev/null +++ b/app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx @@ -0,0 +1,44 @@ +import * as React from 'react' +import { fireEvent } from '@testing-library/react' +import { renderWithProviders } from '@opentrons/components' +import { i18n } from '../../../i18n' +import { ModuleSetupModal } from '../ModuleSetupModal' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('ModuleSetupModal', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { close: jest.fn(), moduleDisplayName: 'mockModuleDisplayName' } + }) + + it('should render the correct header', () => { + const { getByRole } = render(props) + getByRole('heading', { name: 'mockModuleDisplayName Setup Instructions' }) + }) + it('should render the correct body', () => { + const { getByText } = render(props) + getByText( + 'For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box. You can also click the link below or scan the QR code to visit the modules section of the Opentrons Help Center.' + ) + }) + it('should render a link to the learn more page', () => { + const { getByRole } = render(props) + expect( + getByRole('link', { + name: 'mockModuleDisplayName setup instructions', + }).getAttribute('href') + ).toBe('https://support.opentrons.com/s/modules') + }) + it('should call close when the close button is pressed', () => { + const { getByRole } = render(props) + expect(props.close).not.toHaveBeenCalled() + const closeButton = getByRole('button', { name: 'Close' }) + fireEvent.click(closeButton) + expect(props.close).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/ModuleCard/__tests__/TestShakeSlideout.test.tsx b/app/src/organisms/ModuleCard/__tests__/TestShakeSlideout.test.tsx index fbe49b90023..b3a8de07e6a 100644 --- a/app/src/organisms/ModuleCard/__tests__/TestShakeSlideout.test.tsx +++ b/app/src/organisms/ModuleCard/__tests__/TestShakeSlideout.test.tsx @@ -5,14 +5,14 @@ import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' import { renderWithProviders } from '@opentrons/components' import { getIsHeaterShakerAttached } from '../../../redux/config' import { mockHeaterShaker } from '../../../redux/modules/__fixtures__' -import { HeaterShakerWizard } from '../../Devices/HeaterShakerWizard' import { useLatchControls } from '../hooks' import { TestShakeSlideout } from '../TestShakeSlideout' +import { ModuleSetupModal } from '../ModuleSetupModal' jest.mock('../../../redux/config') jest.mock('@opentrons/react-api-client') jest.mock('../hooks') -jest.mock('../../Devices/HeaterShakerWizard') +jest.mock('../ModuleSetupModal') const mockGetIsHeaterShakerAttached = getIsHeaterShakerAttached as jest.MockedFunction< typeof getIsHeaterShakerAttached @@ -23,8 +23,8 @@ const mockUseLiveCommandMutation = useCreateLiveCommandMutation as jest.MockedFu const mockUseLatchControls = useLatchControls as jest.MockedFunction< typeof useLatchControls > -const mockHeaterShakerWizard = HeaterShakerWizard as jest.MockedFunction< - typeof HeaterShakerWizard +const mockModuleSetupModal = ModuleSetupModal as jest.MockedFunction< + typeof ModuleSetupModal > const render = (props: React.ComponentProps) => { @@ -151,12 +151,12 @@ describe('TestShakeSlideout', () => { }) it('renders show attachment instructions link', () => { - mockHeaterShakerWizard.mockReturnValue(
mock HeaterShakerWizard
) + mockModuleSetupModal.mockReturnValue(
mockModuleSetupModal
) const { getByText } = render(props) const button = getByText('Show attachment instructions') fireEvent.click(button) - getByText('mock HeaterShakerWizard') + getByText('mockModuleSetupModal') }) it('start shake button should be disabled if the labware latch is open', () => { diff --git a/app/src/organisms/ModuleCard/index.tsx b/app/src/organisms/ModuleCard/index.tsx index 40c8c1c9dc2..4f559414056 100644 --- a/app/src/organisms/ModuleCard/index.tsx +++ b/app/src/organisms/ModuleCard/index.tsx @@ -45,7 +45,6 @@ import { useMenuHandleClickOutside } from '../../atoms/MenuList/hooks' import { Tooltip } from '../../atoms/Tooltip' import { StyledText } from '../../atoms/text' import { useCurrentRunStatus } from '../RunTimeControl/hooks' -import { HeaterShakerWizard } from '../Devices/HeaterShakerWizard' import { useToaster } from '../ToasterOven' import { MagneticModuleData } from './MagneticModuleData' import { TemperatureModuleData } from './TemperatureModuleData' @@ -69,6 +68,7 @@ import type { } from '../../redux/modules/types' import type { State, Dispatch } from '../../redux/types' import type { RequestState } from '../../redux/robot-api/types' +import { ModuleSetupModal } from './ModuleSetupModal' interface ModuleCardProps { module: AttachedModule @@ -240,9 +240,9 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => { /> ) : null} {showHSWizard && module.moduleType === HEATERSHAKER_MODULE_TYPE && ( - setShowHSWizard(false)} - attachedModule={module} + setShowHSWizard(false)} + moduleDisplayName={getModuleDisplayName(module.moduleModel)} /> )} {showSlideout && ( 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/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/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 ? ( 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} 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( 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 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: 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' 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 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..e70520fb05f 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 # type: ignore + ) diff --git a/update-server/otupdate/openembedded/update_actions.py b/update-server/otupdate/openembedded/update_actions.py index c2f34ceb21f..45dff930cdc 100644 --- a/update-server/otupdate/openembedded/update_actions.py +++ b/update-server/otupdate/openembedded/update_actions.py @@ -127,7 +127,7 @@ def write_update( # check that the uncompressed size is greater than the partition size partition_size = PartitionManager.get_partition_size(part.path) - if total_size >= partition_size: + if total_size > partition_size: msg = f"Write failed, update size ({total_size}) is larger than partition size {part.path} ({partition_size})." LOG.error(msg) return False, msg