(
,
{
+ initialState: {} as State,
i18nInstance: i18n,
}
)[0]
@@ -95,7 +97,6 @@ const render = () => {
describe('ProtocolRunSetup', () => {
beforeEach(() => {
- mockMissingSteps = []
when(vi.mocked(useIsFlex)).calledWith(ROBOT_NAME).thenReturn(false)
when(vi.mocked(useMostRecentCompletedAnalysis))
.calledWith(RUN_ID)
@@ -103,6 +104,9 @@ describe('ProtocolRunSetup', () => {
...noModulesProtocol,
...MOCK_PROTOCOL_LIQUID_KEY,
} as any)
+ when(vi.mocked(getMissingSetupSteps))
+ .calledWith(expect.any(Object), RUN_ID)
+ .thenReturn([])
when(vi.mocked(useProtocolAnalysisErrors)).calledWith(RUN_ID).thenReturn({
analysisErrors: null,
})
@@ -112,6 +116,27 @@ describe('ProtocolRunSetup', () => {
...noModulesProtocol,
...MOCK_PROTOCOL_LIQUID_KEY,
} as unknown) as SharedData.ProtocolAnalysisOutput)
+ when(vi.mocked(useRequiredSetupStepsInOrder))
+ .calledWith({
+ runId: RUN_ID,
+ protocolAnalysis: expect.any(Object),
+ })
+ .thenReturn({
+ orderedSteps: [
+ ReduxRuns.ROBOT_CALIBRATION_STEP_KEY,
+ ReduxRuns.MODULE_SETUP_STEP_KEY,
+ ReduxRuns.LPC_STEP_KEY,
+ ReduxRuns.LABWARE_SETUP_STEP_KEY,
+ ReduxRuns.LIQUID_SETUP_STEP_KEY,
+ ],
+ orderedApplicableSteps: [
+ ReduxRuns.ROBOT_CALIBRATION_STEP_KEY,
+ ReduxRuns.MODULE_SETUP_STEP_KEY,
+ ReduxRuns.LPC_STEP_KEY,
+ ReduxRuns.LABWARE_SETUP_STEP_KEY,
+ ReduxRuns.LIQUID_SETUP_STEP_KEY,
+ ],
+ })
vi.mocked(parseAllRequiredModuleModels).mockReturnValue([])
vi.mocked(parseLiquidsInLoadOrder).mockReturnValue([])
when(vi.mocked(useRobot))
@@ -179,6 +204,20 @@ describe('ProtocolRunSetup', () => {
when(vi.mocked(useStoredProtocolAnalysis))
.calledWith(RUN_ID)
.thenReturn(null)
+ when(vi.mocked(useRequiredSetupStepsInOrder))
+ .calledWith({ runId: RUN_ID, protocolAnalysis: null })
+ .thenReturn({
+ orderedSteps: [
+ ReduxRuns.ROBOT_CALIBRATION_STEP_KEY,
+ ReduxRuns.LPC_STEP_KEY,
+ ReduxRuns.LABWARE_SETUP_STEP_KEY,
+ ],
+ orderedApplicableSteps: [
+ ReduxRuns.ROBOT_CALIBRATION_STEP_KEY,
+ ReduxRuns.LPC_STEP_KEY,
+ ReduxRuns.LABWARE_SETUP_STEP_KEY,
+ ],
+ })
render()
screen.getByText('Loading data...')
})
diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx
index a00335f6475..d73d402585d 100644
--- a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx
+++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx
@@ -8,7 +8,7 @@ import {
RUN_STATUS_RUNNING,
RUN_STATUS_STOP_REQUESTED,
} from '@opentrons/api-client'
-import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware'
+import { getLoadedLabwareDefinitionsByUri } from '@opentrons/shared-data'
import { renderWithProviders } from '/app/__testing-utils__'
import { i18n } from '/app/i18n'
@@ -33,7 +33,13 @@ vi.mock('/app/redux/config')
vi.mock('../RecoverySplash')
vi.mock('/app/redux-resources/analytics')
vi.mock('@opentrons/react-api-client')
-vi.mock('/app/local-resources/labware')
+vi.mock('@opentrons/shared-data', async () => {
+ const actual = await vi.importActual('@opentrons/shared-data')
+ return {
+ ...actual,
+ getLoadedLabwareDefinitionsByUri: vi.fn(),
+ }
+})
vi.mock('react-redux', async () => {
const actual = await vi.importActual('react-redux')
return {
@@ -45,7 +51,6 @@ vi.mock('react-redux', async () => {
describe('useErrorRecoveryFlows', () => {
beforeEach(() => {
vi.mocked(useCurrentlyRecoveringFrom).mockReturnValue('mockCommand' as any)
- vi.mocked(getLabwareDefinitionsFromCommands).mockReturnValue([])
})
it('should have initial state of isERActive as false', () => {
@@ -143,7 +148,7 @@ describe('ErrorRecoveryFlows', () => {
runStatus: RUN_STATUS_AWAITING_RECOVERY,
failedCommandByRunRecord: mockFailedCommand,
runId: 'MOCK_RUN_ID',
- protocolAnalysis: {} as any,
+ protocolAnalysis: null,
}
vi.mocked(ErrorRecoveryWizard).mockReturnValue(MOCK WIZARD
)
vi.mocked(RecoverySplash).mockReturnValue(MOCK RUN PAUSED SPLASH
)
@@ -168,6 +173,7 @@ describe('ErrorRecoveryFlows', () => {
intent: 'recovering',
showTakeover: false,
})
+ vi.mocked(getLoadedLabwareDefinitionsByUri).mockReturnValue({})
})
it('renders the wizard when showERWizard is true', () => {
diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx
index ab12a0e7280..b0716af5c8a 100644
--- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx
+++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { screen } from '@testing-library/react'
+import { screen, renderHook } from '@testing-library/react'
import { renderWithProviders } from '/app/__testing-utils__'
import { i18n } from '/app/i18n'
@@ -168,8 +168,8 @@ const TestWrapper = (props: GetRelevantLwLocationsParams) => {
const displayLocation = useRelevantFailedLwLocations(props)
return (
<>
- {`Current Loc: ${displayLocation.currentLoc}`}
- {`New Loc: ${displayLocation.newLoc}`}
+ {`Current Loc: ${displayLocation.displayNameCurrentLoc}`}
+ {`New Loc: ${displayLocation.displayNameNewLoc}`}
>
)
}
@@ -181,7 +181,7 @@ const render = (props: ComponentProps) => {
}
describe('useRelevantFailedLwLocations', () => {
- const mockProtocolAnalysis = {} as any
+ const mockRunRecord = { data: { modules: [], labware: [] } } as any
const mockFailedLabware = {
location: { slotName: 'D1' },
} as any
@@ -194,14 +194,25 @@ describe('useRelevantFailedLwLocations', () => {
render({
failedLabware: mockFailedLabware,
failedCommandByRunRecord: mockFailedCommand,
- protocolAnalysis: mockProtocolAnalysis,
+ runRecord: mockRunRecord,
})
screen.getByText('Current Loc: Slot D1')
screen.getByText('New Loc: null')
+
+ const { result } = renderHook(() =>
+ useRelevantFailedLwLocations({
+ failedLabware: mockFailedLabware,
+ failedCommandByRunRecord: mockFailedCommand,
+ runRecord: mockRunRecord,
+ })
+ )
+
+ expect(result.current.currentLoc).toStrictEqual({ slotName: 'D1' })
+ expect(result.current.newLoc).toBeNull()
})
- it('should return current and new location for moveLabware commands', () => {
+ it('should return current and new locations for moveLabware commands', () => {
const mockFailedCommand = {
commandType: 'moveLabware',
params: {
@@ -212,10 +223,21 @@ describe('useRelevantFailedLwLocations', () => {
render({
failedLabware: mockFailedLabware,
failedCommandByRunRecord: mockFailedCommand,
- protocolAnalysis: mockProtocolAnalysis,
+ runRecord: mockRunRecord,
})
screen.getByText('Current Loc: Slot D1')
screen.getByText('New Loc: Slot C2')
+
+ const { result } = renderHook(() =>
+ useRelevantFailedLwLocations({
+ failedLabware: mockFailedLabware,
+ failedCommandByRunRecord: mockFailedCommand,
+ runRecord: mockRunRecord,
+ })
+ )
+
+ expect(result.current.currentLoc).toStrictEqual({ slotName: 'D1' })
+ expect(result.current.newLoc).toStrictEqual({ slotName: 'C2' })
})
})
diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts
index 95dac5abdb7..06453d06d08 100644
--- a/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts
+++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts
@@ -2,7 +2,6 @@ import { useMemo } from 'react'
import {
getDeckDefFromRobotType,
- getLoadedLabwareDefinitionsByUri,
getFixedTrashLabwareDefinition,
getModuleDef2,
getPositionFromSlotId,
@@ -11,6 +10,11 @@ import {
THERMOCYCLER_MODULE_V1,
} from '@opentrons/shared-data'
+import {
+ getRunLabwareRenderInfo,
+ getRunModuleRenderInfo,
+} from '/app/organisms/InterventionModal/utils'
+
import type { Run } from '@opentrons/api-client'
import type {
DeckDefinition,
@@ -22,14 +26,21 @@ import type {
LoadedLabware,
RobotType,
LabwareDefinitionsByUri,
+ LoadedModule,
} from '@opentrons/shared-data'
import type { ErrorRecoveryFlowsProps } from '..'
import type { UseFailedLabwareUtilsResult } from './useFailedLabwareUtils'
+import type {
+ RunLabwareInfo,
+ RunModuleInfo,
+} from '/app/organisms/InterventionModal/utils'
+import type { ERUtilsProps } from './useERUtils'
interface UseDeckMapUtilsProps {
runId: ErrorRecoveryFlowsProps['runId']
protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis']
failedLabwareUtils: UseFailedLabwareUtilsResult
+ labwareDefinitionsByUri: ERUtilsProps['labwareDefinitionsByUri']
runRecord?: Run
}
@@ -37,6 +48,11 @@ export interface UseDeckMapUtilsResult {
deckConfig: CutoutConfigProtocolSpec[]
modulesOnDeck: RunCurrentModulesOnDeck[]
labwareOnDeck: RunCurrentLabwareOnDeck[]
+ loadedLabware: LoadedLabware[]
+ loadedModules: LoadedModule[]
+ movedLabwareDef: LabwareDefinition2 | null
+ moduleRenderInfo: RunModuleInfo[]
+ labwareRenderInfo: RunLabwareInfo[]
highlightLabwareEventuallyIn: string[]
kind: 'intervention'
robotType: RobotType
@@ -47,19 +63,12 @@ export function useDeckMapUtils({
runRecord,
runId,
failedLabwareUtils,
+ labwareDefinitionsByUri,
}: UseDeckMapUtilsProps): UseDeckMapUtilsResult {
const robotType = protocolAnalysis?.robotType ?? OT2_ROBOT_TYPE
const deckConfig = getSimplestDeckConfigForProtocol(protocolAnalysis)
const deckDef = getDeckDefFromRobotType(robotType)
- const labwareDefinitionsByUri = useMemo(
- () =>
- protocolAnalysis != null
- ? getLoadedLabwareDefinitionsByUri(protocolAnalysis?.commands)
- : null,
- [protocolAnalysis]
- )
-
const currentModulesInfo = useMemo(
() =>
getRunCurrentModulesInfo({
@@ -93,6 +102,35 @@ export function useDeckMapUtils({
[runId, protocolAnalysis, runRecord, deckDef, failedLabwareUtils]
)
+ const movedLabwareDef =
+ labwareDefinitionsByUri != null && failedLabwareUtils.failedLabware != null
+ ? labwareDefinitionsByUri[failedLabwareUtils.failedLabware.definitionUri]
+ : null
+
+ const moduleRenderInfo = useMemo(
+ () =>
+ runRecord != null && labwareDefinitionsByUri != null
+ ? getRunModuleRenderInfo(
+ runRecord.data,
+ deckDef,
+ labwareDefinitionsByUri
+ )
+ : [],
+ [deckDef, labwareDefinitionsByUri, runRecord]
+ )
+
+ const labwareRenderInfo = useMemo(
+ () =>
+ runRecord != null && labwareDefinitionsByUri != null
+ ? getRunLabwareRenderInfo(
+ runRecord.data,
+ labwareDefinitionsByUri,
+ deckDef
+ )
+ : [],
+ [deckDef, labwareDefinitionsByUri, runRecord]
+ )
+
return {
deckConfig,
modulesOnDeck: runCurrentModules.map(
@@ -112,6 +150,11 @@ export function useDeckMapUtils({
.filter(maybeSlot => maybeSlot != null) as string[],
kind: 'intervention',
robotType,
+ loadedModules: runRecord?.data.modules ?? [],
+ loadedLabware: runRecord?.data.labware ?? [],
+ movedLabwareDef,
+ moduleRenderInfo,
+ labwareRenderInfo,
}
}
diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts
index ee8c4de6b83..57691a30e55 100644
--- a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts
+++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts
@@ -20,7 +20,11 @@ import { useShowDoorInfo } from './useShowDoorInfo'
import { useCleanupRecoveryState } from './useCleanupRecoveryState'
import { useFailedPipetteUtils } from './useFailedPipetteUtils'
-import type { LabwareDefinition2, RobotType } from '@opentrons/shared-data'
+import type {
+ LabwareDefinition2,
+ LabwareDefinitionsByUri,
+ RobotType,
+} from '@opentrons/shared-data'
import type { IRecoveryMap, RouteStep, RecoveryRoute } from '../types'
import type { ErrorRecoveryFlowsProps } from '..'
import type { UseRouteUpdateActionsResult } from './useRouteUpdateActions'
@@ -48,6 +52,7 @@ export type ERUtilsProps = Omit & {
failedCommand: ReturnType
showTakeover: boolean
allRunDefs: LabwareDefinition2[]
+ labwareDefinitionsByUri: LabwareDefinitionsByUri | null
}
export interface ERUtilsResults {
@@ -82,6 +87,7 @@ export function useERUtils({
runStatus,
showTakeover,
allRunDefs,
+ labwareDefinitionsByUri,
}: ERUtilsProps): ERUtilsResults {
const { data: attachedInstruments } = useInstrumentsQuery()
const { data: runRecord } = useNotifyRunQuery(runId)
@@ -168,6 +174,7 @@ export function useERUtils({
runRecord,
protocolAnalysis,
failedLabwareUtils,
+ labwareDefinitionsByUri,
})
const recoveryActionMutationUtils = useRecoveryActionMutation(
diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts
index 239cb6f9e3d..d108bfb7d0a 100644
--- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts
+++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts
@@ -26,6 +26,7 @@ import type {
DispenseRunTimeCommand,
LiquidProbeRunTimeCommand,
MoveLabwareRunTimeCommand,
+ LabwareLocation,
} from '@opentrons/shared-data'
import type { LabwareDisplayLocationSlotOnly } from '/app/local-resources/labware'
import type { ErrorRecoveryFlowsProps } from '..'
@@ -40,8 +41,10 @@ interface UseFailedLabwareUtilsProps {
}
interface RelevantFailedLabwareLocations {
- currentLoc: string
- newLoc: string | null
+ displayNameCurrentLoc: string
+ displayNameNewLoc: string | null
+ currentLoc: LabwareLocation | null
+ newLoc: LabwareLocation | null
}
export type UseFailedLabwareUtilsResult = UseTipSelectionUtilsResult & {
@@ -53,6 +56,7 @@ export type UseFailedLabwareUtilsResult = UseTipSelectionUtilsResult & {
relevantWellName: string | null
/* The user-content nickname of the failed labware, if any */
failedLabwareNickname: string | null
+ /* Details relating to the labware location. */
failedLabwareLocations: RelevantFailedLabwareLocations
}
@@ -103,7 +107,7 @@ export function useFailedLabwareUtils({
const failedLabwareLocations = useRelevantFailedLwLocations({
failedLabware,
failedCommandByRunRecord,
- protocolAnalysis,
+ runRecord,
})
return {
@@ -336,7 +340,7 @@ export function getRelevantWellName(
export type GetRelevantLwLocationsParams = Pick<
UseFailedLabwareUtilsProps,
- 'protocolAnalysis' | 'failedCommandByRunRecord'
+ 'runRecord' | 'failedCommandByRunRecord'
> & {
failedLabware: UseFailedLabwareUtilsResult['failedLabware']
}
@@ -344,7 +348,7 @@ export type GetRelevantLwLocationsParams = Pick<
export function useRelevantFailedLwLocations({
failedLabware,
failedCommandByRunRecord,
- protocolAnalysis,
+ runRecord,
}: GetRelevantLwLocationsParams): RelevantFailedLabwareLocations {
const { t } = useTranslation('protocol_command_text')
@@ -352,33 +356,43 @@ export function useRelevantFailedLwLocations({
LabwareDisplayLocationSlotOnly,
'location'
> = {
- loadedLabwares: protocolAnalysis?.labware ?? [],
- loadedModules: protocolAnalysis?.modules ?? [],
+ loadedLabwares: runRecord?.data?.labware ?? [],
+ loadedModules: runRecord?.data?.modules ?? [],
robotType: FLEX_ROBOT_TYPE,
t,
detailLevel: 'slot-only',
isOnDevice: false, // Always return the "slot XYZ" copy, which is the desktop copy.
}
- const currentLocation = getLabwareDisplayLocation({
+ const displayNameCurrentLoc = getLabwareDisplayLocation({
...BASE_DISPLAY_PARAMS,
location: failedLabware?.location ?? null,
})
- const getNewLocation = (): string | null => {
+ const getNewLocation = (): Pick<
+ RelevantFailedLabwareLocations,
+ 'displayNameNewLoc' | 'newLoc'
+ > => {
switch (failedCommandByRunRecord?.commandType) {
case 'moveLabware':
- return getLabwareDisplayLocation({
- ...BASE_DISPLAY_PARAMS,
- location: failedCommandByRunRecord.params.newLocation,
- })
+ return {
+ displayNameNewLoc: getLabwareDisplayLocation({
+ ...BASE_DISPLAY_PARAMS,
+ location: failedCommandByRunRecord.params.newLocation,
+ }),
+ newLoc: failedCommandByRunRecord.params.newLocation,
+ }
default:
- return null
+ return {
+ displayNameNewLoc: null,
+ newLoc: null,
+ }
}
}
return {
- currentLoc: currentLocation,
- newLoc: getNewLocation(),
+ displayNameCurrentLoc,
+ currentLoc: failedLabware?.location ?? null,
+ ...getNewLocation(),
}
}
diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx
index 2bd26beb747..124c4fea65f 100644
--- a/app/src/organisms/ErrorRecoveryFlows/index.tsx
+++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx
@@ -13,11 +13,13 @@ import {
RUN_STATUS_STOP_REQUESTED,
RUN_STATUS_SUCCEEDED,
} from '@opentrons/api-client'
-import { OT2_ROBOT_TYPE } from '@opentrons/shared-data'
+import {
+ getLoadedLabwareDefinitionsByUri,
+ OT2_ROBOT_TYPE,
+} from '@opentrons/shared-data'
import { useHost } from '@opentrons/react-api-client'
import { getIsOnDevice } from '/app/redux/config'
-import { getLabwareDefinitionsFromCommands } from '/app/local-resources/labware'
import { ErrorRecoveryWizard, useERWizard } from './ErrorRecoveryWizard'
import { RecoverySplash, useRecoverySplash } from './RecoverySplash'
import { RecoveryTakeover } from './RecoveryTakeover'
@@ -127,13 +129,19 @@ export function ErrorRecoveryFlows(
const robotName = useHost()?.robotName ?? 'robot'
const isValidRobotSideAnalysis = protocolAnalysis != null
- const allRunDefs = useMemo(
+
+ // TODO(jh, 10-22-24): EXEC-769.
+ const labwareDefinitionsByUri = useMemo(
() =>
protocolAnalysis != null
- ? getLabwareDefinitionsFromCommands(protocolAnalysis.commands)
- : [],
+ ? getLoadedLabwareDefinitionsByUri(protocolAnalysis?.commands)
+ : null,
[isValidRobotSideAnalysis]
)
+ const allRunDefs =
+ labwareDefinitionsByUri != null
+ ? Object.values(labwareDefinitionsByUri)
+ : []
const {
showTakeover,
@@ -151,6 +159,7 @@ export function ErrorRecoveryFlows(
showTakeover,
failedCommand: failedCommandBySource,
allRunDefs,
+ labwareDefinitionsByUri,
})
const renderWizard =
diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/GripperReleaseLabware.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/GripperReleaseLabware.tsx
index f57357f6451..3e0c24756d2 100644
--- a/app/src/organisms/ErrorRecoveryFlows/shared/GripperReleaseLabware.tsx
+++ b/app/src/organisms/ErrorRecoveryFlows/shared/GripperReleaseLabware.tsx
@@ -7,6 +7,7 @@ import {
RESPONSIVENESS,
Flex,
StyledText,
+ JUSTIFY_CENTER,
} from '@opentrons/components'
import { TwoColumn } from '/app/molecules/InterventionModal'
@@ -15,6 +16,8 @@ import { RECOVERY_MAP } from '/app/organisms/ErrorRecoveryFlows/constants'
import { RecoveryFooterButtons } from './RecoveryFooterButtons'
import { RecoverySingleColumnContentWrapper } from './RecoveryContentWrapper'
+import gripperReleaseAnimation from '/app/assets/videos/error-recovery/Gripper_Release.webm'
+
import type { JSX } from 'react'
import type { RecoveryContentProps } from '../types'
@@ -51,7 +54,20 @@ export function GripperReleaseLabware({
heading={t('labware_released_from_current_height')}
/>
- ANIMATION GOES HERE
+
+
+
['infoProps']['newLocationProps'] =>
- newLoc != null ? { deckLabel: newLoc.toUpperCase() } : undefined
+ displayNameNewLoc != null
+ ? { deckLabel: displayNameNewLoc.toUpperCase() }
+ : undefined
return (
{
+ switch (selectedRecoveryOption) {
+ case MANUAL_MOVE_AND_SKIP.ROUTE: {
+ const { newLoc, currentLoc } = failedLabwareUtils.failedLabwareLocations
+ const {
+ movedLabwareDef,
+ moduleRenderInfo,
+ labwareRenderInfo,
+ ...restUtils
+ } = deckMapUtils
+
+ const failedLwId = failedLabware?.id ?? ''
+
+ const isValidDeck =
+ currentLoc != null && newLoc != null && movedLabwareDef != null
+
+ return isValidDeck ? (
+
+ {moduleRenderInfo.map(
+ ({
+ x,
+ y,
+ moduleId,
+ moduleDef,
+ nestedLabwareDef,
+ nestedLabwareId,
+ }) => (
+
+ {nestedLabwareDef != null &&
+ nestedLabwareId !== failedLwId ? (
+
+ ) : null}
+
+ )
+ )}
+ {labwareRenderInfo
+ .filter(l => l.labwareId !== failedLwId)
+ .map(({ x, y, labwareDef, labwareId }) => (
+
+ {labwareDef != null && labwareId !== failedLwId ? (
+
+ ) : null}
+
+ ))}
+ >
+ }
+ />
+ ) : (
+
+ )
+ }
+ default:
+ return
+ }
+ }
+
return (
@@ -109,9 +187,7 @@ export function TwoColLwInfoAndDeck(
type={buildType()}
bannerText={buildBannerText()}
/>
-
-
-
+ {buildDeckView()}
diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx
index 9eff4a09ba4..3bdd9f97819 100644
--- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx
+++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx
@@ -9,6 +9,10 @@ import { clickButtonLabeled } from '/app/organisms/ErrorRecoveryFlows/__tests__/
import type { Mock } from 'vitest'
+vi.mock('/app/assets/videos/error-recovery/Gripper_Release.webm', () => ({
+ default: 'mocked-animation-path.webm',
+}))
+
const render = (props: React.ComponentProps) => {
return renderWithProviders(, {
i18nInstance: i18n,
@@ -48,4 +52,14 @@ describe('GripperReleaseLabware', () => {
expect(mockHandleMotionRouting).toHaveBeenCalled()
})
+
+ it('renders gripper animation', () => {
+ render(props)
+
+ screen.getByRole('presentation', { hidden: true })
+ expect(screen.getByTestId('gripper-animation')).toHaveAttribute(
+ 'src',
+ 'mocked-animation-path.webm'
+ )
+ })
})
diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx
index e2e6c268ef8..f38e1e06922 100644
--- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx
+++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx
@@ -27,8 +27,8 @@ describe('LeftColumnLabwareInfo', () => {
failedLabwareName: 'MOCK_LW_NAME',
failedLabwareNickname: 'MOCK_LW_NICKNAME',
failedLabwareLocations: {
- currentLoc: 'slot A1',
- newLoc: 'slot B2',
+ displayNameCurrentLoc: 'slot A1',
+ displayNameNewLoc: 'slot B2',
},
} as any,
type: 'location',
@@ -76,7 +76,7 @@ describe('LeftColumnLabwareInfo', () => {
})
it('does not include newLocationProps when newLoc is not provided', () => {
- props.failedLabwareUtils.failedLabwareLocations.newLoc = null
+ props.failedLabwareUtils.failedLabwareLocations.displayNameNewLoc = null
render(props)
expect(vi.mocked(InterventionContent)).toHaveBeenCalledWith(
@@ -91,9 +91,12 @@ describe('LeftColumnLabwareInfo', () => {
it('converts location labels to uppercase', () => {
props.failedLabwareUtils.failedLabwareLocations = {
- currentLoc: 'slot A1',
- newLoc: 'slot B2',
+ displayNameCurrentLoc: 'slot A1',
+ displayNameNewLoc: 'slot B2',
+ newLoc: {} as any,
+ currentLoc: {} as any,
}
+
render(props)
expect(vi.mocked(InterventionContent)).toHaveBeenCalledWith(
diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx
index 9a8fc10f5d6..08db6269c4d 100644
--- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx
+++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx
@@ -53,7 +53,10 @@ describe('SelectTips', () => {
failedLabwareUtils: {
selectedTipLocations: { A1: null },
areTipsSelected: true,
- failedLabwareLocations: { newLoc: null, currentLoc: 'A1' },
+ failedLabwareLocations: {
+ displayNameNewLoc: null,
+ displayNameCurrentLoc: 'A1',
+ },
} as any,
}
@@ -161,7 +164,10 @@ describe('SelectTips', () => {
failedLabwareUtils: {
selectedTipLocations: null,
areTipsSelected: false,
- failedLabwareLocations: { newLoc: null, currentLoc: '' },
+ failedLabwareLocations: {
+ displayNameNewLoc: null,
+ displayNameCurrentLoc: '',
+ },
} as any,
}
diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx
index f2206c8f010..2f24fc0f3bb 100644
--- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx
+++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx
@@ -1,4 +1,7 @@
import { describe, it, vi, expect, beforeEach } from 'vitest'
+import { screen } from '@testing-library/react'
+
+import { MoveLabwareOnDeck } from '@opentrons/components'
import { renderWithProviders } from '/app/__testing-utils__'
import { i18n } from '/app/i18n'
@@ -11,6 +14,13 @@ import { getSlotNameAndLwLocFrom } from '../../hooks/useDeckMapUtils'
import type * as React from 'react'
import type { Mock } from 'vitest'
+vi.mock('@opentrons/components', async () => {
+ const actual = await vi.importActual('@opentrons/components')
+ return {
+ ...actual,
+ MoveLabwareOnDeck: vi.fn(),
+ }
+})
vi.mock('../LeftColumnLabwareInfo')
vi.mock('../../hooks/useDeckMapUtils')
@@ -39,11 +49,17 @@ describe('TwoColLwInfoAndDeck', () => {
failedLabwareUtils: {
relevantWellName: 'A1',
failedLabware: { location: 'C1' },
+ failedLabwareLocations: { newLoc: {}, currentLoc: {} },
+ },
+ deckMapUtils: {
+ movedLabwareDef: {},
+ moduleRenderInfo: [],
+ labwareRenderInfo: [],
},
- deckMapUtils: {},
currentRecoveryOptionUtils: {
selectedRecoveryOption: RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE,
},
+ isOnDevice: true,
} as any
vi.mocked(LeftColumnLabwareInfo).mockReturnValue(
@@ -131,4 +147,34 @@ describe('TwoColLwInfoAndDeck', () => {
expect.anything()
)
})
+
+ it(`renders a move labware on deck view if the selected recovery option is ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE} and props are valid`, () => {
+ vi.mocked(MoveLabwareOnDeck).mockReturnValue(
+ MOCK_MOVE_LW_ON_DECK
+ )
+
+ props.currentRecoveryOptionUtils.selectedRecoveryOption =
+ RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE
+ render(props)
+
+ screen.getByText('MOCK_MOVE_LW_ON_DECK')
+ })
+
+ it(`does not render a move labware on deck view if the selected recovery option is ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE} and props are invalid`, () => {
+ vi.mocked(MoveLabwareOnDeck).mockReturnValue(
+ MOCK_MOVE_LW_ON_DECK
+ )
+
+ props.currentRecoveryOptionUtils.selectedRecoveryOption =
+ RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE
+ props.deckMapUtils = {
+ movedLabwareDef: null,
+ moduleRenderInfo: null,
+ labwareRenderInfo: null,
+ } as any
+
+ render(props)
+
+ expect(screen.queryByText('MOCK_MOVE_LW_ON_DECK')).not.toBeInTheDocument()
+ })
})
diff --git a/app/src/organisms/ODD/QuickTransferFlow/utils/generateCompatibleLabwareForPipette.ts b/app/src/organisms/ODD/QuickTransferFlow/utils/generateCompatibleLabwareForPipette.ts
index 29e0847bf02..38d62455854 100644
--- a/app/src/organisms/ODD/QuickTransferFlow/utils/generateCompatibleLabwareForPipette.ts
+++ b/app/src/organisms/ODD/QuickTransferFlow/utils/generateCompatibleLabwareForPipette.ts
@@ -14,7 +14,8 @@ export function generateCompatibleLabwareForPipette(
(acc, definition) => {
if (
definition.allowedRoles != null &&
- definition.allowedRoles.includes('adapter')
+ (definition.allowedRoles.includes('adapter') ||
+ definition.allowedRoles.includes('lid'))
) {
return acc
} else if (pipetteSpecs.channels === 1) {
diff --git a/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx b/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx
index a290e689809..bc738d0caf3 100644
--- a/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx
+++ b/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx
@@ -24,7 +24,7 @@ describe('UnskippableModal', () => {
render(props)
screen.getByText('This is a critical step that should not be skipped')
screen.getByText(
- 'You must detach the mounting plate and reattach the z-axis carraige before using other pipettes. We do not recommend exiting this process before completion.'
+ 'You must detach the mounting plate and reattach the z-axis carriage before using other pipettes. We do not recommend exiting this process before completion.'
)
fireEvent.click(screen.getByRole('button', { name: 'Go back' }))
expect(props.goBack).toHaveBeenCalled()
@@ -39,7 +39,7 @@ describe('UnskippableModal', () => {
render(props)
screen.getByText('This is a critical step that should not be skipped')
screen.getByText(
- 'You must detach the mounting plate and reattach the z-axis carraige before using other pipettes. We do not recommend exiting this process before completion.'
+ 'You must detach the mounting plate and reattach the z-axis carriage before using other pipettes. We do not recommend exiting this process before completion.'
)
screen.getByText('Exit')
screen.getByText('Go back')
diff --git a/app/src/pages/Desktop/Devices/ProtocolRunDetails/index.tsx b/app/src/pages/Desktop/Devices/ProtocolRunDetails/index.tsx
index 7ea4ceae7c6..4abb609c4fc 100644
--- a/app/src/pages/Desktop/Devices/ProtocolRunDetails/index.tsx
+++ b/app/src/pages/Desktop/Devices/ProtocolRunDetails/index.tsx
@@ -25,10 +25,7 @@ import { ApiHostProvider } from '@opentrons/react-api-client'
import { useSyncRobotClock } from '/app/organisms/Desktop/Devices/hooks'
import { ProtocolRunHeader } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader'
import { RunPreview } from '/app/organisms/Desktop/Devices/RunPreview'
-import {
- ProtocolRunSetup,
- initialMissingSteps,
-} from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunSetup'
+import { ProtocolRunSetup } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunSetup'
import { BackToTopButton } from '/app/organisms/Desktop/Devices/ProtocolRun/BackToTopButton'
import { ProtocolRunModuleControls } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunModuleControls'
import { ProtocolRunRuntimeParameters } from '/app/organisms/Desktop/Devices/ProtocolRun/ProtocolRunRunTimeParameters'
@@ -187,10 +184,6 @@ function PageContents(props: PageContentsProps): JSX.Element {
}
}, [jumpedIndex])
- const [missingSteps, setMissingSteps] = useState<
- ReturnType
- >(initialMissingSteps())
-
const makeHandleScrollToStep = (i: number) => () => {
listRef.current?.scrollToIndex(i, true, -1 * JUMP_OFFSET_FROM_TOP_PX)
}
@@ -210,8 +203,6 @@ function PageContents(props: PageContentsProps): JSX.Element {
protocolRunHeaderRef={protocolRunHeaderRef}
robotName={robotName}
runId={runId}
- setMissingSteps={setMissingSteps}
- missingSteps={missingSteps}
/>
),
backToTop: (
@@ -269,7 +260,6 @@ function PageContents(props: PageContentsProps): JSX.Element {
robotName={robotName}
runId={runId}
makeHandleJumpToStep={makeHandleJumpToStep}
- missingSetupSteps={missingSteps}
/>
{
+ const orderedSteps =
+ protocolAnalysis == null ? NO_ANALYSIS_STEPS_IN_ORDER : ALL_STEPS_IN_ORDER
+
+ const orderedApplicableSteps =
+ protocolAnalysis == null
+ ? NO_ANALYSIS_STEPS_IN_ORDER
+ : ALL_STEPS_IN_ORDER.filter((stepKey: StepKey) => {
+ if (protocolAnalysis.modules.length === 0) {
+ return stepKey !== MODULE_SETUP_STEP_KEY
+ }
+
+ if (protocolAnalysis.liquids.length === 0) {
+ return stepKey !== LIQUID_SETUP_STEP_KEY
+ }
+ return true
+ })
+ return { orderedSteps: orderedSteps as StepKey[], orderedApplicableSteps }
+}
+
+const keyFor = (
+ analysis: CompletedProtocolAnalysis | ProtocolAnalysisOutput | null
+ // @ts-expect-error(sf, 2024-10-23): purposeful weak object typing
+): string | null => analysis?.id ?? analysis?.metadata?.id ?? null
+
+export function useRequiredSetupStepsInOrder({
+ runId,
+ protocolAnalysis,
+}: UseRequiredSetupStepsInOrderProps): UseRequiredSetupStepsInOrderReturn {
+ const dispatch = useDispatch()
+ const requiredSteps = useSelector(state =>
+ getSetupStepsRequired(state, runId)
+ )
+
+ useEffect(() => {
+ const applicable = keysInOrder(protocolAnalysis)
+ dispatch(
+ updateRunSetupStepsRequired(runId, {
+ ...ALL_STEPS_IN_ORDER.reduce<
+ UpdateRunSetupStepsRequiredAction['payload']['required']
+ >(
+ (acc, thiskey) => ({
+ ...acc,
+ [thiskey]: applicable.orderedApplicableSteps.includes(thiskey),
+ }),
+ {}
+ ),
+ })
+ )
+ }, [runId, keyFor(protocolAnalysis), dispatch])
+ return protocolAnalysis == null
+ ? {
+ orderedSteps: NO_ANALYSIS_STEPS_IN_ORDER,
+ orderedApplicableSteps: NO_ANALYSIS_STEPS_IN_ORDER,
+ }
+ : {
+ orderedSteps: ALL_STEPS_IN_ORDER,
+ orderedApplicableSteps: ALL_STEPS_IN_ORDER.filter(
+ step => (requiredSteps as Required> | null)?.[step]
+ ),
+ }
+}
diff --git a/app/src/redux-resources/runs/index.ts b/app/src/redux-resources/runs/index.ts
new file mode 100644
index 00000000000..fc78d35129c
--- /dev/null
+++ b/app/src/redux-resources/runs/index.ts
@@ -0,0 +1 @@
+export * from './hooks'
diff --git a/app/src/redux/protocol-runs/__tests__/reducer.test.ts b/app/src/redux/protocol-runs/__tests__/reducer.test.ts
new file mode 100644
index 00000000000..e10ce306f7d
--- /dev/null
+++ b/app/src/redux/protocol-runs/__tests__/reducer.test.ts
@@ -0,0 +1,76 @@
+import { describe, it, expect } from 'vitest'
+
+import { protocolRunReducer } from '../reducer'
+import {
+ updateRunSetupStepsComplete,
+ updateRunSetupStepsRequired,
+} from '../actions'
+import * as Constants from '../constants'
+
+describe('protocol runs reducer', () => {
+ const INITIAL = {
+ [Constants.ROBOT_CALIBRATION_STEP_KEY]: {
+ required: true,
+ complete: false,
+ },
+ [Constants.MODULE_SETUP_STEP_KEY]: { required: true, complete: false },
+ [Constants.LPC_STEP_KEY]: { required: true, complete: false },
+ [Constants.LABWARE_SETUP_STEP_KEY]: {
+ required: true,
+ complete: false,
+ },
+ [Constants.LIQUID_SETUP_STEP_KEY]: { required: true, complete: false },
+ }
+ it('establishes an empty state if you tell it one', () => {
+ const nextState = protocolRunReducer(
+ undefined,
+ updateRunSetupStepsComplete('some-run-id', {})
+ )
+ expect(nextState['some-run-id']?.setup).toEqual(INITIAL)
+ })
+ it('updates complete based on an action', () => {
+ const nextState = protocolRunReducer(
+ {
+ 'some-run-id': {
+ setup: {
+ ...INITIAL,
+ [Constants.LIQUID_SETUP_STEP_KEY]: {
+ complete: true,
+ required: true,
+ },
+ },
+ },
+ },
+ updateRunSetupStepsComplete('some-run-id', {
+ [Constants.LPC_STEP_KEY]: true,
+ })
+ )
+ expect(nextState['some-run-id']?.setup).toEqual({
+ ...INITIAL,
+ [Constants.LIQUID_SETUP_STEP_KEY]: {
+ required: true,
+ complete: true,
+ },
+ [Constants.LPC_STEP_KEY]: { required: true, complete: true },
+ })
+ })
+ it('updates required based on an action', () => {
+ const nextState = protocolRunReducer(
+ {
+ 'some-run-id': {
+ setup: INITIAL,
+ },
+ },
+ updateRunSetupStepsRequired('some-run-id', {
+ [Constants.LIQUID_SETUP_STEP_KEY]: false,
+ })
+ )
+ expect(nextState['some-run-id']?.setup).toEqual({
+ ...INITIAL,
+ [Constants.LIQUID_SETUP_STEP_KEY]: {
+ required: false,
+ complete: false,
+ },
+ })
+ })
+})
diff --git a/app/src/redux/protocol-runs/actions.ts b/app/src/redux/protocol-runs/actions.ts
new file mode 100644
index 00000000000..378ee297ed2
--- /dev/null
+++ b/app/src/redux/protocol-runs/actions.ts
@@ -0,0 +1,18 @@
+import * as Constants from './constants'
+import type * as Types from './types'
+
+export const updateRunSetupStepsComplete = (
+ runId: string,
+ complete: Types.UpdateRunSetupStepsCompleteAction['payload']['complete']
+): Types.UpdateRunSetupStepsCompleteAction => ({
+ type: Constants.UPDATE_RUN_SETUP_STEPS_COMPLETE,
+ payload: { runId, complete },
+})
+
+export const updateRunSetupStepsRequired = (
+ runId: string,
+ required: Types.UpdateRunSetupStepsRequiredAction['payload']['required']
+): Types.UpdateRunSetupStepsRequiredAction => ({
+ type: Constants.UPDATE_RUN_SETUP_STEPS_REQUIRED,
+ payload: { runId, required },
+})
diff --git a/app/src/redux/protocol-runs/constants.ts b/app/src/redux/protocol-runs/constants.ts
new file mode 100644
index 00000000000..04f28f760d3
--- /dev/null
+++ b/app/src/redux/protocol-runs/constants.ts
@@ -0,0 +1,18 @@
+export const ROBOT_CALIBRATION_STEP_KEY: 'robot_calibration_step' =
+ 'robot_calibration_step'
+export const MODULE_SETUP_STEP_KEY: 'module_setup_step' = 'module_setup_step'
+export const LPC_STEP_KEY: 'labware_position_check_step' =
+ 'labware_position_check_step'
+export const LABWARE_SETUP_STEP_KEY: 'labware_setup_step' = 'labware_setup_step'
+export const LIQUID_SETUP_STEP_KEY: 'liquid_setup_step' = 'liquid_setup_step'
+
+export const SETUP_STEP_KEYS = [
+ ROBOT_CALIBRATION_STEP_KEY,
+ MODULE_SETUP_STEP_KEY,
+ LPC_STEP_KEY,
+ LABWARE_SETUP_STEP_KEY,
+ LIQUID_SETUP_STEP_KEY,
+] as const
+
+export const UPDATE_RUN_SETUP_STEPS_COMPLETE = 'protocolRuns:UPDATE_RUN_SETUP_STEPS_COMPLETE' as const
+export const UPDATE_RUN_SETUP_STEPS_REQUIRED = 'protocolRuns:UPDATE_RUN_SETUP_STEPS_REQUIRED' as const
diff --git a/app/src/redux/protocol-runs/index.ts b/app/src/redux/protocol-runs/index.ts
new file mode 100644
index 00000000000..9f709c0dbcb
--- /dev/null
+++ b/app/src/redux/protocol-runs/index.ts
@@ -0,0 +1,7 @@
+// runs constants, actions, selectors, and types
+
+export * from './actions'
+export * from './constants'
+export * from './selectors'
+
+export type * from './types'
diff --git a/app/src/redux/protocol-runs/reducer.ts b/app/src/redux/protocol-runs/reducer.ts
new file mode 100644
index 00000000000..0b2d8378a67
--- /dev/null
+++ b/app/src/redux/protocol-runs/reducer.ts
@@ -0,0 +1,63 @@
+import * as Constants from './constants'
+
+import type { Reducer } from 'redux'
+import type { Action } from '../types'
+
+import type { ProtocolRunState, RunSetupStatus } from './types'
+
+const INITIAL_STATE: ProtocolRunState = {}
+
+const INITIAL_SETUP_STEP_STATE = { complete: false, required: true }
+
+const INITIAL_RUN_SETUP_STATE: RunSetupStatus = {
+ [Constants.ROBOT_CALIBRATION_STEP_KEY]: INITIAL_SETUP_STEP_STATE,
+ [Constants.MODULE_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE,
+ [Constants.LPC_STEP_KEY]: INITIAL_SETUP_STEP_STATE,
+ [Constants.LABWARE_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE,
+ [Constants.LIQUID_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE,
+}
+
+export const protocolRunReducer: Reducer = (
+ state = INITIAL_STATE,
+ action
+) => {
+ switch (action.type) {
+ case Constants.UPDATE_RUN_SETUP_STEPS_COMPLETE: {
+ return {
+ ...state,
+ [action.payload.runId]: {
+ setup: Constants.SETUP_STEP_KEYS.reduce(
+ (currentState, step) => ({
+ ...currentState,
+ [step]: {
+ complete:
+ action.payload.complete[step] ?? currentState[step].complete,
+ required: currentState[step].required,
+ },
+ }),
+ state[action.payload.runId]?.setup ?? INITIAL_RUN_SETUP_STATE
+ ),
+ },
+ }
+ }
+ case Constants.UPDATE_RUN_SETUP_STEPS_REQUIRED: {
+ return {
+ ...state,
+ [action.payload.runId]: {
+ setup: Constants.SETUP_STEP_KEYS.reduce(
+ (currentState, step) => ({
+ ...currentState,
+ [step]: {
+ required:
+ action.payload.required[step] ?? currentState[step].required,
+ complete: currentState[step].complete,
+ },
+ }),
+ state[action.payload.runId]?.setup ?? INITIAL_RUN_SETUP_STATE
+ ),
+ },
+ }
+ }
+ }
+ return state
+}
diff --git a/app/src/redux/protocol-runs/selectors.ts b/app/src/redux/protocol-runs/selectors.ts
new file mode 100644
index 00000000000..ca91c7a71ab
--- /dev/null
+++ b/app/src/redux/protocol-runs/selectors.ts
@@ -0,0 +1,91 @@
+import type { State } from '../types'
+import type * as Types from './types'
+
+export const getSetupStepComplete: (
+ state: State,
+ runId: string,
+ step: Types.StepKey
+) => boolean | null = (state, runId, step) =>
+ getSetupStepsComplete(state, runId)?.[step] ?? null
+
+export const getSetupStepsComplete: (
+ state: State,
+ runId: string
+) => Types.StepMap | null = (state, runId) => {
+ const setup = state.protocolRuns[runId]?.setup
+ if (setup == null) {
+ return null
+ }
+ return (Object.entries(setup) as Array<
+ [Types.StepKey, Types.StepState]
+ >).reduce>>(
+ (acc, [step, state]) => ({
+ ...acc,
+ [step]: state.complete,
+ }),
+ {}
+ ) as Types.StepMap
+}
+
+export const getSetupStepRequired: (
+ state: State,
+ runId: string,
+ step: Types.StepKey
+) => boolean | null = (state, runId, step) =>
+ getSetupStepsRequired(state, runId)?.[step] ?? null
+
+export const getSetupStepsRequired: (
+ state: State,
+ runId: string
+) => Types.StepMap | null = (state, runId) => {
+ const setup = state.protocolRuns[runId]?.setup
+ if (setup == null) {
+ return null
+ }
+ return (Object.entries(setup) as Array<
+ [Types.StepKey, Types.StepState]
+ >).reduce>>(
+ (acc, [step, state]) => ({ ...acc, [step]: state.required }),
+ {}
+ ) as Types.StepMap
+}
+
+export const getSetupStepMissing: (
+ state: State,
+ runId: string,
+ step: Types.StepKey
+) => boolean | null = (state, runId, step) =>
+ getSetupStepsMissing(state, runId)?.[step] || null
+
+export const getSetupStepsMissing: (
+ state: State,
+ runId: string
+) => Types.StepMap | null = (state, runId) => {
+ const setup = state.protocolRuns[runId]?.setup
+ if (setup == null) {
+ return null
+ }
+ return (Object.entries(setup) as Array<
+ [Types.StepKey, Types.StepState]
+ >).reduce>>(
+ (acc, [step, state]) => ({
+ ...acc,
+ [step]: state.required && !state.complete,
+ }),
+ {}
+ ) as Types.StepMap
+}
+
+export const getMissingSetupSteps: (
+ state: State,
+ runId: string
+) => Types.StepKey[] = (state, runId) => {
+ const missingStepMap = getSetupStepsMissing(state, runId)
+ if (missingStepMap == null) return []
+ const missingStepList = (Object.entries(missingStepMap) as Array<
+ [Types.StepKey, boolean]
+ >)
+ .map(([step, missing]) => (missing ? step : null))
+ .filter(stepName => stepName != null)
+ return missingStepList as Types.StepKey[]
+}
diff --git a/app/src/redux/protocol-runs/types.ts b/app/src/redux/protocol-runs/types.ts
new file mode 100644
index 00000000000..c14d556d495
--- /dev/null
+++ b/app/src/redux/protocol-runs/types.ts
@@ -0,0 +1,61 @@
+import type {
+ ROBOT_CALIBRATION_STEP_KEY,
+ MODULE_SETUP_STEP_KEY,
+ LPC_STEP_KEY,
+ LABWARE_SETUP_STEP_KEY,
+ LIQUID_SETUP_STEP_KEY,
+ UPDATE_RUN_SETUP_STEPS_COMPLETE,
+ UPDATE_RUN_SETUP_STEPS_REQUIRED,
+} from './constants'
+
+export type RobotCalibrationStepKey = typeof ROBOT_CALIBRATION_STEP_KEY
+export type ModuleSetupStepKey = typeof MODULE_SETUP_STEP_KEY
+export type LPCStepKey = typeof LPC_STEP_KEY
+export type LabwareSetupStepKey = typeof LABWARE_SETUP_STEP_KEY
+export type LiquidSetupStepKey = typeof LIQUID_SETUP_STEP_KEY
+
+export type StepKey =
+ | RobotCalibrationStepKey
+ | ModuleSetupStepKey
+ | LPCStepKey
+ | LabwareSetupStepKey
+ | LiquidSetupStepKey
+
+export interface StepState {
+ required: boolean
+ complete: boolean
+}
+
+export type StepMap = { [Step in StepKey]: V }
+
+export type RunSetupStatus = {
+ [Step in StepKey]: StepState
+}
+
+export interface PerRunUIState {
+ setup: RunSetupStatus
+}
+
+export type ProtocolRunState = Partial<{
+ readonly [runId: string]: PerRunUIState
+}>
+
+export interface UpdateRunSetupStepsCompleteAction {
+ type: typeof UPDATE_RUN_SETUP_STEPS_COMPLETE
+ payload: {
+ runId: string
+ complete: Partial<{ [Step in StepKey]: boolean }>
+ }
+}
+
+export interface UpdateRunSetupStepsRequiredAction {
+ type: typeof UPDATE_RUN_SETUP_STEPS_REQUIRED
+ payload: {
+ runId: string
+ required: Partial<{ [Step in StepKey]: boolean }>
+ }
+}
+
+export type ProtocolRunAction =
+ | UpdateRunSetupStepsCompleteAction
+ | UpdateRunSetupStepsRequiredAction
diff --git a/app/src/redux/reducer.ts b/app/src/redux/reducer.ts
index 44831b0d70e..e21dbded781 100644
--- a/app/src/redux/reducer.ts
+++ b/app/src/redux/reducer.ts
@@ -48,6 +48,9 @@ import { calibrationReducer } from './calibration/reducer'
// local protocol storage from file system state
import { protocolStorageReducer } from './protocol-storage/reducer'
+// local protocol run state
+import { protocolRunReducer } from './protocol-runs/reducer'
+
import type { Reducer } from 'redux'
import type { State, Action } from './types'
@@ -68,4 +71,5 @@ export const rootReducer: Reducer = combineReducers({
sessions: sessionReducer,
calibration: calibrationReducer,
protocolStorage: protocolStorageReducer,
+ protocolRuns: protocolRunReducer,
})
diff --git a/app/src/redux/types.ts b/app/src/redux/types.ts
index 9ed69c3e71f..d3f502cdc40 100644
--- a/app/src/redux/types.ts
+++ b/app/src/redux/types.ts
@@ -37,6 +37,8 @@ import type { AlertsState, AlertsAction } from './alerts/types'
import type { SessionState, SessionsAction } from './sessions/types'
import type { AnalyticsTriggerAction } from './analytics/types'
+import type { ProtocolRunState, ProtocolRunAction } from './protocol-runs/types'
+
export interface State {
readonly robotApi: RobotApiState
readonly robotAdmin: RobotAdminState
@@ -54,6 +56,7 @@ export interface State {
readonly sessions: SessionState
readonly calibration: CalibrationState
readonly protocolStorage: ProtocolStorageState
+ readonly protocolRuns: ProtocolRunState
}
export type Action =
@@ -78,6 +81,7 @@ export type Action =
| CalibrationAction
| AnalyticsTriggerAction
| AddCustomLabwareFromCreatorAction
+ | ProtocolRunAction
export type GetState = () => State
diff --git a/components/src/atoms/ListItem/ListItem.stories.tsx b/components/src/atoms/ListItem/ListItem.stories.tsx
index 0738b583cde..dbe4739249d 100644
--- a/components/src/atoms/ListItem/ListItem.stories.tsx
+++ b/components/src/atoms/ListItem/ListItem.stories.tsx
@@ -50,7 +50,7 @@ export const ListItemDescriptorDefault: Story = {
type: 'noActive',
children: (
mock content}
description={mock description
}
/>
@@ -63,7 +63,7 @@ export const ListItemDescriptorMini: Story = {
type: 'noActive',
children: (
mock content}
description={mock description
}
/>
diff --git a/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx b/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx
index 51d9ca9e181..12b494ea894 100644
--- a/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx
+++ b/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx
@@ -2,43 +2,34 @@ import { Flex } from '../../../primitives'
import {
ALIGN_FLEX_START,
DIRECTION_ROW,
- FLEX_AUTO,
JUSTIFY_SPACE_BETWEEN,
} from '../../../styles'
import { SPACING } from '../../../ui-style-constants'
interface ListItemDescriptorProps {
- type: 'default' | 'mini'
+ type: 'default' | 'large'
description: JSX.Element | string
content: JSX.Element | string
+ isInSlideout?: boolean
}
export const ListItemDescriptor = (
props: ListItemDescriptorProps
): JSX.Element => {
- const { description, content, type } = props
+ const { description, content, type, isInSlideout = false } = props
return (
-
+
{description}
-
- {content}
-
+ {content}
)
}
diff --git a/components/src/atoms/ListItem/__tests__/ListItem.test.tsx b/components/src/atoms/ListItem/__tests__/ListItem.test.tsx
index 2f25b883fae..9cb34e3524f 100644
--- a/components/src/atoms/ListItem/__tests__/ListItem.test.tsx
+++ b/components/src/atoms/ListItem/__tests__/ListItem.test.tsx
@@ -26,7 +26,6 @@ describe('ListItem', () => {
screen.getByText('mock listitem content')
const listItem = screen.getByTestId('ListItem_error')
expect(listItem).toHaveStyle(`backgroundColor: ${COLORS.red35}`)
- expect(listItem).toHaveStyle(`padding: 0`)
expect(listItem).toHaveStyle(`borderRadius: ${BORDERS.borderRadius4}`)
})
it('should render correct style - noActive', () => {
@@ -35,7 +34,6 @@ describe('ListItem', () => {
screen.getByText('mock listitem content')
const listItem = screen.getByTestId('ListItem_noActive')
expect(listItem).toHaveStyle(`backgroundColor: ${COLORS.grey30}`)
- expect(listItem).toHaveStyle(`padding: 0`)
expect(listItem).toHaveStyle(`borderRadius: ${BORDERS.borderRadius4}`)
})
it('should render correct style - success', () => {
@@ -44,7 +42,6 @@ describe('ListItem', () => {
screen.getByText('mock listitem content')
const listItem = screen.getByTestId('ListItem_success')
expect(listItem).toHaveStyle(`backgroundColor: ${COLORS.green35}`)
- expect(listItem).toHaveStyle(`padding: 0`)
expect(listItem).toHaveStyle(`borderRadius: ${BORDERS.borderRadius4}`)
})
it('should render correct style - warning', () => {
@@ -53,7 +50,6 @@ describe('ListItem', () => {
screen.getByText('mock listitem content')
const listItem = screen.getByTestId('ListItem_warning')
expect(listItem).toHaveStyle(`backgroundColor: ${COLORS.yellow35}`)
- expect(listItem).toHaveStyle(`padding: 0`)
expect(listItem).toHaveStyle(`borderRadius: ${BORDERS.borderRadius4}`)
})
it('should call on click when pressed', () => {
diff --git a/components/src/atoms/ListItem/index.tsx b/components/src/atoms/ListItem/index.tsx
index 39367f935c1..cb61f0a4d3c 100644
--- a/components/src/atoms/ListItem/index.tsx
+++ b/components/src/atoms/ListItem/index.tsx
@@ -56,7 +56,6 @@ export function ListItem(props: ListItemProps): JSX.Element {
background-color: ${listItemProps.backgroundColor};
width: 100%;
height: ${FLEX_MAX_CONTENT};
- padding: 0;
border-radius: ${BORDERS.borderRadius4};
@media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} {
diff --git a/components/src/atoms/Toast/index.tsx b/components/src/atoms/Toast/index.tsx
index 04a202b69cf..e4a9af243ab 100644
--- a/components/src/atoms/Toast/index.tsx
+++ b/components/src/atoms/Toast/index.tsx
@@ -370,7 +370,7 @@ export function Toast(props: ToastProps): JSX.Element {
) : null}
{message}
- {linkText ? (
+ {linkText != null ? (
{
diff --git a/components/src/images/labware/measurement-guide/index.ts b/components/src/images/labware/measurement-guide/index.ts
index b866627db1d..6b6c5e14fee 100644
--- a/components/src/images/labware/measurement-guide/index.ts
+++ b/components/src/images/labware/measurement-guide/index.ts
@@ -107,6 +107,10 @@ const FOOTPRINT_DIAGRAMS: Diagrams = {
new URL(FOOTPRINT_IMAGE_RELATIVE_PATH, import.meta.url).href,
new URL(DIMENSIONS_HEIGHT_PLATE_IMAGE_RELATIVE_PATH, import.meta.url).href,
],
+ lid: [
+ new URL(FOOTPRINT_IMAGE_RELATIVE_PATH, import.meta.url).href,
+ new URL(DIMENSIONS_HEIGHT_PLATE_IMAGE_RELATIVE_PATH, import.meta.url).href,
+ ],
}
const ALUM_BLOCK_FOOTPRINTS: Diagrams = {
diff --git a/components/src/modals/Modal.tsx b/components/src/modals/Modal.tsx
index fc823243b5d..7be1ca06340 100644
--- a/components/src/modals/Modal.tsx
+++ b/components/src/modals/Modal.tsx
@@ -6,6 +6,7 @@ import { ModalHeader } from './ModalHeader'
import { ModalShell } from './ModalShell'
import type { IconProps } from '../icons'
import type { StyleProps } from '../primitives'
+import type { Position } from './ModalShell'
type ModalType = 'info' | 'warning' | 'error'
@@ -21,6 +22,8 @@ export interface ModalProps extends StyleProps {
children?: React.ReactNode
footer?: React.ReactNode
zIndexOverlay?: number
+ showOverlay?: boolean
+ position?: Position
}
/**
@@ -38,6 +41,8 @@ export const Modal = (props: ModalProps): JSX.Element => {
titleElement1,
titleElement2,
zIndexOverlay,
+ position,
+ showOverlay,
...styleProps
} = props
@@ -72,9 +77,10 @@ export const Modal = (props: ModalProps): JSX.Element => {
backgroundColor={COLORS.white}
/>
)
-
return (
{
@@ -61,7 +71,7 @@ export function ModalShell(props: ModalShellProps): JSX.Element {
if (onOutsideClick != null) onOutsideClick(e)
}}
>
-
+
)
}
-const Overlay = styled.div<{ zIndex: string | number }>`
+const Overlay = styled.div<{ zIndex: string | number; showOverlay: boolean }>`
position: ${POSITION_ABSOLUTE};
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: ${({ zIndex }) => zIndex};
- background-color: ${COLORS.black90}${COLORS.opacity40HexCode};
+ background-color: ${({ showOverlay }) =>
+ showOverlay
+ ? `${COLORS.black90}${COLORS.opacity40HexCode}`
+ : COLORS.transparent};
cursor: ${CURSOR_DEFAULT};
`
-const ContentArea = styled.div<{ zIndex: string | number }>`
+const ContentArea = styled.div<{ zIndex: string | number; position: Position }>`
display: flex;
position: ${POSITION_ABSOLUTE};
- align-items: ${ALIGN_CENTER};
- justify-content: ${JUSTIFY_CENTER};
+ align-items: ${({ position }) =>
+ position === 'center' ? ALIGN_CENTER : ALIGN_END};
+ justify-content: ${({ position }) =>
+ position === 'center' ? JUSTIFY_CENTER : JUSTIFY_END};
top: 0;
right: 0;
bottom: 0;
diff --git a/components/src/organisms/EndUserAgreementFooter/__tests__/EndUserAgreementFooter.test.tsx b/components/src/organisms/EndUserAgreementFooter/__tests__/EndUserAgreementFooter.test.tsx
new file mode 100644
index 00000000000..a13aacfe27f
--- /dev/null
+++ b/components/src/organisms/EndUserAgreementFooter/__tests__/EndUserAgreementFooter.test.tsx
@@ -0,0 +1,21 @@
+import { describe, it, expect } from 'vitest'
+import { screen } from '@testing-library/react'
+import { renderWithProviders } from '../../../testing/utils'
+import { EndUserAgreementFooter } from '../index'
+
+const render = () => {
+ return renderWithProviders()
+}
+
+describe('EndUserAgreementFooter', () => {
+ it('should render text and links', () => {
+ render()
+ screen.getByText('Copyright © 2024 Opentrons')
+ expect(
+ screen.getByRole('link', { name: 'privacy policy' })
+ ).toHaveAttribute('href', 'https://opentrons.com/privacy-policy')
+ expect(
+ screen.getByRole('link', { name: 'end user license agreement' })
+ ).toHaveAttribute('href', 'https://opentrons.com/eula')
+ })
+})
diff --git a/components/src/organisms/EndUserAgreementFooter/index.tsx b/components/src/organisms/EndUserAgreementFooter/index.tsx
new file mode 100644
index 00000000000..5e40b205665
--- /dev/null
+++ b/components/src/organisms/EndUserAgreementFooter/index.tsx
@@ -0,0 +1,49 @@
+import { StyledText } from '../../atoms'
+import { COLORS } from '../../helix-design-system'
+import { Flex, Link } from '../../primitives'
+import {
+ ALIGN_CENTER,
+ DIRECTION_COLUMN,
+ TEXT_DECORATION_UNDERLINE,
+} from '../../styles'
+import { SPACING } from '../../ui-style-constants'
+
+const PRIVACY_POLICY_URL = 'https://opentrons.com/privacy-policy'
+const EULA_URL = 'https://opentrons.com/eula'
+
+export function EndUserAgreementFooter(): JSX.Element {
+ return (
+
+
+ By continuing, you agree to the Opentrons{' '}
+
+ privacy policy
+ {' '}
+ and{' '}
+
+ end user license agreement
+
+
+
+ Copyright © 2024 Opentrons
+
+
+ )
+}
diff --git a/components/src/organisms/Toolbox/index.tsx b/components/src/organisms/Toolbox/index.tsx
index 4346806b861..e7437e1e57f 100644
--- a/components/src/organisms/Toolbox/index.tsx
+++ b/components/src/organisms/Toolbox/index.tsx
@@ -76,18 +76,18 @@ export function Toolbox(props: ToolboxProps): JSX.Element {
...(side === 'left' && { left: '0' }),
...(horizontalSide === 'bottom' && { bottom: '0' }),
...(horizontalSide === 'top' && { top: '5rem' }),
+ zIndex: 10,
}
: {}
return (
{
beforeEach(() => {
- cy.visit('/')
- cy.viewport('macbook-15')
+ navigateToUrl('/')
})
it('successfully loads', () => {
diff --git a/labware-library/cypress/e2e/labware-creator/create.cy.js b/labware-library/cypress/e2e/labware-creator/create.cy.js
index 299a3444a86..917b263cb78 100644
--- a/labware-library/cypress/e2e/labware-creator/create.cy.js
+++ b/labware-library/cypress/e2e/labware-creator/create.cy.js
@@ -2,10 +2,11 @@
// an element is in view before clicking or checking with
// { force: true }
+import { navigateToUrl } from '../../support/e2e'
+
context('The Labware Creator Landing Page', () => {
beforeEach(() => {
- cy.visit('/#/create')
- cy.viewport('macbook-15')
+ navigateToUrl('/#/create')
})
describe('The initial text', () => {
diff --git a/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js b/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js
index 319e7f4ea81..b7f9cbbcc30 100644
--- a/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js
+++ b/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js
@@ -1,13 +1,13 @@
-import 'cypress-file-upload'
-import { expectDeepEqual } from '@opentrons/shared-data/js/cypressUtils'
-
-const expectedExportFixture =
- '../fixtures/somerackbrand_24_tuberack_1500ul.json'
+import {
+ navigateToUrl,
+ fileHelper,
+ wellBottomImageLocator,
+} from '../../support/e2e'
+const fileHolder = fileHelper('somerackbrand_24_tuberack_1500ul')
context('Tubes and Rack', () => {
before(() => {
- cy.visit('/#/create')
- cy.viewport('macbook-15')
+ navigateToUrl('/#/create')
})
describe('Custom 6 x 4 tube rack', () => {
@@ -109,24 +109,29 @@ context('Tubes and Rack', () => {
cy.contains('Diameter is a required field').should('not.exist')
// well bottom shape and depth
+ // check flat
cy.get("input[name='wellBottomShape'][value='flat']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
+
+ // check u shaped
cy.get("input[name='wellBottomShape'][value='u']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
+
+ // check v shaped
cy.get("input[name='wellBottomShape'][value='v']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('exist')
cy.get("input[name='wellDepth']").focus().blur()
cy.contains('Depth is a required field').should('exist')
cy.get("input[name='wellDepth']").type('100').blur()
@@ -159,28 +164,20 @@ context('Tubes and Rack', () => {
cy.get(
"input[placeholder='somerackbrand 24 Tube Rack with sometubebrand 1.5 mL']"
).should('exist')
- cy.get("input[placeholder='somerackbrand_24_tuberack_1500ul']").should(
+ cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should(
'exist'
)
-
- // now try again with all fields inputed
- cy.fixture(expectedExportFixture).then(expectedExportLabwareDef => {
- cy.get('button').contains('EXPORT FILE').click()
-
- cy.window()
- .its('__lastSavedFileBlob__')
- .should('be.a', 'blob')
- .should(async blob => {
- const labwareDefText = await blob.text()
- const savedDef = JSON.parse(labwareDefText)
-
- expectDeepEqual(assert, savedDef, expectedExportLabwareDef)
+ cy.get('button').contains('EXPORT FILE').click()
+
+ cy.fixture(fileHolder.expectedExportFixture).then(
+ expectedExportLabwareDef => {
+ cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => {
+ expect(actualExportLabwareDef).to.deep.equal(
+ expectedExportLabwareDef
+ )
})
-
- cy.window()
- .its('__lastSavedFileName__')
- .should('equal', `somerackbrand_24_tuberack_1500ul.json`)
- })
+ }
+ )
})
})
})
diff --git a/labware-library/cypress/e2e/labware-creator/fileImport.cy.js b/labware-library/cypress/e2e/labware-creator/fileImport.cy.js
index e0fc480107f..616edca7d5b 100644
--- a/labware-library/cypress/e2e/labware-creator/fileImport.cy.js
+++ b/labware-library/cypress/e2e/labware-creator/fileImport.cy.js
@@ -1,11 +1,15 @@
-import { expectDeepEqual } from '@opentrons/shared-data/js/cypressUtils'
+import {
+ navigateToUrl,
+ fileHelper,
+ wellBottomImageLocator,
+} from '../../support/e2e'
+const fileHolder = fileHelper('testpro_15_wellplate_5ul')
const importedLabwareFile = 'TestLabwareDefinition.json'
describe('File Import', () => {
before(() => {
- cy.visit('/#/create')
- cy.viewport('macbook-15')
+ navigateToUrl('/#/create')
})
it('tests the file import flow', () => {
@@ -49,9 +53,9 @@ describe('File Import', () => {
// verify well bottom and depth
cy.get("input[name='wellBottomShape'][value='flat']").should('exist')
- cy.get("img[src*='_flat.']").should('exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellDepth'][value='5']").should('exist')
// verify grid spacing
@@ -69,7 +73,9 @@ describe('File Import', () => {
// File info
cy.get("input[placeholder='TestPro 15 Well Plate 5 µL']").should('exist')
- cy.get("input[placeholder='testpro_15_wellplate_5ul']").should('exist')
+ cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should(
+ 'exist'
+ )
// All fields present
cy.get('button[class*="_export_button_"]').click({ force: true })
@@ -77,20 +83,12 @@ describe('File Import', () => {
'Please resolve all invalid fields in order to export the labware definition'
).should('not.exist')
- cy.fixture(importedLabwareFile).then(expected => {
- cy.window()
- .its('__lastSavedFileBlob__')
- .should('be.a', 'blob') // wait until we get the blob
- .should(async blob => {
- const labwareDefText = await blob.text()
- const savedDef = JSON.parse(labwareDefText)
-
- expectDeepEqual(assert, savedDef, expected)
+ cy.fixture(fileHolder.expectedExportFixture).then(
+ expectedExportLabwareDef => {
+ cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => {
+ expect(actualExportLabwareDef).to.deep.equal(expectedExportLabwareDef)
})
- })
-
- cy.window()
- .its('__lastSavedFileName__')
- .should('equal', 'testpro_15_wellplate_5ul.json')
+ }
+ )
})
})
diff --git a/labware-library/cypress/e2e/labware-creator/reservoir.cy.js b/labware-library/cypress/e2e/labware-creator/reservoir.cy.js
index 75197208859..32b18c88a40 100644
--- a/labware-library/cypress/e2e/labware-creator/reservoir.cy.js
+++ b/labware-library/cypress/e2e/labware-creator/reservoir.cy.js
@@ -1,11 +1,13 @@
-// Scrolling seems wonky, so I disabled checking to see if
-// an element is in view before clicking or checking with
-// { force: true }
+import {
+ navigateToUrl,
+ fileHelper,
+ wellBottomImageLocator,
+} from '../../support/e2e'
+const fileHolder = fileHelper('testpro_10_reservoir_250ul')
context('Reservoirs', () => {
before(() => {
- cy.visit('/#/create')
- cy.viewport('macbook-15')
+ navigateToUrl('/#/create')
})
describe('Reservoir', () => {
@@ -143,21 +145,21 @@ context('Reservoirs', () => {
cy.get("input[name='wellBottomShape'][value='flat']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='u']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='v']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('exist')
cy.get("input[name='wellDepth']").focus().blur()
cy.contains('Depth is a required field').should('exist')
cy.get("input[name='wellDepth']").type('70').blur()
@@ -198,13 +200,24 @@ context('Reservoirs', () => {
// File info
cy.get("input[placeholder='TestPro 10 Reservoir 250 µL']").should('exist')
- cy.get("input[placeholder='testpro_10_reservoir_250ul']").should('exist')
+ cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should(
+ 'exist'
+ )
// All fields present
cy.get('button[class*="_export_button_"]').click({ force: true })
cy.contains(
'Please resolve all invalid fields in order to export the labware definition'
).should('not.exist')
+ cy.fixture(fileHolder.expectedExportFixture).then(
+ expectedExportLabwareDef => {
+ cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => {
+ expect(actualExportLabwareDef).to.deep.equal(
+ expectedExportLabwareDef
+ )
+ })
+ }
+ )
})
})
})
diff --git a/labware-library/cypress/e2e/labware-creator/tipRack.cy.js b/labware-library/cypress/e2e/labware-creator/tipRack.cy.js
index e69e3dd7285..4d27a47effc 100644
--- a/labware-library/cypress/e2e/labware-creator/tipRack.cy.js
+++ b/labware-library/cypress/e2e/labware-creator/tipRack.cy.js
@@ -1,12 +1,9 @@
-import 'cypress-file-upload'
-import { expectDeepEqual } from '@opentrons/shared-data/js/cypressUtils'
-
-const expectedExportFixture = '../fixtures/generic_1_tiprack_20ul.json'
+import { navigateToUrl, fileHelper } from '../../support/e2e'
+const fileHolder = fileHelper('generic_1_tiprack_20ul')
describe('Create a Tip Rack', () => {
before(() => {
- cy.visit('/#/create')
- cy.viewport('macbook-15')
+ navigateToUrl('/#/create')
})
it('Should create a tip rack', () => {
// Tip Rack Selection from drop down
@@ -242,26 +239,19 @@ describe('Create a Tip Rack', () => {
cy.get('input[name="displayName"]')
.clear()
.type('Brand Chalu 1 Tip Rack 20ul')
- cy.get('input[name="loadName"]').clear().type('generic_1_tiprack_20ul')
+ cy.get('input[name="loadName"]').clear().type(fileHolder.downloadFileStem)
// Verify the exported file to the fixture
cy.get('button').contains('EXPORT FILE').click()
- cy.fixture(expectedExportFixture).then(expectedExportLabwareDef => {
- cy.window()
- .its('__lastSavedFileBlob__')
- .should('be.a', 'blob')
- .should(async blob => {
- const labwareDefText = await blob.text()
- const savedDef = JSON.parse(labwareDefText)
-
- expectDeepEqual(assert, savedDef, expectedExportLabwareDef)
+ cy.fixture(fileHolder.expectedExportFixture).then(
+ expectedExportLabwareDef => {
+ cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => {
+ expect(actualExportLabwareDef).to.deep.equal(expectedExportLabwareDef)
})
- })
+ }
+ )
- cy.window()
- .its('__lastSavedFileName__')
- .should('equal', `generic_1_tiprack_20ul.json`)
// 'verify the too big, too small error
cy.get('input[name="gridOffsetY"]').clear().type('24')
cy.get('#CheckYourWork span')
diff --git a/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js b/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js
index 66ea8d0dedc..4240342390a 100644
--- a/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js
+++ b/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js
@@ -1,12 +1,13 @@
-// Scrolling seems wonky, so I disabled checking to see if
-// an element is in view before clicking or checking with
-// { force: true }
+import {
+ navigateToUrl,
+ fileHelper,
+ wellBottomImageLocator,
+} from '../../support/e2e'
+const fileHolder = fileHelper('testpro_24_aluminumblock_10ul')
context('Tubes and Block', () => {
beforeEach(() => {
- cy.visit('/#/create')
- cy.viewport('macbook-15')
-
+ navigateToUrl('/#/create')
cy.get('label')
.contains('What type of labware are you creating?')
.children()
@@ -106,21 +107,21 @@ context('Tubes and Block', () => {
cy.get("input[name='wellBottomShape'][value='flat']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='u']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='v']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('exist')
cy.get("input[name='wellDepth']").focus().blur()
cy.contains('Depth is a required field').should('exist')
cy.get("input[name='wellDepth']").type('10').blur()
@@ -232,21 +233,21 @@ context('Tubes and Block', () => {
cy.get("input[name='wellBottomShape'][value='flat']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='u']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='v']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('exist')
cy.get("input[name='wellDepth']").focus().blur()
cy.contains('Depth is a required field').should('exist')
cy.get("input[name='wellDepth']").type('10').blur()
@@ -383,21 +384,21 @@ context('Tubes and Block', () => {
cy.get("input[name='wellBottomShape'][value='flat']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='u']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='v']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('exist')
cy.get("input[name='wellDepth']").focus().blur()
cy.contains('Depth is a required field').should('exist')
cy.get("input[name='wellDepth']").type('10').blur()
@@ -445,8 +446,7 @@ context('Tubes and Block', () => {
})
it('tests the whole form and file export', () => {
- cy.visit('/#/create')
- cy.viewport('macbook-15')
+ navigateToUrl('/#/create')
cy.get('label')
.contains('What type of labware are you creating?')
.children()
@@ -533,21 +533,21 @@ context('Tubes and Block', () => {
cy.get("input[name='wellBottomShape'][value='flat']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='u']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='v']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('exist')
cy.get("input[name='wellDepth']").focus().blur()
cy.contains('Depth is a required field').should('exist')
cy.get("input[name='wellDepth']").type('10').blur()
@@ -575,7 +575,7 @@ context('Tubes and Block', () => {
cy.get("input[placeholder='TestPro 24 Aluminum Block 10 µL']").should(
'exist'
)
- cy.get("input[placeholder='testpro_24_aluminumblock_10ul']").should(
+ cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should(
'exist'
)
@@ -584,6 +584,18 @@ context('Tubes and Block', () => {
cy.contains(
'Please resolve all invalid fields in order to export the labware definition'
).should('not.exist')
+
+ cy.fixture(fileHolder.expectedExportFixture).then(
+ expectedExportLabwareDef => {
+ cy.readFile(fileHolder.downloadPath).then(
+ actualExportLabwareDef => {
+ expect(actualExportLabwareDef).to.deep.equal(
+ expectedExportLabwareDef
+ )
+ }
+ )
+ }
+ )
})
})
})
diff --git a/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js b/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js
index 4214f215dc0..0409221b6a4 100644
--- a/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js
+++ b/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js
@@ -1,12 +1,9 @@
-// Scrolling seems wonky, so I disabled checking to see if
-// an element is in view before clicking or checking with
-// { force: true }
+import { navigateToUrl, wellBottomImageLocator } from '../../support/e2e'
context('Tubes and Rack', () => {
describe('Six tubes', () => {
before(() => {
- cy.visit('/#/create')
- cy.viewport('macbook-15')
+ navigateToUrl('/#/create')
cy.get('label')
.contains('What type of labware are you creating?')
.children()
@@ -95,21 +92,21 @@ context('Tubes and Rack', () => {
cy.get("input[name='wellBottomShape'][value='flat']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='u']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='v']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('exist')
cy.get("input[name='wellDepth']").focus().blur()
cy.contains('Depth is a required field').should('exist')
cy.get("input[name='wellDepth']").type('10').blur()
@@ -137,9 +134,7 @@ context('Tubes and Rack', () => {
describe('Fifteen tubes', () => {
before(() => {
- cy.visit('/#/create')
- cy.viewport('macbook-15')
-
+ navigateToUrl('#/create')
cy.get('label')
.contains('What type of labware are you creating?')
.children()
@@ -226,21 +221,21 @@ context('Tubes and Rack', () => {
cy.get("input[name='wellBottomShape'][value='flat']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='u']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='v']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('exist')
cy.get("input[name='wellDepth']").focus().blur()
cy.contains('Depth is a required field').should('exist')
cy.get("input[name='wellDepth']").type('10').blur()
@@ -268,9 +263,7 @@ context('Tubes and Rack', () => {
describe('Twentyfour tubes', () => {
before(() => {
- cy.visit('/#/create')
- cy.viewport('macbook-15')
-
+ navigateToUrl('/#/create')
cy.get('label')
.contains('What type of labware are you creating?')
.children()
@@ -356,21 +349,21 @@ context('Tubes and Rack', () => {
cy.get("input[name='wellBottomShape'][value='flat']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='u']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='v']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('exist')
cy.get("input[name='wellDepth']").focus().blur()
cy.contains('Depth is a required field').should('exist')
cy.get("input[name='wellDepth']").type('10').blur()
diff --git a/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js b/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js
index 5b27cfcfd72..df12cf153a5 100644
--- a/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js
+++ b/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js
@@ -2,14 +2,16 @@
// that cannot be imported. The creator probably shouldn't allow
// a user to do this.
-// Scrolling seems wonky, so I disabled checking to see if
-// an element is in view before clicking or checking with
-// { force: true }
+import {
+ navigateToUrl,
+ fileHelper,
+ wellBottomImageLocator,
+} from '../../support/e2e'
+const fileHolder = fileHelper('testpro_80_wellplate_100ul')
context('Well Plates', () => {
before(() => {
- cy.visit('/#/create')
- cy.viewport('macbook-15')
+ navigateToUrl('/#/create')
})
describe('Create a well plate', () => {
@@ -145,21 +147,21 @@ context('Well Plates', () => {
cy.get("input[name='wellBottomShape'][value='flat']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='u']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('exist')
- cy.get("img[src*='_v.']").should('not.exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('exist')
+ cy.get(wellBottomImageLocator.v).should('not.exist')
cy.get("input[name='wellBottomShape'][value='v']").check({
force: true,
})
- cy.get("img[src*='_flat.']").should('not.exist')
- cy.get("img[src*='_round.']").should('not.exist')
- cy.get("img[src*='_v.']").should('exist')
+ cy.get(wellBottomImageLocator.flat).should('not.exist')
+ cy.get(wellBottomImageLocator.round).should('not.exist')
+ cy.get(wellBottomImageLocator.v).should('exist')
cy.get("input[name='wellDepth']").focus().blur()
cy.contains('Depth is a required field').should('exist')
cy.get("input[name='wellDepth']").type('10').blur()
@@ -208,7 +210,9 @@ context('Well Plates', () => {
cy.get("input[placeholder='TestPro 80 Well Plate 100 µL']").should(
'exist'
)
- cy.get("input[placeholder='testpro_80_wellplate_100ul']").should('exist')
+ cy.get(`input[placeholder='${fileHolder.downloadFileStem}']`).should(
+ 'exist'
+ )
// All fields present
cy.get('button[class*="_export_button_"]').click({ force: true })
@@ -216,7 +220,15 @@ context('Well Plates', () => {
'Please resolve all invalid fields in order to export the labware definition'
).should('not.exist')
- // TODO IMMEDIATELY match against fixture ??? Is this not happening?
+ cy.fixture(fileHolder.expectedExportFixture).then(
+ expectedExportLabwareDef => {
+ cy.readFile(fileHolder.downloadPath).then(actualExportLabwareDef => {
+ expect(actualExportLabwareDef).to.deep.equal(
+ expectedExportLabwareDef
+ )
+ })
+ }
+ )
})
})
})
diff --git a/labware-library/cypress/e2e/navigation.cy.js b/labware-library/cypress/e2e/navigation.cy.js
index 83ce2dd7369..0b4c3c14a40 100644
--- a/labware-library/cypress/e2e/navigation.cy.js
+++ b/labware-library/cypress/e2e/navigation.cy.js
@@ -1,7 +1,8 @@
+import { navigateToUrl } from '../support/e2e'
+
describe('Desktop Navigation', () => {
beforeEach(() => {
- cy.visit('/')
- cy.viewport('macbook-15')
+ navigateToUrl('/')
})
it('contains the subdomain nav bar', () => {
diff --git a/labware-library/cypress/fixtures/testpro_10_reservoir_250ul.json b/labware-library/cypress/fixtures/testpro_10_reservoir_250ul.json
new file mode 100644
index 00000000000..5941e1b3e5e
--- /dev/null
+++ b/labware-library/cypress/fixtures/testpro_10_reservoir_250ul.json
@@ -0,0 +1,154 @@
+{
+ "ordering": [
+ ["A1"],
+ ["A2"],
+ ["A3"],
+ ["A4"],
+ ["A5"],
+ ["A6"],
+ ["A7"],
+ ["A8"],
+ ["A9"],
+ ["A10"]
+ ],
+ "brand": {
+ "brand": "TestPro",
+ "brandId": ["001"]
+ },
+ "metadata": {
+ "displayName": "TestPro 10 Reservoir 250 µL",
+ "displayCategory": "reservoir",
+ "displayVolumeUnits": "µL",
+ "tags": []
+ },
+ "dimensions": {
+ "xDimension": 127,
+ "yDimension": 85,
+ "zDimension": 75
+ },
+ "wells": {
+ "A1": {
+ "depth": 70,
+ "totalLiquidVolume": 250,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 60,
+ "x": 10,
+ "y": 40,
+ "z": 5
+ },
+ "A2": {
+ "depth": 70,
+ "totalLiquidVolume": 250,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 60,
+ "x": 22,
+ "y": 40,
+ "z": 5
+ },
+ "A3": {
+ "depth": 70,
+ "totalLiquidVolume": 250,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 60,
+ "x": 34,
+ "y": 40,
+ "z": 5
+ },
+ "A4": {
+ "depth": 70,
+ "totalLiquidVolume": 250,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 60,
+ "x": 46,
+ "y": 40,
+ "z": 5
+ },
+ "A5": {
+ "depth": 70,
+ "totalLiquidVolume": 250,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 60,
+ "x": 58,
+ "y": 40,
+ "z": 5
+ },
+ "A6": {
+ "depth": 70,
+ "totalLiquidVolume": 250,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 60,
+ "x": 70,
+ "y": 40,
+ "z": 5
+ },
+ "A7": {
+ "depth": 70,
+ "totalLiquidVolume": 250,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 60,
+ "x": 82,
+ "y": 40,
+ "z": 5
+ },
+ "A8": {
+ "depth": 70,
+ "totalLiquidVolume": 250,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 60,
+ "x": 94,
+ "y": 40,
+ "z": 5
+ },
+ "A9": {
+ "depth": 70,
+ "totalLiquidVolume": 250,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 60,
+ "x": 106,
+ "y": 40,
+ "z": 5
+ },
+ "A10": {
+ "depth": 70,
+ "totalLiquidVolume": 250,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 60,
+ "x": 118,
+ "y": 40,
+ "z": 5
+ }
+ },
+ "groups": [
+ {
+ "metadata": {
+ "wellBottomShape": "v"
+ },
+ "wells": ["A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10"]
+ }
+ ],
+ "parameters": {
+ "format": "irregular",
+ "quirks": [],
+ "isTiprack": false,
+ "isMagneticModuleCompatible": false,
+ "loadName": "testpro_10_reservoir_250ul"
+ },
+ "namespace": "custom_beta",
+ "version": 1,
+ "schemaVersion": 2,
+ "cornerOffsetFromSlot": {
+ "x": 0,
+ "y": 0,
+ "z": 0
+ }
+}
diff --git a/labware-library/cypress/fixtures/testpro_15_wellplate_5ul.json b/labware-library/cypress/fixtures/testpro_15_wellplate_5ul.json
new file mode 100644
index 00000000000..6eac7bd5fc6
--- /dev/null
+++ b/labware-library/cypress/fixtures/testpro_15_wellplate_5ul.json
@@ -0,0 +1,200 @@
+{
+ "ordering": [
+ ["A1", "B1", "C1"],
+ ["A2", "B2", "C2"],
+ ["A3", "B3", "C3"],
+ ["A4", "B4", "C4"],
+ ["A5", "B5", "C5"]
+ ],
+ "brand": {
+ "brand": "TestPro",
+ "brandId": ["001"]
+ },
+ "metadata": {
+ "displayName": "TestPro 15 Well Plate 5 µL",
+ "displayCategory": "wellPlate",
+ "displayVolumeUnits": "µL",
+ "tags": []
+ },
+ "dimensions": {
+ "xDimension": 127,
+ "yDimension": 85,
+ "zDimension": 5
+ },
+ "wells": {
+ "A1": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 10,
+ "y": 75,
+ "z": 0
+ },
+ "B1": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 10,
+ "y": 50,
+ "z": 0
+ },
+ "C1": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 10,
+ "y": 25,
+ "z": 0
+ },
+ "A2": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 35,
+ "y": 75,
+ "z": 0
+ },
+ "B2": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 35,
+ "y": 50,
+ "z": 0
+ },
+ "C2": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 35,
+ "y": 25,
+ "z": 0
+ },
+ "A3": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 60,
+ "y": 75,
+ "z": 0
+ },
+ "B3": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 60,
+ "y": 50,
+ "z": 0
+ },
+ "C3": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 60,
+ "y": 25,
+ "z": 0
+ },
+ "A4": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 85,
+ "y": 75,
+ "z": 0
+ },
+ "B4": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 85,
+ "y": 50,
+ "z": 0
+ },
+ "C4": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 85,
+ "y": 25,
+ "z": 0
+ },
+ "A5": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 110,
+ "y": 75,
+ "z": 0
+ },
+ "B5": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 110,
+ "y": 50,
+ "z": 0
+ },
+ "C5": {
+ "depth": 5,
+ "totalLiquidVolume": 5,
+ "shape": "circular",
+ "diameter": 5,
+ "x": 110,
+ "y": 25,
+ "z": 0
+ }
+ },
+ "groups": [
+ {
+ "metadata": {
+ "wellBottomShape": "flat"
+ },
+ "wells": [
+ "A1",
+ "B1",
+ "C1",
+ "A2",
+ "B2",
+ "C2",
+ "A3",
+ "B3",
+ "C3",
+ "A4",
+ "B4",
+ "C4",
+ "A5",
+ "B5",
+ "C5"
+ ]
+ }
+ ],
+ "parameters": {
+ "format": "irregular",
+ "quirks": [],
+ "isTiprack": false,
+ "isMagneticModuleCompatible": false,
+ "loadName": "testpro_15_wellplate_5ul"
+ },
+ "namespace": "custom_beta",
+ "version": 1,
+ "schemaVersion": 2,
+ "cornerOffsetFromSlot": {
+ "x": 0,
+ "y": 0,
+ "z": 0
+ }
+}
diff --git a/labware-library/cypress/fixtures/testpro_24_aluminumblock_10ul.json b/labware-library/cypress/fixtures/testpro_24_aluminumblock_10ul.json
new file mode 100644
index 00000000000..d653e918f90
--- /dev/null
+++ b/labware-library/cypress/fixtures/testpro_24_aluminumblock_10ul.json
@@ -0,0 +1,316 @@
+{
+ "ordering": [
+ ["A1", "B1", "C1", "D1"],
+ ["A2", "B2", "C2", "D2"],
+ ["A3", "B3", "C3", "D3"],
+ ["A4", "B4", "C4", "D4"],
+ ["A5", "B5", "C5", "D5"],
+ ["A6", "B6", "C6", "D6"]
+ ],
+ "brand": {
+ "brand": "TestPro",
+ "brandId": ["001"]
+ },
+ "metadata": {
+ "displayName": "TestPro 24 Aluminum Block 10 µL",
+ "displayCategory": "aluminumBlock",
+ "displayVolumeUnits": "µL",
+ "tags": []
+ },
+ "dimensions": {
+ "xDimension": 127.75,
+ "yDimension": 85.5,
+ "zDimension": 75
+ },
+ "wells": {
+ "A1": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 20.75,
+ "y": 68.63,
+ "z": 65
+ },
+ "B1": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 20.75,
+ "y": 51.38,
+ "z": 65
+ },
+ "C1": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 20.75,
+ "y": 34.13,
+ "z": 65
+ },
+ "D1": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 20.75,
+ "y": 16.88,
+ "z": 65
+ },
+ "A2": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 38,
+ "y": 68.63,
+ "z": 65
+ },
+ "B2": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 38,
+ "y": 51.38,
+ "z": 65
+ },
+ "C2": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 38,
+ "y": 34.13,
+ "z": 65
+ },
+ "D2": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 38,
+ "y": 16.88,
+ "z": 65
+ },
+ "A3": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 55.25,
+ "y": 68.63,
+ "z": 65
+ },
+ "B3": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 55.25,
+ "y": 51.38,
+ "z": 65
+ },
+ "C3": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 55.25,
+ "y": 34.13,
+ "z": 65
+ },
+ "D3": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 55.25,
+ "y": 16.88,
+ "z": 65
+ },
+ "A4": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 72.5,
+ "y": 68.63,
+ "z": 65
+ },
+ "B4": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 72.5,
+ "y": 51.38,
+ "z": 65
+ },
+ "C4": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 72.5,
+ "y": 34.13,
+ "z": 65
+ },
+ "D4": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 72.5,
+ "y": 16.88,
+ "z": 65
+ },
+ "A5": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 89.75,
+ "y": 68.63,
+ "z": 65
+ },
+ "B5": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 89.75,
+ "y": 51.38,
+ "z": 65
+ },
+ "C5": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 89.75,
+ "y": 34.13,
+ "z": 65
+ },
+ "D5": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 89.75,
+ "y": 16.88,
+ "z": 65
+ },
+ "A6": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 107,
+ "y": 68.63,
+ "z": 65
+ },
+ "B6": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 107,
+ "y": 51.38,
+ "z": 65
+ },
+ "C6": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 107,
+ "y": 34.13,
+ "z": 65
+ },
+ "D6": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 107,
+ "y": 16.88,
+ "z": 65
+ }
+ },
+ "groups": [
+ {
+ "metadata": {
+ "wellBottomShape": "v",
+ "displayCategory": "tubeRack"
+ },
+ "wells": [
+ "A1",
+ "B1",
+ "C1",
+ "D1",
+ "A2",
+ "B2",
+ "C2",
+ "D2",
+ "A3",
+ "B3",
+ "C3",
+ "D3",
+ "A4",
+ "B4",
+ "C4",
+ "D4",
+ "A5",
+ "B5",
+ "C5",
+ "D5",
+ "A6",
+ "B6",
+ "C6",
+ "D6"
+ ]
+ }
+ ],
+ "parameters": {
+ "format": "irregular",
+ "quirks": [],
+ "isTiprack": false,
+ "isMagneticModuleCompatible": false,
+ "loadName": "testpro_24_aluminumblock_10ul"
+ },
+ "namespace": "custom_beta",
+ "version": 1,
+ "schemaVersion": 2,
+ "cornerOffsetFromSlot": {
+ "x": 0,
+ "y": 0,
+ "z": 0
+ }
+}
diff --git a/labware-library/cypress/fixtures/testpro_80_wellplate_100ul.json b/labware-library/cypress/fixtures/testpro_80_wellplate_100ul.json
new file mode 100644
index 00000000000..f51b575836a
--- /dev/null
+++ b/labware-library/cypress/fixtures/testpro_80_wellplate_100ul.json
@@ -0,0 +1,935 @@
+{
+ "ordering": [
+ ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"],
+ ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"],
+ ["A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3"],
+ ["A4", "B4", "C4", "D4", "E4", "F4", "G4", "H4"],
+ ["A5", "B5", "C5", "D5", "E5", "F5", "G5", "H5"],
+ ["A6", "B6", "C6", "D6", "E6", "F6", "G6", "H6"],
+ ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"],
+ ["A8", "B8", "C8", "D8", "E8", "F8", "G8", "H8"],
+ ["A9", "B9", "C9", "D9", "E9", "F9", "G9", "H9"],
+ ["A10", "B10", "C10", "D10", "E10", "F10", "G10", "H10"]
+ ],
+ "brand": {
+ "brand": "TestPro",
+ "brandId": ["001"]
+ },
+ "metadata": {
+ "displayName": "TestPro 80 Well Plate 100 µL",
+ "displayCategory": "wellPlate",
+ "displayVolumeUnits": "µL",
+ "tags": []
+ },
+ "dimensions": {
+ "xDimension": 127,
+ "yDimension": 85,
+ "zDimension": 75
+ },
+ "wells": {
+ "A1": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 10,
+ "y": 77,
+ "z": 65
+ },
+ "B1": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 10,
+ "y": 67,
+ "z": 65
+ },
+ "C1": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 10,
+ "y": 57,
+ "z": 65
+ },
+ "D1": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 10,
+ "y": 47,
+ "z": 65
+ },
+ "E1": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 10,
+ "y": 37,
+ "z": 65
+ },
+ "F1": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 10,
+ "y": 27,
+ "z": 65
+ },
+ "G1": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 10,
+ "y": 17,
+ "z": 65
+ },
+ "H1": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 10,
+ "y": 7,
+ "z": 65
+ },
+ "A2": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 22,
+ "y": 77,
+ "z": 65
+ },
+ "B2": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 22,
+ "y": 67,
+ "z": 65
+ },
+ "C2": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 22,
+ "y": 57,
+ "z": 65
+ },
+ "D2": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 22,
+ "y": 47,
+ "z": 65
+ },
+ "E2": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 22,
+ "y": 37,
+ "z": 65
+ },
+ "F2": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 22,
+ "y": 27,
+ "z": 65
+ },
+ "G2": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 22,
+ "y": 17,
+ "z": 65
+ },
+ "H2": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 22,
+ "y": 7,
+ "z": 65
+ },
+ "A3": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 34,
+ "y": 77,
+ "z": 65
+ },
+ "B3": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 34,
+ "y": 67,
+ "z": 65
+ },
+ "C3": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 34,
+ "y": 57,
+ "z": 65
+ },
+ "D3": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 34,
+ "y": 47,
+ "z": 65
+ },
+ "E3": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 34,
+ "y": 37,
+ "z": 65
+ },
+ "F3": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 34,
+ "y": 27,
+ "z": 65
+ },
+ "G3": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 34,
+ "y": 17,
+ "z": 65
+ },
+ "H3": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 34,
+ "y": 7,
+ "z": 65
+ },
+ "A4": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 46,
+ "y": 77,
+ "z": 65
+ },
+ "B4": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 46,
+ "y": 67,
+ "z": 65
+ },
+ "C4": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 46,
+ "y": 57,
+ "z": 65
+ },
+ "D4": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 46,
+ "y": 47,
+ "z": 65
+ },
+ "E4": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 46,
+ "y": 37,
+ "z": 65
+ },
+ "F4": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 46,
+ "y": 27,
+ "z": 65
+ },
+ "G4": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 46,
+ "y": 17,
+ "z": 65
+ },
+ "H4": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 46,
+ "y": 7,
+ "z": 65
+ },
+ "A5": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 58,
+ "y": 77,
+ "z": 65
+ },
+ "B5": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 58,
+ "y": 67,
+ "z": 65
+ },
+ "C5": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 58,
+ "y": 57,
+ "z": 65
+ },
+ "D5": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 58,
+ "y": 47,
+ "z": 65
+ },
+ "E5": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 58,
+ "y": 37,
+ "z": 65
+ },
+ "F5": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 58,
+ "y": 27,
+ "z": 65
+ },
+ "G5": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 58,
+ "y": 17,
+ "z": 65
+ },
+ "H5": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 58,
+ "y": 7,
+ "z": 65
+ },
+ "A6": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 70,
+ "y": 77,
+ "z": 65
+ },
+ "B6": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 70,
+ "y": 67,
+ "z": 65
+ },
+ "C6": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 70,
+ "y": 57,
+ "z": 65
+ },
+ "D6": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 70,
+ "y": 47,
+ "z": 65
+ },
+ "E6": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 70,
+ "y": 37,
+ "z": 65
+ },
+ "F6": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 70,
+ "y": 27,
+ "z": 65
+ },
+ "G6": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 70,
+ "y": 17,
+ "z": 65
+ },
+ "H6": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 70,
+ "y": 7,
+ "z": 65
+ },
+ "A7": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 82,
+ "y": 77,
+ "z": 65
+ },
+ "B7": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 82,
+ "y": 67,
+ "z": 65
+ },
+ "C7": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 82,
+ "y": 57,
+ "z": 65
+ },
+ "D7": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 82,
+ "y": 47,
+ "z": 65
+ },
+ "E7": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 82,
+ "y": 37,
+ "z": 65
+ },
+ "F7": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 82,
+ "y": 27,
+ "z": 65
+ },
+ "G7": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 82,
+ "y": 17,
+ "z": 65
+ },
+ "H7": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 82,
+ "y": 7,
+ "z": 65
+ },
+ "A8": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 94,
+ "y": 77,
+ "z": 65
+ },
+ "B8": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 94,
+ "y": 67,
+ "z": 65
+ },
+ "C8": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 94,
+ "y": 57,
+ "z": 65
+ },
+ "D8": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 94,
+ "y": 47,
+ "z": 65
+ },
+ "E8": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 94,
+ "y": 37,
+ "z": 65
+ },
+ "F8": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 94,
+ "y": 27,
+ "z": 65
+ },
+ "G8": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 94,
+ "y": 17,
+ "z": 65
+ },
+ "H8": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 94,
+ "y": 7,
+ "z": 65
+ },
+ "A9": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 106,
+ "y": 77,
+ "z": 65
+ },
+ "B9": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 106,
+ "y": 67,
+ "z": 65
+ },
+ "C9": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 106,
+ "y": 57,
+ "z": 65
+ },
+ "D9": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 106,
+ "y": 47,
+ "z": 65
+ },
+ "E9": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 106,
+ "y": 37,
+ "z": 65
+ },
+ "F9": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 106,
+ "y": 27,
+ "z": 65
+ },
+ "G9": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 106,
+ "y": 17,
+ "z": 65
+ },
+ "H9": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 106,
+ "y": 7,
+ "z": 65
+ },
+ "A10": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 118,
+ "y": 77,
+ "z": 65
+ },
+ "B10": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 118,
+ "y": 67,
+ "z": 65
+ },
+ "C10": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 118,
+ "y": 57,
+ "z": 65
+ },
+ "D10": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 118,
+ "y": 47,
+ "z": 65
+ },
+ "E10": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 118,
+ "y": 37,
+ "z": 65
+ },
+ "F10": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 118,
+ "y": 27,
+ "z": 65
+ },
+ "G10": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 118,
+ "y": 17,
+ "z": 65
+ },
+ "H10": {
+ "depth": 10,
+ "totalLiquidVolume": 100,
+ "shape": "rectangular",
+ "xDimension": 8,
+ "yDimension": 8,
+ "x": 118,
+ "y": 7,
+ "z": 65
+ }
+ },
+ "groups": [
+ {
+ "metadata": {
+ "wellBottomShape": "v"
+ },
+ "wells": [
+ "A1",
+ "B1",
+ "C1",
+ "D1",
+ "E1",
+ "F1",
+ "G1",
+ "H1",
+ "A2",
+ "B2",
+ "C2",
+ "D2",
+ "E2",
+ "F2",
+ "G2",
+ "H2",
+ "A3",
+ "B3",
+ "C3",
+ "D3",
+ "E3",
+ "F3",
+ "G3",
+ "H3",
+ "A4",
+ "B4",
+ "C4",
+ "D4",
+ "E4",
+ "F4",
+ "G4",
+ "H4",
+ "A5",
+ "B5",
+ "C5",
+ "D5",
+ "E5",
+ "F5",
+ "G5",
+ "H5",
+ "A6",
+ "B6",
+ "C6",
+ "D6",
+ "E6",
+ "F6",
+ "G6",
+ "H6",
+ "A7",
+ "B7",
+ "C7",
+ "D7",
+ "E7",
+ "F7",
+ "G7",
+ "H7",
+ "A8",
+ "B8",
+ "C8",
+ "D8",
+ "E8",
+ "F8",
+ "G8",
+ "H8",
+ "A9",
+ "B9",
+ "C9",
+ "D9",
+ "E9",
+ "F9",
+ "G9",
+ "H9",
+ "A10",
+ "B10",
+ "C10",
+ "D10",
+ "E10",
+ "F10",
+ "G10",
+ "H10"
+ ]
+ }
+ ],
+ "parameters": {
+ "format": "irregular",
+ "quirks": [],
+ "isTiprack": false,
+ "isMagneticModuleCompatible": false,
+ "loadName": "testpro_80_wellplate_100ul"
+ },
+ "namespace": "custom_beta",
+ "version": 1,
+ "schemaVersion": 2,
+ "cornerOffsetFromSlot": {
+ "x": 0,
+ "y": 0,
+ "z": 0
+ }
+}
diff --git a/labware-library/cypress/fixtures/testpro_96_aluminumblock_10ul.json b/labware-library/cypress/fixtures/testpro_96_aluminumblock_10ul.json
new file mode 100644
index 00000000000..0b99d24def6
--- /dev/null
+++ b/labware-library/cypress/fixtures/testpro_96_aluminumblock_10ul.json
@@ -0,0 +1,1114 @@
+{
+ "ordering": [
+ ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"],
+ ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"],
+ ["A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3"],
+ ["A4", "B4", "C4", "D4", "E4", "F4", "G4", "H4"],
+ ["A5", "B5", "C5", "D5", "E5", "F5", "G5", "H5"],
+ ["A6", "B6", "C6", "D6", "E6", "F6", "G6", "H6"],
+ ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"],
+ ["A8", "B8", "C8", "D8", "E8", "F8", "G8", "H8"],
+ ["A9", "B9", "C9", "D9", "E9", "F9", "G9", "H9"],
+ ["A10", "B10", "C10", "D10", "E10", "F10", "G10", "H10"],
+ ["A11", "B11", "C11", "D11", "E11", "F11", "G11", "H11"],
+ ["A12", "B12", "C12", "D12", "E12", "F12", "G12", "H12"]
+ ],
+ "brand": {
+ "brand": "TestPro",
+ "brandId": ["001"]
+ },
+ "metadata": {
+ "displayName": "TestPro 96 Aluminum Block 10 µL",
+ "displayCategory": "aluminumBlock",
+ "displayVolumeUnits": "µL",
+ "tags": []
+ },
+ "dimensions": {
+ "xDimension": 127.75,
+ "yDimension": 85.5,
+ "zDimension": 75
+ },
+ "wells": {
+ "A1": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 14.38,
+ "y": 74.25,
+ "z": 65
+ },
+ "B1": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 14.38,
+ "y": 65.25,
+ "z": 65
+ },
+ "C1": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 14.38,
+ "y": 56.25,
+ "z": 65
+ },
+ "D1": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 14.38,
+ "y": 47.25,
+ "z": 65
+ },
+ "E1": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 14.38,
+ "y": 38.25,
+ "z": 65
+ },
+ "F1": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 14.38,
+ "y": 29.25,
+ "z": 65
+ },
+ "G1": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 14.38,
+ "y": 20.25,
+ "z": 65
+ },
+ "H1": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 14.38,
+ "y": 11.25,
+ "z": 65
+ },
+ "A2": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 23.38,
+ "y": 74.25,
+ "z": 65
+ },
+ "B2": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 23.38,
+ "y": 65.25,
+ "z": 65
+ },
+ "C2": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 23.38,
+ "y": 56.25,
+ "z": 65
+ },
+ "D2": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 23.38,
+ "y": 47.25,
+ "z": 65
+ },
+ "E2": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 23.38,
+ "y": 38.25,
+ "z": 65
+ },
+ "F2": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 23.38,
+ "y": 29.25,
+ "z": 65
+ },
+ "G2": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 23.38,
+ "y": 20.25,
+ "z": 65
+ },
+ "H2": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 23.38,
+ "y": 11.25,
+ "z": 65
+ },
+ "A3": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 32.38,
+ "y": 74.25,
+ "z": 65
+ },
+ "B3": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 32.38,
+ "y": 65.25,
+ "z": 65
+ },
+ "C3": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 32.38,
+ "y": 56.25,
+ "z": 65
+ },
+ "D3": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 32.38,
+ "y": 47.25,
+ "z": 65
+ },
+ "E3": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 32.38,
+ "y": 38.25,
+ "z": 65
+ },
+ "F3": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 32.38,
+ "y": 29.25,
+ "z": 65
+ },
+ "G3": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 32.38,
+ "y": 20.25,
+ "z": 65
+ },
+ "H3": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 32.38,
+ "y": 11.25,
+ "z": 65
+ },
+ "A4": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 41.38,
+ "y": 74.25,
+ "z": 65
+ },
+ "B4": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 41.38,
+ "y": 65.25,
+ "z": 65
+ },
+ "C4": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 41.38,
+ "y": 56.25,
+ "z": 65
+ },
+ "D4": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 41.38,
+ "y": 47.25,
+ "z": 65
+ },
+ "E4": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 41.38,
+ "y": 38.25,
+ "z": 65
+ },
+ "F4": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 41.38,
+ "y": 29.25,
+ "z": 65
+ },
+ "G4": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 41.38,
+ "y": 20.25,
+ "z": 65
+ },
+ "H4": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 41.38,
+ "y": 11.25,
+ "z": 65
+ },
+ "A5": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 50.38,
+ "y": 74.25,
+ "z": 65
+ },
+ "B5": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 50.38,
+ "y": 65.25,
+ "z": 65
+ },
+ "C5": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 50.38,
+ "y": 56.25,
+ "z": 65
+ },
+ "D5": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 50.38,
+ "y": 47.25,
+ "z": 65
+ },
+ "E5": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 50.38,
+ "y": 38.25,
+ "z": 65
+ },
+ "F5": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 50.38,
+ "y": 29.25,
+ "z": 65
+ },
+ "G5": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 50.38,
+ "y": 20.25,
+ "z": 65
+ },
+ "H5": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 50.38,
+ "y": 11.25,
+ "z": 65
+ },
+ "A6": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 59.38,
+ "y": 74.25,
+ "z": 65
+ },
+ "B6": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 59.38,
+ "y": 65.25,
+ "z": 65
+ },
+ "C6": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 59.38,
+ "y": 56.25,
+ "z": 65
+ },
+ "D6": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 59.38,
+ "y": 47.25,
+ "z": 65
+ },
+ "E6": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 59.38,
+ "y": 38.25,
+ "z": 65
+ },
+ "F6": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 59.38,
+ "y": 29.25,
+ "z": 65
+ },
+ "G6": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 59.38,
+ "y": 20.25,
+ "z": 65
+ },
+ "H6": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 59.38,
+ "y": 11.25,
+ "z": 65
+ },
+ "A7": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 68.38,
+ "y": 74.25,
+ "z": 65
+ },
+ "B7": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 68.38,
+ "y": 65.25,
+ "z": 65
+ },
+ "C7": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 68.38,
+ "y": 56.25,
+ "z": 65
+ },
+ "D7": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 68.38,
+ "y": 47.25,
+ "z": 65
+ },
+ "E7": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 68.38,
+ "y": 38.25,
+ "z": 65
+ },
+ "F7": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 68.38,
+ "y": 29.25,
+ "z": 65
+ },
+ "G7": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 68.38,
+ "y": 20.25,
+ "z": 65
+ },
+ "H7": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 68.38,
+ "y": 11.25,
+ "z": 65
+ },
+ "A8": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 77.38,
+ "y": 74.25,
+ "z": 65
+ },
+ "B8": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 77.38,
+ "y": 65.25,
+ "z": 65
+ },
+ "C8": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 77.38,
+ "y": 56.25,
+ "z": 65
+ },
+ "D8": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 77.38,
+ "y": 47.25,
+ "z": 65
+ },
+ "E8": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 77.38,
+ "y": 38.25,
+ "z": 65
+ },
+ "F8": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 77.38,
+ "y": 29.25,
+ "z": 65
+ },
+ "G8": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 77.38,
+ "y": 20.25,
+ "z": 65
+ },
+ "H8": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 77.38,
+ "y": 11.25,
+ "z": 65
+ },
+ "A9": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 86.38,
+ "y": 74.25,
+ "z": 65
+ },
+ "B9": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 86.38,
+ "y": 65.25,
+ "z": 65
+ },
+ "C9": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 86.38,
+ "y": 56.25,
+ "z": 65
+ },
+ "D9": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 86.38,
+ "y": 47.25,
+ "z": 65
+ },
+ "E9": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 86.38,
+ "y": 38.25,
+ "z": 65
+ },
+ "F9": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 86.38,
+ "y": 29.25,
+ "z": 65
+ },
+ "G9": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 86.38,
+ "y": 20.25,
+ "z": 65
+ },
+ "H9": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 86.38,
+ "y": 11.25,
+ "z": 65
+ },
+ "A10": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 95.38,
+ "y": 74.25,
+ "z": 65
+ },
+ "B10": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 95.38,
+ "y": 65.25,
+ "z": 65
+ },
+ "C10": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 95.38,
+ "y": 56.25,
+ "z": 65
+ },
+ "D10": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 95.38,
+ "y": 47.25,
+ "z": 65
+ },
+ "E10": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 95.38,
+ "y": 38.25,
+ "z": 65
+ },
+ "F10": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 95.38,
+ "y": 29.25,
+ "z": 65
+ },
+ "G10": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 95.38,
+ "y": 20.25,
+ "z": 65
+ },
+ "H10": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 95.38,
+ "y": 11.25,
+ "z": 65
+ },
+ "A11": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 104.38,
+ "y": 74.25,
+ "z": 65
+ },
+ "B11": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 104.38,
+ "y": 65.25,
+ "z": 65
+ },
+ "C11": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 104.38,
+ "y": 56.25,
+ "z": 65
+ },
+ "D11": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 104.38,
+ "y": 47.25,
+ "z": 65
+ },
+ "E11": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 104.38,
+ "y": 38.25,
+ "z": 65
+ },
+ "F11": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 104.38,
+ "y": 29.25,
+ "z": 65
+ },
+ "G11": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 104.38,
+ "y": 20.25,
+ "z": 65
+ },
+ "H11": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 104.38,
+ "y": 11.25,
+ "z": 65
+ },
+ "A12": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 113.38,
+ "y": 74.25,
+ "z": 65
+ },
+ "B12": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 113.38,
+ "y": 65.25,
+ "z": 65
+ },
+ "C12": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 113.38,
+ "y": 56.25,
+ "z": 65
+ },
+ "D12": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 113.38,
+ "y": 47.25,
+ "z": 65
+ },
+ "E12": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 113.38,
+ "y": 38.25,
+ "z": 65
+ },
+ "F12": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 113.38,
+ "y": 29.25,
+ "z": 65
+ },
+ "G12": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 113.38,
+ "y": 20.25,
+ "z": 65
+ },
+ "H12": {
+ "depth": 10,
+ "totalLiquidVolume": 10,
+ "shape": "rectangular",
+ "xDimension": 10,
+ "yDimension": 10,
+ "x": 113.38,
+ "y": 11.25,
+ "z": 65
+ }
+ },
+ "groups": [
+ {
+ "metadata": {
+ "wellBottomShape": "v",
+ "displayCategory": "wellPlate"
+ },
+ "wells": [
+ "A1",
+ "B1",
+ "C1",
+ "D1",
+ "E1",
+ "F1",
+ "G1",
+ "H1",
+ "A2",
+ "B2",
+ "C2",
+ "D2",
+ "E2",
+ "F2",
+ "G2",
+ "H2",
+ "A3",
+ "B3",
+ "C3",
+ "D3",
+ "E3",
+ "F3",
+ "G3",
+ "H3",
+ "A4",
+ "B4",
+ "C4",
+ "D4",
+ "E4",
+ "F4",
+ "G4",
+ "H4",
+ "A5",
+ "B5",
+ "C5",
+ "D5",
+ "E5",
+ "F5",
+ "G5",
+ "H5",
+ "A6",
+ "B6",
+ "C6",
+ "D6",
+ "E6",
+ "F6",
+ "G6",
+ "H6",
+ "A7",
+ "B7",
+ "C7",
+ "D7",
+ "E7",
+ "F7",
+ "G7",
+ "H7",
+ "A8",
+ "B8",
+ "C8",
+ "D8",
+ "E8",
+ "F8",
+ "G8",
+ "H8",
+ "A9",
+ "B9",
+ "C9",
+ "D9",
+ "E9",
+ "F9",
+ "G9",
+ "H9",
+ "A10",
+ "B10",
+ "C10",
+ "D10",
+ "E10",
+ "F10",
+ "G10",
+ "H10",
+ "A11",
+ "B11",
+ "C11",
+ "D11",
+ "E11",
+ "F11",
+ "G11",
+ "H11",
+ "A12",
+ "B12",
+ "C12",
+ "D12",
+ "E12",
+ "F12",
+ "G12",
+ "H12"
+ ]
+ }
+ ],
+ "parameters": {
+ "format": "irregular",
+ "quirks": [],
+ "isTiprack": false,
+ "isMagneticModuleCompatible": false,
+ "loadName": "testpro_96_aluminumblock_10ul"
+ },
+ "namespace": "custom_beta",
+ "version": 1,
+ "schemaVersion": 2,
+ "cornerOffsetFromSlot": {
+ "x": 0,
+ "y": 0,
+ "z": 0
+ }
+}
diff --git a/labware-library/cypress/mocks/file-saver.js b/labware-library/cypress/mocks/file-saver.js
deleted file mode 100644
index d4c7febe539..00000000000
--- a/labware-library/cypress/mocks/file-saver.js
+++ /dev/null
@@ -1,6 +0,0 @@
-// mock for 'file-saver' npm module
-
-export const saveAs = (blob, fileName) => {
- global.__lastSavedFileBlob__ = blob
- global.__lastSavedFileName__ = fileName
-}
diff --git a/labware-library/cypress/plugins/index.js b/labware-library/cypress/plugins/index.js
deleted file mode 100644
index f392875c7d9..00000000000
--- a/labware-library/cypress/plugins/index.js
+++ /dev/null
@@ -1,23 +0,0 @@
-// eslint-disable-next-line @typescript-eslint/triple-slash-reference
-///
-// ***********************************************************
-// This example plugins/index.js can be used to load plugins
-//
-// You can change the location of this file or turn off loading
-// the plugins file with the 'pluginsFile' configuration option.
-//
-// You can read more here:
-// https://on.cypress.io/plugins-guide
-// ***********************************************************
-
-// This function is called when a project is opened or re-opened (e.g. due to
-// the project's config changing)
-
-/**
- * @type {Cypress.PluginConfig}
- */
-// eslint-disable-next-line no-unused-vars
-module.exports = (on, config) => {
- // `on` is used to hook into various events Cypress emits
- // `config` is the resolved Cypress config
-}
diff --git a/labware-library/cypress/support/e2e.js b/labware-library/cypress/support/e2e.js
deleted file mode 100644
index d68db96df26..00000000000
--- a/labware-library/cypress/support/e2e.js
+++ /dev/null
@@ -1,20 +0,0 @@
-// ***********************************************************
-// This example support/index.js is processed and
-// loaded automatically before your test files.
-//
-// This is a great place to put global configuration and
-// behavior that modifies Cypress.
-//
-// You can change the location of this file or turn off
-// automatically serving support files with the
-// 'supportFile' configuration option.
-//
-// You can read more here:
-// https://on.cypress.io/configuration
-// ***********************************************************
-
-// Import commands.js using ES2015 syntax:
-import './commands'
-
-// Alternatively you can use CommonJS syntax:
-// require('./commands')
diff --git a/labware-library/cypress/support/e2e.ts b/labware-library/cypress/support/e2e.ts
new file mode 100644
index 00000000000..85dcff19ba8
--- /dev/null
+++ b/labware-library/cypress/support/e2e.ts
@@ -0,0 +1,40 @@
+// ***********************************************************
+// This file runs before every single spec file.
+// We do this purely as a convenience mechanism so you don't have to import this file.
+// https://docs.cypress.io/guides/core-concepts/writing-and-organizing-tests#Support-file
+// ***********************************************************
+import { join } from 'path'
+import './commands'
+
+export const navigateToUrl = (url: string): void => {
+ cy.visit(url)
+ cy.viewport('macbook-15')
+}
+
+export const wellBottomImageLocator: Record = {
+ flat: 'img[alt*="flat bottom"]',
+ round: 'img[alt*="u shaped"]',
+ v: 'img[alt*="v shaped"]',
+}
+
+interface FileHelperResult {
+ downloadsFolder: string
+ downloadFileStem: string
+ downloadFilename: string
+ downloadPath: string
+ expectedExportFixture: string
+}
+
+export const fileHelper = (fileStem: string): FileHelperResult => {
+ const downloadsFolder = Cypress.config('downloadsFolder')
+ const downloadFileStem = fileStem
+ const downloadFilename = `${downloadFileStem}.json`
+ const downloadPath = join(downloadsFolder, downloadFilename)
+ return {
+ downloadsFolder,
+ downloadFileStem,
+ downloadFilename,
+ downloadPath,
+ expectedExportFixture: `../fixtures/${downloadFilename}`,
+ }
+}
diff --git a/labware-library/src/components/labware-ui/labware-images.ts b/labware-library/src/components/labware-ui/labware-images.ts
index 36fe2cb8dfb..8df00e07f2a 100644
--- a/labware-library/src/components/labware-ui/labware-images.ts
+++ b/labware-library/src/components/labware-ui/labware-images.ts
@@ -468,4 +468,13 @@ export const labwareImages: Record = {
import.meta.url
).href,
],
+ opentrons_tough_pcr_auto_sealing_lid: [
+ new URL(
+ '../../images/opentrons_tough_pcr_auto_sealing_lid.jpg',
+ import.meta.url
+ ).href,
+ ],
+ opentrons_flex_deck_riser: [
+ new URL('../../images/opentrons_flex_deck_riser.jpg', import.meta.url).href,
+ ],
}
diff --git a/labware-library/src/images/opentrons_flex_deck_riser.jpg b/labware-library/src/images/opentrons_flex_deck_riser.jpg
new file mode 100644
index 00000000000..b8576833538
Binary files /dev/null and b/labware-library/src/images/opentrons_flex_deck_riser.jpg differ
diff --git a/labware-library/src/images/opentrons_tough_pcr_auto_sealing_lid.jpg b/labware-library/src/images/opentrons_tough_pcr_auto_sealing_lid.jpg
new file mode 100644
index 00000000000..a81c42b2d2c
Binary files /dev/null and b/labware-library/src/images/opentrons_tough_pcr_auto_sealing_lid.jpg differ
diff --git a/labware-library/src/localization/en.ts b/labware-library/src/localization/en.ts
index 07e7bda76d1..9745ed44fb2 100644
--- a/labware-library/src/localization/en.ts
+++ b/labware-library/src/localization/en.ts
@@ -10,6 +10,7 @@ export const CATEGORY_LABELS_BY_CATEGORY = {
trash: 'Trash',
other: 'Other',
adapter: 'Adapter',
+ lid: 'Lid',
}
export const PLURAL_CATEGORY_LABELS_BY_CATEGORY = {
@@ -20,6 +21,7 @@ export const PLURAL_CATEGORY_LABELS_BY_CATEGORY = {
wellPlate: 'Well Plates',
reservoir: 'Reservoirs',
aluminumBlock: 'Aluminum Blocks',
+ lid: 'Lid',
trash: 'Trashes',
other: 'Other',
}
diff --git a/labware-library/vite.config.mts b/labware-library/vite.config.mts
index 43d5065c011..0c05338af06 100644
--- a/labware-library/vite.config.mts
+++ b/labware-library/vite.config.mts
@@ -8,14 +8,6 @@ import postCssPresetEnv from 'postcss-preset-env'
import lostCss from 'lost'
import { cssModuleSideEffect } from './cssModuleSideEffect'
-const testAliases: {} | { 'file-saver': string } =
- process.env.CYPRESS === '1'
- ? {
- 'file-saver':
- path.resolve(__dirname, 'cypress/mocks/file-saver.js') ?? '',
- }
- : {}
-
export default defineConfig({
// this makes imports relative rather than absolute
base: '',
@@ -70,7 +62,6 @@ export default defineConfig({
'@opentrons/step-generation': path.resolve(
'../step-generation/src/index.ts'
),
- ...testAliases,
},
},
server: {
diff --git a/opentrons-ai-client/src/App.test.tsx b/opentrons-ai-client/src/App.test.tsx
index 859bb488f0e..ec61b02472c 100644
--- a/opentrons-ai-client/src/App.test.tsx
+++ b/opentrons-ai-client/src/App.test.tsx
@@ -1,22 +1,13 @@
-import { fireEvent, screen } from '@testing-library/react'
+import { screen } from '@testing-library/react'
import { describe, it, vi, beforeEach, expect } from 'vitest'
-import * as auth0 from '@auth0/auth0-react'
import { renderWithProviders } from './__testing-utils__'
import { i18n } from './i18n'
-import { SidePanel } from './molecules/SidePanel'
-import { MainContentContainer } from './organisms/MainContentContainer'
-import { Loading } from './molecules/Loading'
import { App } from './App'
+import { OpentronsAI } from './OpentronsAI'
-vi.mock('@auth0/auth0-react')
-
-const mockLogout = vi.fn()
-
-vi.mock('./molecules/SidePanel')
-vi.mock('./organisms/MainContentContainer')
-vi.mock('./molecules/Loading')
+vi.mock('./OpentronsAI')
const render = (): ReturnType => {
return renderWithProviders(, {
@@ -26,42 +17,11 @@ const render = (): ReturnType => {
describe('App', () => {
beforeEach(() => {
- vi.mocked(SidePanel).mockReturnValue(mock SidePanel
)
- vi.mocked(MainContentContainer).mockReturnValue(
- mock MainContentContainer
- )
- vi.mocked(Loading).mockReturnValue(mock Loading
)
- })
-
- it('should render loading screen when isLoading is true', () => {
- ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({
- isAuthenticated: false,
- isLoading: true,
- })
- render()
- screen.getByText('mock Loading')
- })
-
- it('should render text', () => {
- ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({
- isAuthenticated: true,
- isLoading: false,
- })
- render()
- screen.getByText('mock SidePanel')
- screen.getByText('mock MainContentContainer')
- screen.getByText('Logout')
+ vi.mocked(OpentronsAI).mockReturnValue(mock OpentronsAI
)
})
- it('should call a mock function when clicking logout button', () => {
- ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({
- isAuthenticated: true,
- isLoading: false,
- logout: mockLogout,
- })
+ it('should render OpentronsAI', () => {
render()
- const logoutButton = screen.getByText('Logout')
- fireEvent.click(logoutButton)
- expect(mockLogout).toHaveBeenCalled()
+ expect(screen.getByText('mock OpentronsAI')).toBeInTheDocument()
})
})
diff --git a/opentrons-ai-client/src/App.tsx b/opentrons-ai-client/src/App.tsx
index 263ea02c844..104977150fc 100644
--- a/opentrons-ai-client/src/App.tsx
+++ b/opentrons-ai-client/src/App.tsx
@@ -1,82 +1,5 @@
-import { useEffect } from 'react'
-import { useAuth0 } from '@auth0/auth0-react'
-import { useTranslation } from 'react-i18next'
-import { useForm, FormProvider } from 'react-hook-form'
-import { useAtom } from 'jotai'
-import {
- COLORS,
- Flex,
- Link as LinkButton,
- POSITION_ABSOLUTE,
- POSITION_RELATIVE,
- TYPOGRAPHY,
-} from '@opentrons/components'
-
-import { tokenAtom } from './resources/atoms'
-import { useGetAccessToken } from './resources/hooks'
-import { SidePanel } from './molecules/SidePanel'
-import { Loading } from './molecules/Loading'
-import { MainContentContainer } from './organisms/MainContentContainer'
-
-export interface InputType {
- userPrompt: string
-}
+import { OpentronsAI } from './OpentronsAI'
export function App(): JSX.Element | null {
- const { t } = useTranslation('protocol_generator')
- const { isAuthenticated, logout, isLoading, loginWithRedirect } = useAuth0()
- const [, setToken] = useAtom(tokenAtom)
- const { getAccessToken } = useGetAccessToken()
-
- const fetchAccessToken = async (): Promise => {
- try {
- const accessToken = await getAccessToken()
- setToken(accessToken)
- } catch (error) {
- console.error('Error fetching access token:', error)
- }
- }
- const methods = useForm({
- defaultValues: {
- userPrompt: '',
- },
- })
-
- useEffect(() => {
- if (!isAuthenticated && !isLoading) {
- void loginWithRedirect()
- }
- if (isAuthenticated) {
- void fetchAccessToken()
- }
- }, [isAuthenticated, isLoading, loginWithRedirect])
-
- if (isLoading) {
- return
- }
-
- if (!isAuthenticated) {
- return null
- }
-
- return (
-
-
- logout()}
- textDecoration={TYPOGRAPHY.textDecorationUnderline}
- >
- {t('logout')}
-
-
-
-
-
-
-
- )
+ return
}
diff --git a/opentrons-ai-client/src/OpentronsAI.test.tsx b/opentrons-ai-client/src/OpentronsAI.test.tsx
new file mode 100644
index 00000000000..68d604edf07
--- /dev/null
+++ b/opentrons-ai-client/src/OpentronsAI.test.tsx
@@ -0,0 +1,82 @@
+import { screen } from '@testing-library/react'
+import { describe, it, vi, beforeEach } from 'vitest'
+import * as auth0 from '@auth0/auth0-react'
+
+import { renderWithProviders } from './__testing-utils__'
+import { i18n } from './i18n'
+import { Loading } from './molecules/Loading'
+
+import { OpentronsAI } from './OpentronsAI'
+import { Landing } from './pages/Landing'
+import { useGetAccessToken } from './resources/hooks'
+import { Header } from './molecules/Header'
+import { Footer } from './molecules/Footer'
+
+vi.mock('@auth0/auth0-react')
+
+vi.mock('./pages/Landing')
+vi.mock('./molecules/Header')
+vi.mock('./molecules/Footer')
+vi.mock('./molecules/Loading')
+vi.mock('./resources/hooks/useGetAccessToken')
+vi.mock('./analytics/mixpanel')
+
+const mockUseTrackEvent = vi.fn()
+
+vi.mock('./resources/hooks/useTrackEvent', () => ({
+ useTrackEvent: () => mockUseTrackEvent,
+}))
+
+const render = (): ReturnType => {
+ return renderWithProviders(, {
+ i18nInstance: i18n,
+ })
+}
+
+describe('OpentronsAI', () => {
+ beforeEach(() => {
+ vi.mocked(useGetAccessToken).mockReturnValue({
+ getAccessToken: vi.fn().mockResolvedValue('mock access token'),
+ })
+ vi.mocked(Landing).mockReturnValue(mock Landing page
)
+ vi.mocked(Loading).mockReturnValue(mock Loading
)
+ vi.mocked(Header).mockReturnValue(mock Header component
)
+ vi.mocked(Footer).mockReturnValue(mock Footer component
)
+ })
+
+ it('should render loading screen when isLoading is true', () => {
+ ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({
+ isAuthenticated: false,
+ isLoading: true,
+ })
+ render()
+ screen.getByText('mock Loading')
+ })
+
+ it('should render text', () => {
+ ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({
+ isAuthenticated: true,
+ isLoading: false,
+ })
+ render()
+ screen.getByText('mock Landing page')
+ })
+
+ it('should render Header component', () => {
+ ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({
+ isAuthenticated: true,
+ isLoading: false,
+ })
+ render()
+ screen.getByText('mock Header component')
+ })
+
+ it('should render Footer component', () => {
+ ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({
+ isAuthenticated: true,
+ isLoading: false,
+ })
+ render()
+ screen.getByText('mock Footer component')
+ })
+})
diff --git a/opentrons-ai-client/src/OpentronsAI.tsx b/opentrons-ai-client/src/OpentronsAI.tsx
new file mode 100644
index 00000000000..621c2453e50
--- /dev/null
+++ b/opentrons-ai-client/src/OpentronsAI.tsx
@@ -0,0 +1,90 @@
+import { HashRouter } from 'react-router-dom'
+import {
+ DIRECTION_COLUMN,
+ Flex,
+ OVERFLOW_AUTO,
+ COLORS,
+ ALIGN_CENTER,
+} from '@opentrons/components'
+import { OpentronsAIRoutes } from './OpentronsAIRoutes'
+import { useAuth0 } from '@auth0/auth0-react'
+import { useAtom } from 'jotai'
+import { useEffect } from 'react'
+import { Loading } from './molecules/Loading'
+import { mixpanelAtom, tokenAtom } from './resources/atoms'
+import { useGetAccessToken } from './resources/hooks'
+import { initializeMixpanel } from './analytics/mixpanel'
+import { useTrackEvent } from './resources/hooks/useTrackEvent'
+import { Header } from './molecules/Header'
+import { CLIENT_MAX_WIDTH } from './resources/constants'
+import { Footer } from './molecules/Footer'
+
+export function OpentronsAI(): JSX.Element | null {
+ const { isAuthenticated, isLoading, loginWithRedirect } = useAuth0()
+ const [, setToken] = useAtom(tokenAtom)
+ const [mixpanel] = useAtom(mixpanelAtom)
+ const { getAccessToken } = useGetAccessToken()
+ const trackEvent = useTrackEvent()
+
+ initializeMixpanel(mixpanel)
+
+ const fetchAccessToken = async (): Promise => {
+ try {
+ const accessToken = await getAccessToken()
+ setToken(accessToken)
+ } catch (error) {
+ console.error('Error fetching access token:', error)
+ }
+ }
+
+ useEffect(() => {
+ if (!isAuthenticated && !isLoading) {
+ void loginWithRedirect()
+ }
+ if (isAuthenticated) {
+ void fetchAccessToken()
+ }
+ }, [isAuthenticated, isLoading, loginWithRedirect])
+
+ useEffect(() => {
+ if (isAuthenticated) {
+ trackEvent({ name: 'user-login', properties: {} })
+ }
+ }, [isAuthenticated])
+
+ if (isLoading) {
+ return
+ }
+
+ if (!isAuthenticated) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/opentrons-ai-client/src/OpentronsAIRoutes.tsx b/opentrons-ai-client/src/OpentronsAIRoutes.tsx
new file mode 100644
index 00000000000..630429c2aa1
--- /dev/null
+++ b/opentrons-ai-client/src/OpentronsAIRoutes.tsx
@@ -0,0 +1,39 @@
+import { Route, Navigate, Routes } from 'react-router-dom'
+import { Landing } from './pages/Landing'
+
+import type { RouteProps } from './resources/types'
+
+const opentronsAIRoutes: RouteProps[] = [
+ // replace Landing with the correct component
+ {
+ Component: Landing,
+ name: 'Create A New Protocol',
+ navLinkTo: '/new-protocol',
+ path: '/new-protocol',
+ },
+ {
+ Component: Landing,
+ name: 'Update An Existing Protocol',
+ navLinkTo: '/update-protocol',
+ path: '/update-protocol',
+ },
+]
+
+export function OpentronsAIRoutes(): JSX.Element {
+ const landingPage: RouteProps = {
+ Component: Landing,
+ name: 'Landing',
+ navLinkTo: '/',
+ path: '/',
+ }
+ const allRoutes: RouteProps[] = [...opentronsAIRoutes, landingPage]
+
+ return (
+
+ {allRoutes.map(({ Component, path }: RouteProps) => (
+ } />
+ ))}
+ } />
+
+ )
+}
diff --git a/opentrons-ai-client/src/analytics/mixpanel.ts b/opentrons-ai-client/src/analytics/mixpanel.ts
new file mode 100644
index 00000000000..eb81b72e6e3
--- /dev/null
+++ b/opentrons-ai-client/src/analytics/mixpanel.ts
@@ -0,0 +1,67 @@
+import mixpanel from 'mixpanel-browser'
+import { getHasOptedIn } from './selectors'
+
+export const getIsProduction = (): boolean =>
+ global.location.host === 'designer.opentrons.com' // UPDATE THIS TO CORRECT URL
+
+export type AnalyticsEvent =
+ | {
+ name: string
+ properties: Record
+ superProperties?: Record
+ }
+ | { superProperties: Record }
+
+// pulled in from environment at build time
+const MIXPANEL_ID = process.env.OT_AI_CLIENT_MIXPANEL_ID
+
+const MIXPANEL_OPTS = {
+ // opt out by default
+ opt_out_tracking_by_default: true,
+}
+
+export function initializeMixpanel(state: any): void {
+ const optedIn = getHasOptedIn(state) ?? false
+ if (MIXPANEL_ID != null) {
+ console.debug('Initializing Mixpanel', { optedIn })
+
+ mixpanel.init(MIXPANEL_ID, MIXPANEL_OPTS)
+ setMixpanelTracking(optedIn)
+ trackEvent({ name: 'appOpen', properties: {} }, optedIn) // TODO IMMEDIATELY: do we want this?
+ } else {
+ console.warn('MIXPANEL_ID not found; this is a bug if build is production')
+ }
+}
+
+export function trackEvent(event: AnalyticsEvent, optedIn: boolean): void {
+ console.debug('Trackable event', { event, optedIn })
+ if (MIXPANEL_ID != null && optedIn) {
+ if ('superProperties' in event && event.superProperties != null) {
+ mixpanel.register(event.superProperties)
+ }
+ if ('name' in event && event.name != null) {
+ mixpanel.track(event.name, event.properties)
+ }
+ }
+}
+
+export function setMixpanelTracking(optedIn: boolean): void {
+ if (MIXPANEL_ID != null) {
+ if (optedIn) {
+ console.debug('User has opted into analytics; tracking with Mixpanel')
+ mixpanel.opt_in_tracking()
+ // Register "super properties" which are included with all events
+ mixpanel.register({
+ appVersion: 'test', // TODO update this?
+ // NOTE(IL, 2020): Since PD may be in the same Mixpanel project as other OT web apps, this 'appName' property is intended to distinguish it
+ appName: 'opentronsAIClient',
+ })
+ } else {
+ console.debug(
+ 'User has opted out of analytics; stopping Mixpanel tracking'
+ )
+ mixpanel.opt_out_tracking()
+ mixpanel.reset()
+ }
+ }
+}
diff --git a/opentrons-ai-client/src/analytics/selectors.ts b/opentrons-ai-client/src/analytics/selectors.ts
new file mode 100644
index 00000000000..b55165f3049
--- /dev/null
+++ b/opentrons-ai-client/src/analytics/selectors.ts
@@ -0,0 +1,2 @@
+export const getHasOptedIn = (state: any): boolean | null =>
+ state.analytics.hasOptedIn
diff --git a/opentrons-ai-client/src/assets/images/welcome_dashboard.png b/opentrons-ai-client/src/assets/images/welcome_dashboard.png
new file mode 100644
index 00000000000..c6f84b4429b
Binary files /dev/null and b/opentrons-ai-client/src/assets/images/welcome_dashboard.png differ
diff --git a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json
index 21db34105db..bffc4a9265a 100644
--- a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json
+++ b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json
@@ -3,6 +3,7 @@
"api": "API: An API level is 2.15",
"application": "Application: Your protocol's name, describing what it does.",
"commands": "Commands: List the protocol's steps, specifying quantities in microliters (uL) and giving exact source and destination locations.",
+ "copyright": "Copyright © 2024 Opentrons",
"copy_code": "Copy code",
"choose_file": "Choose file",
"disclaimer": "OpentronsAI can make mistakes. Review your protocol before running it on an Opentrons robot.",
@@ -12,6 +13,12 @@
"got_feedback": "Got feedback? We love to hear it.",
"key_info": "Here are some key pieces of information to provide in your prompt:",
"labware_and_tipracks": "Labware and tip racks: Use names from the Opentrons Labware Library.",
+ "landing_page_body": "Get started building a prompt that will generate a Python protocol that you can use on your Opentrons robot. OpentronsAI lets you create and optimize your protocol by responding in natural language.",
+ "landing_page_body_mobile": "Use a desktop browser to use OpentronsAI.",
+ "landing_page_button_new_protocol": "Create a new protocol",
+ "landing_page_button_update_protocol": "Update an existing protocol",
+ "landing_page_heading": "Welcome to OpentronsAI",
+ "landing_page_image_alt": "welcome image",
"liquid_locations": "Liquid locations: Describe where liquids should go in the labware.",
"loading": "Loading...",
"login": "Login",
@@ -25,6 +32,7 @@
"pcr_flex": "PCR (Flex)",
"pcr": "PCR",
"pipettes": "Pipettes: Specify your pipettes, including the volume, number of channels, and whether they’re mounted on the left or right.",
+ "privacy_policy": "By continuing, you agree to the Opentrons Privacy Policy and End user license agreement",
"protocol_file": "Protocol file",
"provide_details_of_changes": "Provide details of changes you want to make",
"python_file_type_error": "Python file type required",
diff --git a/opentrons-ai-client/src/molecules/Footer/Footer.stories.tsx b/opentrons-ai-client/src/molecules/Footer/Footer.stories.tsx
new file mode 100644
index 00000000000..78fdf2d42bc
--- /dev/null
+++ b/opentrons-ai-client/src/molecules/Footer/Footer.stories.tsx
@@ -0,0 +1,20 @@
+import type { Meta, StoryObj } from '@storybook/react'
+import { Footer } from '.'
+import { COLORS, Flex } from '@opentrons/components'
+
+const meta: Meta = {
+ title: 'AI/Molecules/Footer',
+ component: Footer,
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+}
+export default meta
+
+type Story = StoryObj
+
+export const FooterExample: Story = {}
diff --git a/opentrons-ai-client/src/molecules/Footer/__tests__/Footer.test.tsx b/opentrons-ai-client/src/molecules/Footer/__tests__/Footer.test.tsx
new file mode 100644
index 00000000000..704855096ed
--- /dev/null
+++ b/opentrons-ai-client/src/molecules/Footer/__tests__/Footer.test.tsx
@@ -0,0 +1,38 @@
+import { Footer } from '..'
+import { renderWithProviders } from '../../../__testing-utils__'
+import { screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { i18n } from '../../../i18n'
+
+const render = (): ReturnType => {
+ return renderWithProviders(, {
+ i18nInstance: i18n,
+ })
+}
+
+describe('Footer', () => {
+ it('should render Footer component', () => {
+ render()
+ screen.getByText('Privacy Policy')
+ screen.getByText('End user license agreement')
+ screen.getByText('Copyright © 2024 Opentrons')
+ })
+
+ it('should have a link to the Privacy Policy', () => {
+ render()
+ const privacyPolicy = screen.getByText('Privacy Policy')
+ expect(privacyPolicy).toHaveAttribute(
+ 'href',
+ 'https://insights.opentrons.com/hubfs/Legal%20Documentation/Opentrons-Labworks-Privacy-Policy-5-4-23.docx-1.pdf'
+ )
+ })
+
+ it('should have a link to the end user license agreement', () => {
+ render()
+ const eula = screen.getByText('End user license agreement')
+ expect(eula).toHaveAttribute(
+ 'href',
+ 'https://insights.opentrons.com/hubfs/Legal%20Documentation/Opentrons%20EULA%2020240710.pdf'
+ )
+ })
+})
diff --git a/opentrons-ai-client/src/molecules/Footer/index.tsx b/opentrons-ai-client/src/molecules/Footer/index.tsx
new file mode 100644
index 00000000000..c8bbc4054fd
--- /dev/null
+++ b/opentrons-ai-client/src/molecules/Footer/index.tsx
@@ -0,0 +1,70 @@
+import styled from 'styled-components'
+import {
+ ALIGN_CENTER,
+ COLORS,
+ Flex,
+ JUSTIFY_CENTER,
+ SPACING,
+ TYPOGRAPHY,
+} from '@opentrons/components'
+import { Trans, useTranslation } from 'react-i18next'
+
+const NewLineText = styled.span`
+ display: block;
+`
+
+const BlueLink = styled.a`
+ color: ${COLORS.blue50};
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`
+
+const FooterText = styled.p`
+ color: ${COLORS.grey60};
+ font-size: ${TYPOGRAPHY.fontSizeH4};
+ line-height: ${TYPOGRAPHY.lineHeight16};
+ text-align: ${TYPOGRAPHY.textAlignCenter};
+ padding-bottom: ${SPACING.spacing24};
+`
+
+export function Footer(): JSX.Element {
+ const { t } = useTranslation('protocol_generator')
+
+ return (
+
+
+
+ ),
+ EULALink: (
+
+ ),
+ }}
+ />
+ {t('copyright')}
+
+
+ )
+}
diff --git a/opentrons-ai-client/src/molecules/Header/__tests__/Header.test.tsx b/opentrons-ai-client/src/molecules/Header/__tests__/Header.test.tsx
index 31f3b01e629..2769da2c8ec 100644
--- a/opentrons-ai-client/src/molecules/Header/__tests__/Header.test.tsx
+++ b/opentrons-ai-client/src/molecules/Header/__tests__/Header.test.tsx
@@ -1,8 +1,17 @@
import { renderWithProviders } from '../../../__testing-utils__'
import { i18n } from '../../../i18n'
import { Header } from '../index'
-import { describe, it } from 'vitest'
-import { screen } from '@testing-library/react'
+import { describe, it, vi, expect, beforeEach } from 'vitest'
+import { fireEvent, screen } from '@testing-library/react'
+import * as auth0 from '@auth0/auth0-react'
+
+vi.mock('@auth0/auth0-react')
+const mockLogout = vi.fn()
+const mockUseTrackEvent = vi.fn()
+
+vi.mock('../../../resources/hooks/useTrackEvent', () => ({
+ useTrackEvent: () => mockUseTrackEvent,
+}))
const render = (): ReturnType => {
return renderWithProviders(, {
@@ -11,6 +20,14 @@ const render = (): ReturnType => {
}
describe('Header', () => {
+ beforeEach(() => {
+ ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({
+ isAuthenticated: true,
+ isLoading: false,
+ logout: mockLogout,
+ })
+ })
+
it('should render Header component', () => {
render()
screen.getByText('Opentrons')
@@ -20,4 +37,21 @@ describe('Header', () => {
render()
screen.getByText('Logout')
})
+
+ it('should logout when log out button is clicked', () => {
+ render()
+ const logoutButton = screen.getByText('Logout')
+ fireEvent.click(logoutButton)
+ expect(mockLogout).toHaveBeenCalled()
+ })
+
+ it('should track logout event when log out button is clicked', () => {
+ render()
+ const logoutButton = screen.getByText('Logout')
+ fireEvent.click(logoutButton)
+ expect(mockUseTrackEvent).toHaveBeenCalledWith({
+ name: 'user-logout',
+ properties: {},
+ })
+ })
})
diff --git a/opentrons-ai-client/src/molecules/Header/index.tsx b/opentrons-ai-client/src/molecules/Header/index.tsx
index e909aeaf691..8221aa03e81 100644
--- a/opentrons-ai-client/src/molecules/Header/index.tsx
+++ b/opentrons-ai-client/src/molecules/Header/index.tsx
@@ -10,15 +10,19 @@ import {
COLORS,
POSITION_RELATIVE,
ALIGN_CENTER,
+ JUSTIFY_CENTER,
JUSTIFY_SPACE_BETWEEN,
} from '@opentrons/components'
import { useAuth0 } from '@auth0/auth0-react'
+import { CLIENT_MAX_WIDTH } from '../../resources/constants'
+import { useTrackEvent } from '../../resources/hooks/useTrackEvent'
const HeaderBar = styled(Flex)`
position: ${POSITION_RELATIVE};
background-color: ${COLORS.white};
width: 100%;
align-items: ${ALIGN_CENTER};
+ justify-content: ${JUSTIFY_CENTER};
height: 60px;
`
@@ -27,6 +31,7 @@ const HeaderBarContent = styled(Flex)`
padding: 18px 32px;
justify-content: ${JUSTIFY_SPACE_BETWEEN};
width: 100%;
+ max-width: ${CLIENT_MAX_WIDTH};
`
const HeaderGradientTitle = styled(StyledText)`
@@ -48,6 +53,12 @@ const LogoutButton = styled(LinkButton)`
export function Header(): JSX.Element {
const { t } = useTranslation('protocol_generator')
const { logout } = useAuth0()
+ const trackEvent = useTrackEvent()
+
+ function handleLogout(): void {
+ logout()
+ trackEvent({ name: 'user-logout', properties: {} })
+ }
return (
@@ -56,7 +67,7 @@ export function Header(): JSX.Element {
{t('opentrons')}
{t('ai')}
- logout()}>{t('logout')}
+ {t('logout')}
)
diff --git a/opentrons-ai-client/src/pages/Landing/__tests__/Landing.test.tsx b/opentrons-ai-client/src/pages/Landing/__tests__/Landing.test.tsx
new file mode 100644
index 00000000000..a90807878eb
--- /dev/null
+++ b/opentrons-ai-client/src/pages/Landing/__tests__/Landing.test.tsx
@@ -0,0 +1,107 @@
+import { screen } from '@testing-library/react'
+import { describe, it, vi, beforeEach, expect } from 'vitest'
+import { renderWithProviders } from '../../../__testing-utils__'
+import type { NavigateFunction } from 'react-router-dom'
+
+import { Landing } from '../index'
+import { i18n } from '../../../i18n'
+
+const mockNavigate = vi.fn()
+const mockUseTrackEvent = vi.fn()
+
+vi.mock('../../../resources/hooks/useTrackEvent', () => ({
+ useTrackEvent: () => mockUseTrackEvent,
+}))
+
+vi.mock('react-router-dom', async importOriginal => {
+ const reactRouterDom = await importOriginal()
+ return {
+ ...reactRouterDom,
+ useNavigate: () => mockNavigate,
+ }
+})
+
+vi.mock('../../../hooks/useTrackEvent', () => ({
+ useTrackEvent: () => mockUseTrackEvent,
+}))
+
+const render = () => {
+ return renderWithProviders(, {
+ i18nInstance: i18n,
+ })
+}
+
+describe('Landing', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render', () => {
+ render()
+ expect(screen.getByText('Welcome to OpentronsAI')).toBeInTheDocument()
+ })
+
+ it('should render the image, heading and body text', () => {
+ render()
+ expect(screen.getByAltText('welcome image')).toBeInTheDocument()
+ expect(screen.getByText('Welcome to OpentronsAI')).toBeInTheDocument()
+ expect(
+ screen.getByText(
+ 'Get started building a prompt that will generate a Python protocol that you can use on your Opentrons robot. OpentronsAI lets you create and optimize your protocol by responding in natural language.'
+ )
+ ).toBeInTheDocument()
+ })
+
+ it('should render create and update protocol buttons', () => {
+ render()
+ expect(screen.getByText('Create a new protocol')).toBeInTheDocument()
+ expect(screen.getByText('Update an existing protocol')).toBeInTheDocument()
+ })
+
+ it('should render the mobile body text if the screen width is less than 768px', () => {
+ vi.stubGlobal('innerWidth', 767)
+ window.dispatchEvent(new Event('resize'))
+ render()
+ expect(
+ screen.getByText('Use a desktop browser to use OpentronsAI.')
+ ).toBeInTheDocument()
+
+ vi.unstubAllGlobals()
+ })
+
+ it('should redirect to the new protocol page when the create a new protocol button is clicked', () => {
+ render()
+ const createProtocolButton = screen.getByText('Create a new protocol')
+ createProtocolButton.click()
+ expect(mockNavigate).toHaveBeenCalledWith('/new-protocol')
+ })
+
+ it('should redirect to the update protocol page when the update an existing protocol button is clicked', () => {
+ render()
+ const updateProtocolButton = screen.getByText('Update an existing protocol')
+ updateProtocolButton.click()
+ expect(mockNavigate).toHaveBeenCalledWith('/update-protocol')
+ })
+
+ it('should track new protocol event when new protocol button is clicked', () => {
+ render()
+ const createProtocolButton = screen.getByText('Create a new protocol')
+ createProtocolButton.click()
+
+ expect(mockUseTrackEvent).toHaveBeenCalledWith({
+ name: 'create-new-protocol',
+ properties: {},
+ })
+ })
+
+ it('should track logout event when log out button is clicked', () => {
+ render()
+ const updateProtocolButton = screen.getByText('Update an existing protocol')
+ updateProtocolButton.click()
+
+ expect(mockUseTrackEvent).toHaveBeenCalledWith({
+ name: 'update-protocol',
+ properties: {},
+ })
+ })
+})
diff --git a/opentrons-ai-client/src/pages/Landing/index.tsx b/opentrons-ai-client/src/pages/Landing/index.tsx
new file mode 100644
index 00000000000..b464ad5ff29
--- /dev/null
+++ b/opentrons-ai-client/src/pages/Landing/index.tsx
@@ -0,0 +1,89 @@
+import {
+ ALIGN_CENTER,
+ BORDERS,
+ COLORS,
+ DIRECTION_COLUMN,
+ Flex,
+ JUSTIFY_CENTER,
+ LargeButton,
+ POSITION_RELATIVE,
+ SPACING,
+ StyledText,
+ TEXT_ALIGN_CENTER,
+} from '@opentrons/components'
+import welcomeImage from '../../assets/images/welcome_dashboard.png'
+import { useTranslation } from 'react-i18next'
+import { useIsMobile } from '../../resources/hooks/useIsMobile'
+import { useNavigate } from 'react-router-dom'
+import { useTrackEvent } from '../../resources/hooks/useTrackEvent'
+
+export interface InputType {
+ userPrompt: string
+}
+
+export function Landing(): JSX.Element | null {
+ const navigate = useNavigate()
+ const { t } = useTranslation('protocol_generator')
+ const isMobile = useIsMobile()
+ const trackEvent = useTrackEvent()
+
+ function handleCreateNewProtocol(): void {
+ trackEvent({ name: 'create-new-protocol', properties: {} })
+ navigate('/new-protocol')
+ }
+
+ function handleUpdateProtocol(): void {
+ trackEvent({ name: 'update-protocol', properties: {} })
+ navigate('/update-protocol')
+ }
+
+ return (
+
+
+
+
+
+ {t('landing_page_heading')}
+
+
+ {!isMobile ? t('landing_page_body') : t('landing_page_body_mobile')}
+
+
+ {!isMobile && (
+ <>
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/opentrons-ai-client/src/resources/atoms.ts b/opentrons-ai-client/src/resources/atoms.ts
index 2065f7e89e2..73d45fb165b 100644
--- a/opentrons-ai-client/src/resources/atoms.ts
+++ b/opentrons-ai-client/src/resources/atoms.ts
@@ -1,6 +1,6 @@
// jotai's atoms
import { atom } from 'jotai'
-import type { Chat, ChatData } from './types'
+import type { Chat, ChatData, Mixpanel } from './types'
/** ChatDataAtom is for chat data (user prompt and response from OpenAI API) */
export const chatDataAtom = atom([])
@@ -8,3 +8,7 @@ export const chatDataAtom = atom([])
export const chatHistoryAtom = atom([])
export const tokenAtom = atom(null)
+
+export const mixpanelAtom = atom({
+ analytics: { hasOptedIn: true }, // TODO: set to false
+})
diff --git a/opentrons-ai-client/src/resources/constants.ts b/opentrons-ai-client/src/resources/constants.ts
index 834e58cb1db..c5e2f8826c6 100644
--- a/opentrons-ai-client/src/resources/constants.ts
+++ b/opentrons-ai-client/src/resources/constants.ts
@@ -19,3 +19,5 @@ export const LOCAL_AUTH0_CLIENT_ID = 'PcuD1wEutfijyglNeRBi41oxsKJ1HtKw'
export const LOCAL_AUTH0_AUDIENCE = 'sandbox-ai-api'
export const LOCAL_AUTH0_DOMAIN = 'identity.auth-dev.opentrons.com'
export const LOCAL_END_POINT = 'http://localhost:8000/api/chat/completion'
+
+export const CLIENT_MAX_WIDTH = '1440px'
diff --git a/opentrons-ai-client/src/resources/hooks/__tests__/useIsMobile.test.ts b/opentrons-ai-client/src/resources/hooks/__tests__/useIsMobile.test.ts
new file mode 100644
index 00000000000..bd1374e64b1
--- /dev/null
+++ b/opentrons-ai-client/src/resources/hooks/__tests__/useIsMobile.test.ts
@@ -0,0 +1,18 @@
+import { describe, it, vi, expect } from 'vitest'
+import { renderHook } from '@testing-library/react'
+import { useIsMobile } from '../useIsMobile'
+
+describe('useIsMobile', () => {
+ it('should return true if the window width is less than 768px', () => {
+ vi.stubGlobal('innerWidth', 767)
+ const { result } = renderHook(() => useIsMobile())
+ expect(result.current).toBe(true)
+ })
+
+ it('should return false if the window width is greater than 768px', () => {
+ vi.stubGlobal('innerWidth', 769)
+ window.dispatchEvent(new Event('resize'))
+ const { result } = renderHook(() => useIsMobile())
+ expect(result.current).toBe(false)
+ })
+})
diff --git a/opentrons-ai-client/src/resources/hooks/__tests__/useTrackEvent.test.tsx b/opentrons-ai-client/src/resources/hooks/__tests__/useTrackEvent.test.tsx
new file mode 100644
index 00000000000..fab96155156
--- /dev/null
+++ b/opentrons-ai-client/src/resources/hooks/__tests__/useTrackEvent.test.tsx
@@ -0,0 +1,60 @@
+import { describe, it, vi, expect, afterEach } from 'vitest'
+import { trackEvent } from '../../../analytics/mixpanel'
+import { useTrackEvent } from '../useTrackEvent'
+import { renderHook } from '@testing-library/react'
+import { mixpanelAtom } from '../../atoms'
+import type { AnalyticsEvent } from '../../../analytics/mixpanel'
+import type { Mixpanel } from '../../types'
+import { TestProvider } from '../../utils/testUtils'
+
+vi.mock('../../../analytics/mixpanel', () => ({
+ trackEvent: vi.fn(),
+}))
+
+describe('useTrackEvent', () => {
+ afterEach(() => {
+ vi.resetAllMocks()
+ })
+
+ it('should call trackEvent with the correct arguments when hasOptedIn is true', () => {
+ const mockMixpanelAtom: Mixpanel = {
+ analytics: {
+ hasOptedIn: true,
+ },
+ }
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ )
+
+ const { result } = renderHook(() => useTrackEvent(), { wrapper })
+
+ const event: AnalyticsEvent = { name: 'test_event', properties: {} }
+ result.current(event)
+
+ expect(trackEvent).toHaveBeenCalledWith(event, true)
+ })
+
+ it('should call trackEvent with the correct arguments when hasOptedIn is false', () => {
+ const mockMixpanelAtomFalse: Mixpanel = {
+ analytics: {
+ hasOptedIn: false,
+ },
+ }
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ )
+
+ const { result } = renderHook(() => useTrackEvent(), { wrapper })
+
+ const event: AnalyticsEvent = { name: 'test_event', properties: {} }
+ result.current(event)
+
+ expect(trackEvent).toHaveBeenCalledWith(event, false)
+ })
+})
diff --git a/opentrons-ai-client/src/resources/hooks/useIsMobile.ts b/opentrons-ai-client/src/resources/hooks/useIsMobile.ts
new file mode 100644
index 00000000000..5c0f4933b75
--- /dev/null
+++ b/opentrons-ai-client/src/resources/hooks/useIsMobile.ts
@@ -0,0 +1,22 @@
+import { useState, useEffect } from 'react'
+
+const MOBILE_BREAKPOINT = 768
+
+export const useIsMobile = (): boolean => {
+ const [isMobile, setIsMobile] = useState(
+ window.innerWidth < MOBILE_BREAKPOINT
+ )
+
+ useEffect(() => {
+ const handleResize = (): void => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ }
+
+ window.addEventListener('resize', handleResize)
+ return () => {
+ window.removeEventListener('resize', handleResize)
+ }
+ }, [])
+
+ return isMobile
+}
diff --git a/opentrons-ai-client/src/resources/hooks/useTrackEvent.ts b/opentrons-ai-client/src/resources/hooks/useTrackEvent.ts
new file mode 100644
index 00000000000..bdd9eb1c470
--- /dev/null
+++ b/opentrons-ai-client/src/resources/hooks/useTrackEvent.ts
@@ -0,0 +1,16 @@
+import { useAtom } from 'jotai'
+import { trackEvent } from '../../analytics/mixpanel'
+import { mixpanelAtom } from '../atoms'
+import type { AnalyticsEvent } from '../types'
+
+/**
+ * React hook to send an analytics tracking event directly from a component
+ *
+ * @returns {AnalyticsEvent => void} track event function
+ */
+export function useTrackEvent(): (e: AnalyticsEvent) => void {
+ const [mixpanel] = useAtom(mixpanelAtom)
+ return event => {
+ trackEvent(event, mixpanel?.analytics?.hasOptedIn ?? false)
+ }
+}
diff --git a/opentrons-ai-client/src/resources/types.ts b/opentrons-ai-client/src/resources/types.ts
index d2758c966ae..067c1ef9764 100644
--- a/opentrons-ai-client/src/resources/types.ts
+++ b/opentrons-ai-client/src/resources/types.ts
@@ -16,3 +16,29 @@ export interface Chat {
/** content ChatGPT API return or user prompt */
content: string
}
+
+export interface RouteProps {
+ /** the component rendered by a route match
+ * drop developed components into slots held by placeholder div components
+ * */
+ Component: React.FC
+ /** a route/page name to render in the nav bar
+ */
+ name: string
+ /** the path for navigation linking, for example to push to a default tab
+ */
+ path: string
+ navLinkTo: string
+}
+
+export interface Mixpanel {
+ analytics: {
+ hasOptedIn: boolean
+ }
+}
+
+export interface AnalyticsEvent {
+ name: string
+ properties: Record
+ superProperties?: Record
+}
diff --git a/opentrons-ai-client/src/resources/utils/testUtils.tsx b/opentrons-ai-client/src/resources/utils/testUtils.tsx
new file mode 100644
index 00000000000..954307bd391
--- /dev/null
+++ b/opentrons-ai-client/src/resources/utils/testUtils.tsx
@@ -0,0 +1,29 @@
+import { Provider } from 'jotai'
+import { useHydrateAtoms } from 'jotai/utils'
+
+interface HydrateAtomsProps {
+ initialValues: Array<[any, any]>
+ children: React.ReactNode
+}
+
+interface TestProviderProps {
+ initialValues: Array<[any, any]>
+ children: React.ReactNode
+}
+
+export const HydrateAtoms = ({
+ initialValues,
+ children,
+}: HydrateAtomsProps): React.ReactNode => {
+ useHydrateAtoms(initialValues)
+ return children
+}
+
+export const TestProvider = ({
+ initialValues,
+ children,
+}: TestProviderProps): React.ReactNode => (
+
+ {children}
+
+)
diff --git a/protocol-designer/src/ProtocolEditor.tsx b/protocol-designer/src/ProtocolEditor.tsx
index 570b27da6b6..8c9fb9fe1a0 100644
--- a/protocol-designer/src/ProtocolEditor.tsx
+++ b/protocol-designer/src/ProtocolEditor.tsx
@@ -16,7 +16,7 @@ import { PrereleaseModeIndicator } from './components/PrereleaseModeIndicator'
import { PortalRoot as TopPortalRoot } from './components/portals/TopPortal'
import { FileUploadMessageModal } from './components/modals/FileUploadMessageModal/FileUploadMessageModal'
import { LabwareUploadMessageModal } from './components/modals/LabwareUploadMessageModal/LabwareUploadMessageModal'
-import { GateModal } from './components/modals/GateModal'
+import { GateModal } from './organisms/GateModal'
import { CreateFileWizard } from './components/modals/CreateFileWizard'
import { AnnouncementModal } from './organisms'
import { ProtocolRoutes } from './ProtocolRoutes'
diff --git a/protocol-designer/src/ProtocolRoutes.tsx b/protocol-designer/src/ProtocolRoutes.tsx
index e2766b34c14..908f46539be 100644
--- a/protocol-designer/src/ProtocolRoutes.tsx
+++ b/protocol-designer/src/ProtocolRoutes.tsx
@@ -11,7 +11,7 @@ import {
Kitchen,
FileUploadMessagesModal,
LabwareUploadModal,
- AnnouncementModal,
+ GateModal,
} from './organisms'
import type { RouteProps } from './types'
@@ -57,13 +57,15 @@ export function ProtocolRoutes(): JSX.Element {
path: '/',
}
const allRoutes: RouteProps[] = [...pdRoutes, landingPage]
+ const showGateModal =
+ process.env.NODE_ENV === 'production' || process.env.OT_PD_SHOW_GATE
return (
<>
-
+ {showGateModal ? : null}
diff --git a/protocol-designer/src/assets/localization/en/alert.json b/protocol-designer/src/assets/localization/en/alert.json
index b8f73cc290b..248d70a0aec 100644
--- a/protocol-designer/src/assets/localization/en/alert.json
+++ b/protocol-designer/src/assets/localization/en/alert.json
@@ -97,75 +97,75 @@
"timeline": {
"error": {
"LABWARE_DISCARDED_IN_WASTE_CHUTE": {
- "title": "The labware has been previously discarded into the waste chute",
- "body": "Please select a different labware to move."
+ "title": "Labware not available",
+ "body": "This step uses labware that has previously been discarded into a Waste Chute."
},
"LABWARE_ON_ANOTHER_ENTITY": {
- "title": "Attempting to move a labware on top of another entity",
+ "title": "Attempting to move labware on top of another entity",
"body": "Please reselect which slot your labware should move to."
},
"INSUFFICIENT_TIPS": {
"title": "Not enough tips to complete action",
- "body": "Add another tip rack to an empty slot in "
+ "body": "Add another tip rack to your deck or change your tip management during transfer and mix steps.",
+ "link": "Edit starting deck"
},
"NO_TIP_SELECTED": {
"title": "No tip rack was selected to complete action",
"body": "Add a tip rack in the step"
},
"NO_TIP_ON_PIPETTE": {
- "title": "No tip on pipette at the start of step",
- "body1": "Choose a different Change Tip setting. Change Tip cannot be \"Never\" the first time a pipette is used in a protocol, or following a step that used the ",
- "link": "Air gap dispense setting",
- "body2": ". Pipetting steps must begin with a tip on."
+ "title": "No tip on pipette at start of step",
+ "body": "Use a different tip handling setting. Don't set it to Never the first time a pipette is used in a protocol, or following a step that air gaps when dispensing."
},
"MODULE_PIPETTE_COLLISION_DANGER": {
- "title": "Pipette cannot access labware",
- "body": "Gen 1 8-Channel pipettes cannot access labware or tip racks in slot 4 or 6 because they are adjacent to modules. Read more "
+ "title": "GEN1 8-Channel Pipettes can't move adjacent to modules",
+ "body": "Move labware and modules or use a different pipette."
},
"MISSING_MODULE": {
- "title": "Missing module for step",
- "body": "A step requires a module that does not exist"
+ "title": "Module not in protocol",
+ "body": "This step tries to use a module not in the protocol. Add the module to your protocol or remove this step."
},
"MISSING_TEMPERATURE_STEP": {
- "title": "Missing Temperature step",
- "body": "Add a Temperature step prior to this Pause step. The module is not currently changing temperature because it has either been deactivated or is holding a temperature"
+ "title": "Unreachable target temperature",
+ "body": "The protocol can't proceed beyond this pause step, because the module is not changing temperature. Add or modify a temperature step before this step."
},
"THERMOCYCLER_LID_CLOSED": {
- "title": "Thermocycler lid is closed",
- "body": "Before the robot can interact with labware in the Thermocycler, the lid must be open. To resolve this error, please add a thermocycler step ahead of the current step, and set the lid status to \"open\"."
+ "title": "Thermocycler lid closed",
+ "body": "This step tries to use labware in the Thermocycler. Open the lid before this step."
},
"HEATER_SHAKER_LATCH_OPEN": {
- "title": "Heater-Shaker labware latch is open",
+ "title": "Heater-Shaker latch open",
"body": "Before the robot can interact with labware on the Heater-Shaker module, the labware latch must be closed. To resolve this error, please add a Heater-Shaker step ahead of the current step, and set the labware latch status to \"closed\"."
},
"HEATER_SHAKER_IS_SHAKING": {
"title": "Heater-Shaker is shaking",
- "body": "the robot cannot interact with labware on the Heater-Shaker Module while it is shaking."
+ "body": "The robot cannot interact with labware on the Heater-Shaker Module while it is shaking. Add a step to stop shaking to interact with the labware."
},
"TALL_LABWARE_EAST_WEST_OF_HEATER_SHAKER": {
"body": "The Heater-Shaker labware latch will collide with labware over 53 mm. Move labware to a different slot."
},
"HEATER_SHAKER_EAST_WEST_LATCH_OPEN": {
+ "title": "Heater-Shaker labware latch open",
"body": "Pipettes cannot access labware on, or to the left or right of, the Heater-Shaker while the labware latch is open. Create a step before this one that closes the latch."
},
"HEATER_SHAKER_NORTH_SOUTH_EAST_WEST_SHAKING": {
- "title": "The Heater-Shaker is shaking",
- "body": "Pipettes cannot access labware on or adjacent to the Heater-Shaker while it is shaking. Create a step before this one that deactivates the shaker."
+ "title": "Robot unable to perform step",
+ "body": "The robot cannot interact with labware on or next to the Heater-Shaker Module while it is shaking. Add a heater-shaker step to stop shaking."
},
"HEATER_SHAKER_EAST_WEST_MULTI_CHANNEL": {
- "title": "8-Channel pipette cannot access labware",
- "body": "8-Channel pipettes cannot access labware or tip racks to the left or right of a Heater-Shaker GEN1 module. Move labware to a different slot to access it with an 8-Channel pipette."
+ "title": "8-Channel unable to access slot",
+ "body": "8-Channel pipettes cannot access labware to the left or right of a Heater-Shaker GEN1 module. Move labware to a different slot to access it with an 8-Channel pipette."
},
"HEATER_SHAKER_NORTH_SOUTH__OF_NON_TIPRACK_WITH_MULTI_CHANNEL": {
- "title": "8-Channel pipette cannot access labware",
- "body": "8-Channel pipettes cannot access labware in front of or behind a Heater-Shaker. They can access Opentrons Tip Racks in this slot. Move labware to a different slot."
+ "title": "8-Channel unable to access slot",
+ "body": "8-Channel pipettes cannot access non-tiprack labware to the top or bottom of a Heater-Shaker GEN1 module. Move labware to a different slot to access it with an 8-Channel pipette."
},
"LABWARE_OFF_DECK": {
- "title": "Labware is off-deck",
+ "title": "Labware not on deck",
"body": "The robot can only perform steps on labware that is on the deck. Add or change a Move Labware step to put it on the deck before this step."
},
"HEATER_SHAKER_LATCH_CLOSED": {
- "title": "Heater-Shaker labware latch is closed",
+ "title": "Heater-Shaker latch closed",
"body": "The Heater-Shaker’s labware latch must be open when moving labware to or from the module. Add a Heater-Shaker step that opens the latch before this step."
},
"DROP_TIP_LOCATION_DOES_NOT_EXIST": {
@@ -173,46 +173,56 @@
"body": "The Waste Chute or Trash Bin to drop tip in does not exist."
},
"MISSING_96_CHANNEL_TIPRACK_ADAPTER": {
- "title": "Missing 96-channel tip rack adapter",
- "body": "The tip rack must be placed in an adapter when picking up 96 tips simultaneously."
+ "title": "Tip rack adapter required",
+ "body": "The 96-channel pipette uses a tip rack adapter to pick up a full rack of tips. Add one to your starting deck or use partial tip pickup.",
+ "link": "Edit starting deck"
},
"EQUIPMENT_DOES_NOT_EXIST": {
- "title": "Attempting to interact with an unknown entity",
+ "title": "Unable to perform step",
"body": "An entity you are interacting with does not exist."
},
"GRIPPER_REQUIRED": {
- "title": "A gripper is required to complete this action",
- "body": "Attempting to move a labware without a gripper into the waste chute. Please add a gripper to this step."
+ "title": "Cannot move with gripper",
+ "body": "The gripper cannot move aluminum blocks. Deselect the 'Use Gripper' checkbox."
},
"REMOVE_96_CHANNEL_TIPRACK_ADAPTER": {
- "title": "Do not use tip rack adapter for partial tip pickup",
- "body": "Partial tip pickup requires a tip rack placed directly on the deck. Remove the adapter, or add a new tip rack without an adapter."
+ "title": "Extra tip rack adapter",
+ "body": "When picking up fewer than 96 tips, the tip rack must be placed directly on the deck, not in the tip rack adapter.",
+ "link": "Edit starting deck"
},
"CANNOT_MOVE_WITH_GRIPPER": {
"title": "Cannot move with gripper",
"body": "The gripper cannot move aluminum blocks. Edit the step and deselect the 'Use Gripper' checkbox."
},
"PIPETTE_HAS_TIP": {
- "title": "Possible collision with tip",
- "body": "The gripper cannot pick up labware while pipettes have tips attached. Drop all tips before this move labware step."
+ "title": "Gripper movement with tips attached",
+ "body": "Picking up labware with the gripper while tips are on an adjacent pipette can cause collisions. Drop tips from all pipettes before this step."
+ },
+ "POSSIBLE_PIPETTE_COLLISION": {
+ "title": "Pipette collisions likely",
+ "body": "There is a possibility that the pipette will collide with the adjascent labware or module for partial tip pick up."
}
},
"warning": {
"ASPIRATE_MORE_THAN_WELL_CONTENTS": {
- "title": "Not enough liquid in well(s)",
- "body": "You are trying to aspirate more than the current volume of one of your well(s)"
+ "title": "Not enough liquid",
+ "body": "This step tries to aspirate more than the current volume of a source well."
},
"ASPIRATE_FROM_PRISTINE_WELL": {
"title": "Source well is empty",
- "body": "The well(s) you're trying to aspirate from are empty. To add liquids, hover over labware in "
+ "body": "This step tries to aspirate from an empty well."
},
"LABWARE_IN_WASTE_CHUTE_HAS_LIQUID": {
- "title": "Moving labware into waste chute",
- "body": "This labware has remaining liquid, be advised that once you dispose of it, there is no way to get it back later in the protocol."
+ "title": "Disposing liquid-filled labware",
+ "body": "This step moves a labware that contains liquid to the waste chute. There is no way to retrieve the liquid after disposal."
},
"TIPRACK_IN_WASTE_CHUTE_HAS_TIPS": {
- "title": "Moving tiprack into waste chute",
- "body": "This tiprack has remaining tips, be advised that once you dispose of it, there is no way to get it back later in the protocol. "
+ "title": "Disposing unused tips",
+ "body": "This step moves a tip rack that contains unused tips to the waste chute. There is no way to retrieve the tips after disposal."
+ },
+ "TEMPERATURE_IS_POTENTIALLY_UNREACHABLE": {
+ "title": "The pause temperature is potentially unreachable",
+ "body": "This step tries to set the module temperature but it can possibly not be reached, resulting in your protocol running forever."
}
}
},
diff --git a/protocol-designer/src/assets/localization/en/application.json b/protocol-designer/src/assets/localization/en/application.json
index 78394f35ab7..b4d1ebf8157 100644
--- a/protocol-designer/src/assets/localization/en/application.json
+++ b/protocol-designer/src/assets/localization/en/application.json
@@ -43,7 +43,7 @@
"thermocycler": "thermocycler"
},
"temperature": "Temperature (˚C)",
- "time": "Time (hh:mm:ss)",
+ "time": "Time",
"units": {
"cycles": "cycles",
"degrees": "°C",
@@ -55,6 +55,7 @@
"rpm": "rpm",
"seconds": "s",
"time": "mm:ss",
+ "time_hms": "hh:mm:ss",
"times": "x"
},
"update": "UPDATE",
diff --git a/protocol-designer/src/assets/localization/en/form.json b/protocol-designer/src/assets/localization/en/form.json
index 80bef5b5a20..995c6ae9d0e 100644
--- a/protocol-designer/src/assets/localization/en/form.json
+++ b/protocol-designer/src/assets/localization/en/form.json
@@ -72,23 +72,24 @@
"range": "between {{min}} and {{max}}"
},
"heaterShaker": {
+ "duration": "Duration",
"latch": {
- "setLatch": "Labware Latch",
- "toggleOff": "Closed & Locked",
+ "setLatch": "Labware latch",
+ "toggleOff": "Close",
"toggleOn": "Open"
},
"shaker": {
- "setShake": "Set shake speed",
- "toggleOff": "Deactivated",
+ "setShake": "Shake speed",
+ "toggleOff": "Deactivate",
"toggleOn": "Active"
},
"temperature": {
- "setTemperature": "Set temperature",
- "toggleOff": "Deactivated",
+ "setTemperature": "Temperature",
+ "toggleOff": "Deactivate",
"toggleOn": "Active"
},
"timer": {
- "heaterShakerSetTimer": "Set timer"
+ "heaterShakerSetTimer": "Timer"
}
},
"location": {
@@ -144,6 +145,7 @@
}
},
"pauseAction": {
+ "duration": "Delay duration",
"options": {
"untilResume": "Pause until told to resume",
"untilTemperature": "Pause until temperature reached",
diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json
index 42ccaa9c15b..4f7e3a00ed6 100644
--- a/protocol-designer/src/assets/localization/en/protocol_steps.json
+++ b/protocol-designer/src/assets/localization/en/protocol_steps.json
@@ -93,6 +93,7 @@
"select_volume": "Select a volume",
"shake": "Shake",
"single": "Single path",
+ "speed": "Speed",
"starting_deck_state": "Starting deck state",
"step_substeps": "{{stepType}} details",
"temperature": "Temperature",
@@ -105,6 +106,8 @@
"closed": "closed",
"open": "open"
},
+ "repeat": "Repeat {{repetitions}} times",
+ "substep_settings": "Set block temperature tofor",
"thermocycler_profile": {
"end_hold": {
"block": "End at thermocycler block",
diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json
index 737d92cc952..8151c1e3270 100644
--- a/protocol-designer/src/assets/localization/en/shared.json
+++ b/protocol-designer/src/assets/localization/en/shared.json
@@ -1,5 +1,7 @@
{
"add": "add",
+ "agree": "Agree",
+ "analytics_tracking": "I consent to analytics tracking:",
"amount": "Amount:",
"app_settings": "App settings",
"ask_for_labware_overwrite": "Duplicate labware name",
@@ -37,6 +39,7 @@
"labware_detail": "Labware detail",
"labware_name_conflict": "Duplicate labware name",
"labware": "Labware",
+ "learn_more": "Learn more about the recent changes in the {{version}} release.",
"left_right": "Left+Right",
"left": "Left",
"liquid": "Liquid",
@@ -101,8 +104,10 @@
"protocol_designer": "Protocol Designer",
"re_export": "To use this definition, use Labware Creator to give it a unique load name and display name.",
"remove": "remove",
+ "reject": "Reject",
"reset_hints_and_tips": "Reset all hints and tips notifications",
"reset_hints": "Reset hints",
+ "review_our_privacy_policy": "You can adjust this setting at any time by clicking on the settings icon. Find detailed information in our privacy policy.",
"right": "Right",
"save": "Save",
"settings": "Settings",
@@ -115,9 +120,11 @@
"stagingArea": "Staging area",
"step_count": "Step {{current}}",
"step": "Step {{current}} / {{max}}",
+ "consent_to_eula": "By using Protocol Designer, you consent to the Opentrons EULA.",
"temperaturemoduletype": "Temperature Module",
"thermocyclermoduletype": "Thermocycler Module",
"trashBin": "Trash Bin",
+ "updated_protocol_designer": "We've updated Protocol Designer!",
"user_settings": "User settings",
"uses_standard_namespace": "Opentrons verified labware",
"version": "Version {{version}}",
@@ -127,5 +134,6 @@
"wasteChuteAndStagingArea": "Waste chute and staging area slot",
"we_are_improving": "In order to improve our products, Opentrons would like to collect data related to your use of Protocol Designer. With your consent, Opentrons will collect and store analytics and session data, including through the use of cookies and similar technologies, solely for the purpose enhancing our products. Find detailed information in our privacy policy. By using Protocol Designer, you consent to the Opentrons EULA.",
"welcome": "Welcome to Protocol Designer!",
+ "opentrons_collects_data": "In order to improve our products, Opentrons would like to collect data related to your use of Protocol Designer. With your consent, Opentrons will collect and store analytics and session data, including through the use of cookies and similar technologies, solely for the purpose enhancing our products.",
"yes": "Yes"
}
diff --git a/protocol-designer/src/atoms/ToggleButton/index.tsx b/protocol-designer/src/atoms/ToggleButton/index.tsx
index 9bb4c45a330..0dfb605ec7b 100644
--- a/protocol-designer/src/atoms/ToggleButton/index.tsx
+++ b/protocol-designer/src/atoms/ToggleButton/index.tsx
@@ -1,7 +1,7 @@
import type * as React from 'react'
import { css } from 'styled-components'
-import { Btn, Icon, COLORS } from '@opentrons/components'
+import { Btn, Icon, COLORS, Flex } from '@opentrons/components'
import type { StyleProps } from '@opentrons/components'
@@ -38,8 +38,8 @@ const TOGGLE_ENABLED_STYLES = css`
`
interface ToggleButtonProps extends StyleProps {
- label: string
toggledOn: boolean
+ label?: string | null
disabled?: boolean | null
id?: string
onClick?: (e: React.MouseEvent) => void
@@ -59,7 +59,9 @@ export function ToggleButton(props: ToggleButtonProps): JSX.Element {
css={props.toggledOn ? TOGGLE_ENABLED_STYLES : TOGGLE_DISABLED_STYLES}
{...buttonProps}
>
-
+
+
+
)
}
diff --git a/protocol-designer/src/atoms/constants.ts b/protocol-designer/src/atoms/constants.ts
index e5c73333cd8..e1acf21fddc 100644
--- a/protocol-designer/src/atoms/constants.ts
+++ b/protocol-designer/src/atoms/constants.ts
@@ -1,5 +1,6 @@
import { css } from 'styled-components'
-import { COLORS } from '@opentrons/components'
+import { COLORS, OVERFLOW_HIDDEN } from '@opentrons/components'
+import type { FlattenSimpleInterpolation } from 'styled-components'
export const BUTTON_LINK_STYLE = css`
color: ${COLORS.grey60};
@@ -7,3 +8,14 @@ export const BUTTON_LINK_STYLE = css`
color: ${COLORS.grey40};
}
`
+
+export const LINE_CLAMP_TEXT_STYLE = (
+ lineClamp: number
+): FlattenSimpleInterpolation => css`
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ overflow: ${OVERFLOW_HIDDEN};
+ text-overflow: ellipsis;
+ word-wrap: break-word;
+ -webkit-line-clamp: ${lineClamp};
+`
diff --git a/protocol-designer/src/components/StepEditForm/index.tsx b/protocol-designer/src/components/StepEditForm/index.tsx
index 8b54c56c891..747651e7268 100644
--- a/protocol-designer/src/components/StepEditForm/index.tsx
+++ b/protocol-designer/src/components/StepEditForm/index.tsx
@@ -187,7 +187,7 @@ const StepEditFormManager = (
: ''
}
handleCancelClick={saveStepForm}
- handleContinueClick={confirmAddPauseUntilTempStep}
+ handleContinueClick={handleSave}
// TODO (nd: 10/21/2024) can remove this prop once redesign FF removed
moduleType={
showAddPauseUntilTempStepModal
diff --git a/protocol-designer/src/components/modals/GateModal/index.tsx b/protocol-designer/src/components/modals/GateModal/index.tsx
deleted file mode 100644
index b3b97e4f366..00000000000
--- a/protocol-designer/src/components/modals/GateModal/index.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { useTranslation } from 'react-i18next'
-import { useSelector, useDispatch } from 'react-redux'
-import cx from 'classnames'
-import { AlertModal } from '@opentrons/components'
-import {
- actions as analyticsActions,
- selectors as analyticsSelectors,
-} from '../../../analytics'
-import settingsStyles from '../../SettingsPage/SettingsPage.module.css'
-import modalStyles from '../modal.module.css'
-
-export function GateModal(): JSX.Element | null {
- const { t } = useTranslation(['card', 'button'])
- const hasOptedIn = useSelector(analyticsSelectors.getHasOptedIn)
- const dispatch = useDispatch()
-
- if (hasOptedIn == null) {
- return (
- dispatch(analyticsActions.optOut()),
- children: t('button:no'),
- },
- {
- onClick: () => dispatch(analyticsActions.optIn()),
- children: t('button:yes'),
- },
- ]}
- >
- {t('toggle.share_session')}
-
-
- {t('body.reason_for_collecting_data')}
-
-
- - {t('body.data_collected_is_internal')}
- - {t('body.data_only_from_pd')}
- - {t('body.opt_out_of_data_collection')}
-
-
-
- )
- } else {
- return null
- }
-}
diff --git a/protocol-designer/src/file-data/actions.ts b/protocol-designer/src/file-data/actions.ts
index 0e175493baf..639966b1501 100644
--- a/protocol-designer/src/file-data/actions.ts
+++ b/protocol-designer/src/file-data/actions.ts
@@ -1,4 +1,8 @@
-import type { FileMetadataFields, SaveFileMetadataAction } from './types'
+import type {
+ FileMetadataFields,
+ SaveFileMetadataAction,
+ SelectDesignerTabAction,
+} from './types'
import type { WorkerResponse } from '../timelineMiddleware/types'
export const saveFileMetadata = (
payload: FileMetadataFields
@@ -22,3 +26,14 @@ export const computeRobotStateTimelineSuccess = (
type: 'COMPUTE_ROBOT_STATE_TIMELINE_SUCCESS',
payload,
})
+
+export interface DesignerTabPayload {
+ tab: 'protocolSteps' | 'startingDeck'
+}
+
+export const selectDesignerTab = (
+ payload: DesignerTabPayload
+): SelectDesignerTabAction => ({
+ type: 'SELECT_DESIGNER_TAB',
+ payload,
+})
diff --git a/protocol-designer/src/file-data/reducers/index.ts b/protocol-designer/src/file-data/reducers/index.ts
index 7f4d010e8ec..0d2b50ea773 100644
--- a/protocol-designer/src/file-data/reducers/index.ts
+++ b/protocol-designer/src/file-data/reducers/index.ts
@@ -7,8 +7,15 @@ import type { RobotType } from '@opentrons/shared-data'
import type { Action } from '../../types'
import type { LoadFileAction, NewProtocolFields } from '../../load-file'
import type { Substeps } from '../../steplist/types'
-import type { ComputeRobotStateTimelineSuccessAction } from '../actions'
-import type { FileMetadataFields, SaveFileMetadataAction } from '../types'
+import type {
+ ComputeRobotStateTimelineSuccessAction,
+ DesignerTabPayload,
+} from '../actions'
+import type {
+ FileMetadataFields,
+ SaveFileMetadataAction,
+ SelectDesignerTabAction,
+} from '../types'
export const timelineIsBeingComputed: Reducer = handleActions(
{
@@ -110,6 +117,18 @@ const robotTypeReducer = (
}
return state
}
+
+const designerTabReducer = (
+ state: DesignerTabPayload['tab'] = 'startingDeck',
+ action: SelectDesignerTabAction
+): DesignerTabPayload['tab'] => {
+ if (action.type === 'SELECT_DESIGNER_TAB') {
+ return action.payload.tab
+ } else {
+ return state
+ }
+}
+
export interface RootState {
computedRobotStateTimeline: Timeline
computedSubsteps: Substeps
@@ -117,6 +136,7 @@ export interface RootState {
fileMetadata: FileMetadataFields
timelineIsBeingComputed: boolean
robotType: RobotType
+ designerTab: DesignerTabPayload['tab']
}
const _allReducers = {
computedRobotStateTimeline,
@@ -125,6 +145,7 @@ const _allReducers = {
fileMetadata,
timelineIsBeingComputed,
robotType: robotTypeReducer,
+ designerTab: designerTabReducer,
}
export const rootReducer: Reducer = combineReducers(
_allReducers
diff --git a/protocol-designer/src/file-data/selectors/fileFields.ts b/protocol-designer/src/file-data/selectors/fileFields.ts
index 33824dfff79..5b66170f222 100644
--- a/protocol-designer/src/file-data/selectors/fileFields.ts
+++ b/protocol-designer/src/file-data/selectors/fileFields.ts
@@ -3,6 +3,7 @@ import type { BaseState, Selector } from '../../types'
import type { RootState } from '../reducers'
import type { FileMetadataFields } from '../types'
import type { RobotType } from '@opentrons/shared-data'
+import type { DesignerTabPayload } from '../actions'
export const rootSelector = (state: BaseState): RootState => state.fileData
export const getCurrentProtocolExists: Selector = createSelector(
@@ -21,3 +22,7 @@ export const getRobotType: Selector = createSelector(
rootSelector,
state => state.robotType
)
+
+export const getDesignerTab: Selector<
+ DesignerTabPayload['tab']
+> = createSelector(rootSelector, state => state.designerTab)
diff --git a/protocol-designer/src/file-data/types.ts b/protocol-designer/src/file-data/types.ts
index dc784b51c75..9b64f3e26b6 100644
--- a/protocol-designer/src/file-data/types.ts
+++ b/protocol-designer/src/file-data/types.ts
@@ -1,7 +1,13 @@
import type { ProtocolFile } from '@opentrons/shared-data'
+import type { DesignerTabPayload } from './actions'
export type FileMetadataFields = ProtocolFile<{}>['metadata']
export type FileMetadataFieldAccessors = keyof FileMetadataFields
export interface SaveFileMetadataAction {
type: 'SAVE_FILE_METADATA'
payload: FileMetadataFields
}
+
+export interface SelectDesignerTabAction {
+ type: 'SELECT_DESIGNER_TAB'
+ payload: DesignerTabPayload
+}
diff --git a/protocol-designer/src/form-types.ts b/protocol-designer/src/form-types.ts
index 0dc4497cdae..58da6b5676b 100644
--- a/protocol-designer/src/form-types.ts
+++ b/protocol-designer/src/form-types.ts
@@ -366,6 +366,7 @@ export interface HydratedHeaterShakerFormData {
heaterShakerSetTimer: 'true' | 'false' | null
heaterShakerTimerMinutes: string | null
heaterShakerTimerSeconds: string | null
+ heaterShakerTimer?: string | null
id: string
latchOpen: boolean
moduleId: string
diff --git a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx
index ed57de37f3b..b27b72b7821 100644
--- a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx
+++ b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx
@@ -18,12 +18,11 @@ interface ToggleExpandStepFormFieldProps extends FieldProps {
fieldTitle: string
isSelected: boolean
units: string
- onLabel: string
- offLabel: string
toggleUpdateValue: (value: unknown) => void
toggleValue: unknown
+ onLabel?: string
+ offLabel?: string
caption?: string
- islabel?: boolean
}
export function ToggleExpandStepFormField(
props: ToggleExpandStepFormFieldProps
@@ -38,16 +37,24 @@ export function ToggleExpandStepFormField(
toggleUpdateValue,
toggleValue,
caption,
- islabel,
...restProps
} = props
+ const resetFieldValue = (): void => {
+ restProps.updateValue('null')
+ }
+
const onToggleUpdateValue = (): void => {
if (typeof toggleValue === 'boolean') {
toggleUpdateValue(!toggleValue)
+ if (toggleValue) {
+ resetFieldValue()
+ }
} else if (toggleValue === 'engage' || toggleValue === 'disengage') {
const newToggleValue = toggleValue === 'engage' ? 'disengage' : 'engage'
toggleUpdateValue(newToggleValue)
+ } else if (toggleValue == null) {
+ toggleUpdateValue(true)
}
}
@@ -60,16 +67,10 @@ export function ToggleExpandStepFormField(
>
{title}
-
- {islabel ? (
-
- {isSelected ? onLabel : offLabel}
-
- ) : null}
-
+
+
+ {isSelected ? onLabel : offLabel ?? null}
+
{
onToggleUpdateValue()
diff --git a/protocol-designer/src/molecules/ToggleStepFormField/index.tsx b/protocol-designer/src/molecules/ToggleStepFormField/index.tsx
index 51bc2a1ef25..9ce244c7d57 100644
--- a/protocol-designer/src/molecules/ToggleStepFormField/index.tsx
+++ b/protocol-designer/src/molecules/ToggleStepFormField/index.tsx
@@ -62,14 +62,16 @@ export function ToggleStepFormField(
>
{isSelected ? onLabel : offLabel}
- {
- toggleUpdateValue(!toggleValue)
- }}
- label={isSelected ? onLabel : offLabel}
- toggledOn={isSelected}
- />
+ {isDisabled ? null : (
+ {
+ toggleUpdateValue(!toggleValue)
+ }}
+ label={isSelected ? onLabel : offLabel}
+ toggledOn={isSelected}
+ />
+ )}
diff --git a/protocol-designer/src/organisms/Alerts/ErrorContents.tsx b/protocol-designer/src/organisms/Alerts/ErrorContents.tsx
index 23754599ff4..73dfa60f995 100644
--- a/protocol-designer/src/organisms/Alerts/ErrorContents.tsx
+++ b/protocol-designer/src/organisms/Alerts/ErrorContents.tsx
@@ -1,7 +1,14 @@
import { useTranslation } from 'react-i18next'
-import { START_TERMINAL_ITEM_ID } from '../../steplist'
-import { KnowledgeBaseLink } from '../../components/KnowledgeBaseLink'
-import { TerminalItemLink } from './TerminalItemLink'
+import { useDispatch } from 'react-redux'
+import {
+ Btn,
+ Flex,
+ JUSTIFY_SPACE_BETWEEN,
+ SPACING,
+ TYPOGRAPHY,
+} from '@opentrons/components'
+import { BUTTON_LINK_STYLE } from '../../atoms'
+import { selectDesignerTab } from '../../file-data/actions'
import type { AlertLevel } from './types'
@@ -14,6 +21,7 @@ export const ErrorContents = (
): JSX.Element | null => {
const { errorType, level } = props
const { t } = useTranslation(['alert', 'shared'])
+ const dispatch = useDispatch()
if (level === 'timeline') {
const bodyText = t(`timeline.error.${errorType}.body`, {
@@ -22,29 +30,42 @@ export const ErrorContents = (
switch (errorType) {
case 'INSUFFICIENT_TIPS':
return (
- <>
+
{bodyText}
-
- >
- )
- case 'MODULE_PIPETTE_COLLISION_DANGER':
- return (
- <>
- {bodyText}
-
- {t('shared:here')}
-
- >
+ {
+ dispatch(selectDesignerTab({ tab: 'startingDeck' }))
+ }}
+ >
+ {t(`timeline.error.${errorType}.link`)}
+
+
)
- case 'NO_TIP_ON_PIPETTE':
+ case 'REMOVE_96_CHANNEL_TIPRACK_ADAPTER':
+ case 'MISSING_96_CHANNEL_TIPRACK_ADAPTER':
return (
- <>
- {t(`timeline.error.${errorType}.body1`)}
-
+
+ {t(`timeline.error.${errorType}.body`)}
+ {
+ dispatch(selectDesignerTab({ tab: 'startingDeck' }))
+ }}
+ >
{t(`timeline.error.${errorType}.link`)}
-
- {t(`timeline.error.${errorType}.body2`)}
- >
+
+
)
default:
return bodyText
diff --git a/protocol-designer/src/organisms/Alerts/TimelineAlerts.tsx b/protocol-designer/src/organisms/Alerts/TimelineAlerts.tsx
index 7e793723acf..4df442b44f7 100644
--- a/protocol-designer/src/organisms/Alerts/TimelineAlerts.tsx
+++ b/protocol-designer/src/organisms/Alerts/TimelineAlerts.tsx
@@ -3,7 +3,6 @@ import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import {
- ALIGN_CENTER,
Banner,
DIRECTION_COLUMN,
Flex,
@@ -31,13 +30,9 @@ function TimelineAlertsComponent(): JSX.Element {
-
+
{data.title}
{data.description}
diff --git a/protocol-designer/src/organisms/Alerts/__tests__/FormAlerts.test.tsx b/protocol-designer/src/organisms/Alerts/__tests__/FormAlerts.test.tsx
index edbb682b38c..b069da5534f 100644
--- a/protocol-designer/src/organisms/Alerts/__tests__/FormAlerts.test.tsx
+++ b/protocol-designer/src/organisms/Alerts/__tests__/FormAlerts.test.tsx
@@ -54,9 +54,9 @@ describe('FormAlerts', () => {
},
])
render(props)
- screen.getByText('Moving labware into waste chute')
+ screen.getByText('Disposing liquid-filled labware')
screen.getByText(
- 'This labware has remaining liquid, be advised that once you dispose of it, there is no way to get it back later in the protocol.'
+ 'This step moves a labware that contains liquid to the waste chute. There is no way to retrieve the liquid after disposal.'
)
fireEvent.click(screen.getByTestId('Banner_close-button'))
expect(vi.mocked(dismissTimelineWarning)).toHaveBeenCalled()
diff --git a/protocol-designer/src/organisms/Alerts/__tests__/TimelineAlerts.test.tsx b/protocol-designer/src/organisms/Alerts/__tests__/TimelineAlerts.test.tsx
index bf73edde76a..d2e0667ac17 100644
--- a/protocol-designer/src/organisms/Alerts/__tests__/TimelineAlerts.test.tsx
+++ b/protocol-designer/src/organisms/Alerts/__tests__/TimelineAlerts.test.tsx
@@ -4,11 +4,11 @@ import { fireEvent, screen } from '@testing-library/react'
import { i18n } from '../../../assets/localization'
import { renderWithProviders } from '../../../__testing-utils__'
import { getRobotStateTimeline } from '../../../file-data/selectors'
-import { selectTerminalItem } from '../../../ui/steps/actions/actions'
+import { selectDesignerTab } from '../../../file-data/actions'
import { TimelineAlerts } from '../TimelineAlerts'
vi.mock('../../../file-data/selectors')
-vi.mock('../../../ui/steps/actions/actions')
+vi.mock('../../../file-data/actions')
const render = () => {
return renderWithProviders(, {
@@ -27,9 +27,11 @@ describe('TimelineAlerts', () => {
it('renders the insufficient tips timeline error and clicking on the button turns it into the starting deck state terminal id ', () => {
render()
screen.getByText('Not enough tips to complete action')
- screen.getByText('Add another tip rack to an empty slot in')
- fireEvent.click(screen.getByText('Starting Deck State'))
- expect(vi.mocked(selectTerminalItem)).toHaveBeenCalled()
+ screen.getByText(
+ 'Add another tip rack to your deck or change your tip management during transfer and mix steps.'
+ )
+ fireEvent.click(screen.getByText('Edit starting deck'))
+ expect(vi.mocked(selectDesignerTab)).toHaveBeenCalled()
})
it('renders the no tip on pipette timeline error and the knowledge link', () => {
vi.mocked(getRobotStateTimeline).mockReturnValue({
@@ -37,7 +39,6 @@ describe('TimelineAlerts', () => {
errors: [{ message: 'mockMessage', type: 'NO_TIP_ON_PIPETTE' }],
})
render()
- screen.getByText('No tip on pipette at the start of step')
- screen.getByText('Air gap dispense setting')
+ screen.getByText('No tip on pipette at start of step')
})
})
diff --git a/protocol-designer/src/organisms/AnnouncementModal/announcements.tsx b/protocol-designer/src/organisms/AnnouncementModal/announcements.tsx
index 4115c55198d..38b16bf95aa 100644
--- a/protocol-designer/src/organisms/AnnouncementModal/announcements.tsx
+++ b/protocol-designer/src/organisms/AnnouncementModal/announcements.tsx
@@ -302,7 +302,7 @@ export const useAnnouncements = (): Announcement[] => {
),
},
{
- announcementKey: 'redesign9.0',
+ announcementKey: 'redesign8.2',
image: ,
heading: t('announcements.redesign.body1'),
message: (
diff --git a/protocol-designer/src/organisms/AnnouncementModal/index.tsx b/protocol-designer/src/organisms/AnnouncementModal/index.tsx
index 6eca54206ac..34a40249157 100644
--- a/protocol-designer/src/organisms/AnnouncementModal/index.tsx
+++ b/protocol-designer/src/organisms/AnnouncementModal/index.tsx
@@ -64,7 +64,7 @@ export const AnnouncementModal = (
justifyContent={JUSTIFY_CENTER}
gridGap={SPACING.spacing12}
>
- {image && image}
+ {image != null && image}
{message}
diff --git a/protocol-designer/src/organisms/GateModal/index.tsx b/protocol-designer/src/organisms/GateModal/index.tsx
new file mode 100644
index 00000000000..cfe35b1b24a
--- /dev/null
+++ b/protocol-designer/src/organisms/GateModal/index.tsx
@@ -0,0 +1,97 @@
+import { Trans, useTranslation } from 'react-i18next'
+import { useSelector, useDispatch } from 'react-redux'
+import {
+ COLORS,
+ DIRECTION_COLUMN,
+ Flex,
+ JUSTIFY_END,
+ Link as LinkComponent,
+ Modal,
+ PrimaryButton,
+ SPACING,
+ SecondaryButton,
+ StyledText,
+} from '@opentrons/components'
+import {
+ actions as analyticsActions,
+ selectors as analyticsSelectors,
+} from '../../analytics'
+
+const PRIVACY_POLICY_URL = 'https://opentrons.com/privacy-policy'
+const EULA_URL = 'https://opentrons.com/eula'
+
+export function GateModal(): JSX.Element | null {
+ const { t } = useTranslation('shared')
+ const hasOptedIn = useSelector(analyticsSelectors.getHasOptedIn)
+ const dispatch = useDispatch()
+
+ if (hasOptedIn == null) {
+ return (
+
+ dispatch(analyticsActions.optOut())}
+ >
+
+ {t('reject')}
+
+
+ dispatch(analyticsActions.optIn())}>
+
+ {t('agree')}
+
+
+
+ }
+ >
+
+
+ {t('opentrons_collects_data')}
+
+
+
+ ),
+ }}
+ />
+
+
+
+ ),
+ }}
+ />
+
+
+ {t('analytics_tracking')}
+
+
+
+ )
+ } else {
+ return null
+ }
+}
diff --git a/protocol-designer/src/organisms/MaterialsListModal/index.tsx b/protocol-designer/src/organisms/MaterialsListModal/index.tsx
index 0d5c90c1f4b..6fa69307eef 100644
--- a/protocol-designer/src/organisms/MaterialsListModal/index.tsx
+++ b/protocol-designer/src/organisms/MaterialsListModal/index.tsx
@@ -93,7 +93,7 @@ export function MaterialsListModal({
? fixtures.map(fixture => (
}
@@ -187,7 +187,7 @@ export function MaterialsListModal({
return (
}
diff --git a/protocol-designer/src/organisms/SlotInformation/index.tsx b/protocol-designer/src/organisms/SlotInformation/index.tsx
index 19c4e25f2e5..cd3550ed7d5 100644
--- a/protocol-designer/src/organisms/SlotInformation/index.tsx
+++ b/protocol-designer/src/organisms/SlotInformation/index.tsx
@@ -52,7 +52,7 @@ export const SlotInformation: React.FC = ({
{liquids.length > 1 ? (
@@ -113,7 +113,7 @@ function StackInfo({ title, stackInformation }: StackInfoProps): JSX.Element {
return (
- {tab === 'protocolSteps' ? (
-
-
-
- ) : null}
-
+
+
-
-
- {() => (
- <>
- {robotType === OT2_ROBOT_TYPE ? (
-
- ) : (
- <>
- {filteredAddressableAreas.map(addressableArea => {
- const cutoutId = getCutoutIdForAddressableArea(
- addressableArea.id,
- deckDef.cutoutFixtures
+ {() => (
+ <>
+ {robotType === OT2_ROBOT_TYPE ? (
+
+ ) : (
+ <>
+ {filteredAddressableAreas.map(addressableArea => {
+ const cutoutId = getCutoutIdForAddressableArea(
+ addressableArea.id,
+ deckDef.cutoutFixtures
+ )
+ return cutoutId != null ? (
+
+ ) : null
+ })}
+ {stagingAreaFixtures.map(fixture => {
+ if (
+ zoomIn.cutout == null ||
+ zoomIn.cutout !== fixture.location
+ ) {
+ return (
+
+ )
+ }
+ })}
+ {trash != null
+ ? trashBinFixtures.map(({ cutoutId }) =>
+ cutoutId != null &&
+ (zoomIn.cutout == null ||
+ zoomIn.cutout !== cutoutId) ? (
+
+
+
+
+ ) : null
)
- return cutoutId != null ? (
- {
+ if (
+ zoomIn.cutout == null ||
+ zoomIn.cutout !== fixture.location
+ ) {
+ return (
+
- ) : null
- })}
- {stagingAreaFixtures.map(fixture => {
- if (
- zoomIn.cutout == null ||
- zoomIn.cutout !== fixture.location
- ) {
- return (
-
- )
- }
- })}
- {trash != null
- ? trashBinFixtures.map(({ cutoutId }) =>
- cutoutId != null &&
- (zoomIn.cutout == null ||
- zoomIn.cutout !== cutoutId) ? (
-
-
-
-
- ) : null
- )
- : null}
- {wasteChuteFixtures.map(fixture => {
- if (
- zoomIn.cutout == null ||
- zoomIn.cutout !== fixture.location
- ) {
- return (
-
- )
- }
- })}
- {wasteChuteStagingAreaFixtures.map(fixture => {
- if (
- zoomIn.cutout == null ||
- zoomIn.cutout !== fixture.location
- ) {
- return (
-
- )
- }
- })}
- >
+ )
+ }
+ })}
+ {wasteChuteStagingAreaFixtures.map(fixture => {
+ if (
+ zoomIn.cutout == null ||
+ zoomIn.cutout !== fixture.location
+ ) {
+ return (
+
+ )
+ }
+ })}
+ >
+ )}
+ areas.location as CutoutId
)}
- areas.location as CutoutId
- )}
- {...{
- deckDef,
- showGen1MultichannelCollisionWarnings,
- }}
- />
-
+ 0}
+ />
+ {hoverSlot != null ? (
+ 0}
+ slot={hoverSlot}
/>
- {hoverSlot != null ? (
-
- ) : null}
- >
- )}
-
-
+ ) : null}
+ >
+ )}
+
- {zoomIn.slot != null && zoomIn.cutout != null ? (
- {
- dispatch(selectZoomedIntoSlot({ slot: null, cutout: null }))
- animateZoom({
- targetViewBox: initialViewBox,
- viewBox,
- setViewBox,
- })
- }}
- setHoveredLabware={setHoveredLabware}
- />
- ) : null}
+ {zoomIn.slot != null && zoomIn.cutout != null ? (
+ {
+ dispatch(selectZoomedIntoSlot({ slot: null, cutout: null }))
+ animateZoom({
+ targetViewBox: initialViewBox,
+ viewBox,
+ setViewBox,
+ })
+ }}
+ setHoveredLabware={setHoveredLabware}
+ />
+ ) : null}
)
}
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx
index 81a6f259c95..db1c69ae982 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx
@@ -69,7 +69,6 @@ export const BatchEditToolbox = (): JSX.Element | null => {
return (
{t('protocol_steps:batch_edit')}
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx
index 709cff49d7c..f101237cb45 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx
@@ -206,7 +206,6 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element {
}
- height="calc(100vh - 64px)"
title={
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx
index eefc9d36717..e82230adeb0 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx
@@ -12,9 +12,7 @@ import {
} from '@opentrons/components'
import { getHeaterShakerLabwareOptions } from '../../../../../../ui/modules/selectors'
import {
- CheckboxExpandStepFormField,
DropdownStepFormField,
- InputStepFormField,
ToggleExpandStepFormField,
ToggleStepFormField,
} from '../../../../../../molecules'
@@ -90,7 +88,7 @@ export function HeaterShakerTools(props: StepFormProps): JSX.Element {
toggleValue={propsForFields.setShake.value}
toggleUpdateValue={propsForFields.setShake.updateValue}
title={t('form:step_edit_form.field.heaterShaker.shaker.setShake')}
- fieldTitle={t('protocol_steps:shake')}
+ fieldTitle={t('protocol_steps:speed')}
isSelected={formData.setShake === true}
units={t('units.rpm')}
onLabel={t('form:step_edit_form.field.heaterShaker.shaker.toggleOn')}
@@ -112,25 +110,17 @@ export function HeaterShakerTools(props: StepFormProps): JSX.Element {
: null
}
/>
-
- {/* TODO: wire up the new timer with the combined field */}
- {formData.heaterShakerSetTimer === true ? (
-
- ) : null}
-
+ fieldTitle={t('form:step_edit_form.field.heaterShaker.duration')}
+ isSelected={formData.heaterShakerSetTimer === true}
+ units={t('application:units.time')}
+ />
)
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx
index e536bc750be..42768144177 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx
@@ -2,11 +2,12 @@ import { useSelector } from 'react-redux'
import { useTranslation } from 'react-i18next'
import {
COLORS,
- DIRECTION_COLUMN,
DeckInfoLabel,
+ DIRECTION_COLUMN,
Divider,
Flex,
ListItem,
+ ListItemDescriptor,
SPACING,
StyledText,
} from '@opentrons/components'
@@ -30,6 +31,7 @@ import {
getModuleEntities,
} from '../../../../../../step-forms/selectors'
import { getModulesOnDeckByType } from '../../../../../../ui/modules/utils'
+import { LINE_CLAMP_TEXT_STYLE } from '../../../../../../atoms'
import type { StepFormProps } from '../../types'
@@ -67,7 +69,6 @@ export function MagnetTools(props: StepFormProps): JSX.Element {
})
: ''
const engageHeightCaption = `${engageHeightMinMax} ${engageHeightDefault}`
- // TODO (10-9-2024): Replace ListItem with ListItemDescriptor
return (
-
-
-
-
-
-
- {slotInfo[0]}
-
-
- {slotInfo[1]}
-
-
-
+
+
+ {slotInfo[0]}
+
+
+ {slotInfo[1]}
+
+
+ }
+ description={
+
+
+
+ }
+ />
@@ -113,7 +123,6 @@ export function MagnetTools(props: StepFormProps): JSX.Element {
'form:step_edit_form.field.magnetAction.options.disengage'
)}
caption={engageHeightCaption}
- islabel={true}
/>
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/PauseTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/PauseTools/index.tsx
index e76153a102e..1ab9d90ce44 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/PauseTools/index.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/PauseTools/index.tsx
@@ -139,10 +139,11 @@ export function PauseTools(props: StepFormProps): JSX.Element {
>
@@ -193,7 +194,7 @@ export function PauseTools(props: StepFormProps): JSX.Element {
gridGap={SPACING.spacing4}
paddingX={SPACING.spacing16}
>
-
+
{i18n.format(
t('form:step_edit_form.field.pauseMessage.label'),
'capitalize'
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx
index 0272a35e618..0b7049bd546 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx
@@ -180,7 +180,7 @@ function StepFormManager(props: StepFormManagerProps): JSX.Element | null {
: ''
}
handleCancelClick={saveStepForm}
- handleContinueClick={confirmAddPauseUntilTempStep}
+ handleContinueClick={handleSave}
moduleType={
showAddPauseUntilTempStepModal
? TEMPERATURE_MODULE_TYPE
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx
index e993496dba3..18938c3975b 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx
@@ -304,11 +304,11 @@ export function StepSummary(props: StepSummaryProps): JSX.Element | null {
const destinationLabwareName = labwareNicknamesById[dispense_labware]
const aspirateWellsDisplay = getWellsForStepSummary(
aspirate_wells as string[],
- flatten(labwareEntities[aspirate_labware].def.ordering)
+ flatten(labwareEntities[aspirate_labware]?.def.ordering)
)
const dispenseWellsDisplay = getWellsForStepSummary(
dispense_wells as string[],
- flatten(labwareEntities[dispense_labware].def.ordering)
+ flatten(labwareEntities[dispense_labware]?.def.ordering)
)
stepSummaryContent = (
- {targetSpeed != null ? (
+ {targetSpeed ? (
- {heaterShakerTimerMinutes != null &&
- heaterShakerTimerSeconds != null ? (
+ {heaterShakerTimer ? (
) : null}
>()
const formData = useSelector(getUnsavedForm)
const savedStepFormData = useSelector(getSavedStepForms)[stepId]
const isPipetteStep =
savedStepFormData.stepType === 'moveLiquid' ||
savedStepFormData.stepType === 'mix'
- const isThermocyclerStep = savedStepFormData.stepType === 'thermocycler'
+ const isThermocyclerProfile = savedStepFormData.stepType === 'thermocycler'
const duplicateStep = (
stepId: StepIdType
@@ -76,7 +84,7 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element {
<>
0 ? (
<>
{
duplicateMultipleSteps()
setStepOverflowMenu(false)
@@ -115,9 +124,11 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element {
{formData != null ? null : (
{t('edit_step')}
)}
- {isPipetteStep || isThermocyclerStep ? (
+ {isPipetteStep || isThermocyclerProfile ? (
{
+ setStepOverflowMenu(false)
dispatch(hoverOnStep(stepId))
dispatch(toggleViewSubstep(stepId))
}}
@@ -126,6 +137,7 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element {
) : null}
{
duplicateStep(stepId)
setStepOverflowMenu(false)
@@ -165,5 +177,8 @@ const MenuButton = styled.button`
&:disabled {
color: ${COLORS.grey40};
cursor: auto;
+ &:hover {
+ background-color: ${COLORS.transparent};
+ }
}
`
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/SubstepsToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/SubstepsToolbox.tsx
index b6340bd97ac..922220b7e81 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/SubstepsToolbox.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/SubstepsToolbox.tsx
@@ -1,7 +1,9 @@
import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import {
+ FLEX_MAX_CONTENT,
Flex,
+ Icon,
PrimaryButton,
SPACING,
StyledText,
@@ -43,11 +45,14 @@ export function SubstepsToolbox(
const highlightSubstep = (payload: SubstepIdentifier): HoverOnSubstepAction =>
dispatch(hoverOnSubstep(payload))
- if (substeps == null) {
+ if (substeps == null || formData == null) {
return null
}
- const uiStepType = t(`application:stepType.${formData.stepType}`)
+ const handleClose = (): void => {
+ dispatch(toggleViewSubstep(null))
+ dispatch(hoverOnStep(null))
+ }
return ('commandCreatorFnName' in substeps &&
(substeps.commandCreatorFnName === 'transfer' ||
@@ -56,31 +61,29 @@ export function SubstepsToolbox(
substeps.commandCreatorFnName === 'mix')) ||
substeps.substepType === THERMOCYCLER_PROFILE ? (
}
+ onCloseClick={handleClose}
confirmButton={
- {
- dispatch(toggleViewSubstep(null))
- dispatch(hoverOnStep(null))
- }}
- width="100%"
- >
+
{t('shared:done')}
}
title={
{i18n.format(
- t(`protocol_steps:step_substeps`, { stepType: uiStepType }),
+ t(`protocol_steps:step_substeps`, {
+ stepType: formData?.stepName ?? formData.stepType,
+ }),
'capitalize'
)}
}
>
-
+
{substeps.substepType === THERMOCYCLER_PROFILE ? (
-
+
) : (
Wire this up
+import { useSelector } from 'react-redux'
+import { Trans, useTranslation } from 'react-i18next'
+import {
+ ALIGN_CENTER,
+ ALIGN_FLEX_END,
+ DIRECTION_COLUMN,
+ FLEX_MAX_CONTENT,
+ Flex,
+ ListItem,
+ SPACING,
+ StyledText,
+ Tag,
+} from '@opentrons/components'
+import { getSavedStepForms } from '../../../../step-forms/selectors'
+
+import type { ProfileStepItem } from '../../../../form-types'
+import type { ThermocyclerCycleType } from '../StepForm/StepTools/ThermocyclerTools/ThermocyclerCycle'
+import type { ThermocyclerStepType } from '../StepForm/StepTools/ThermocyclerTools/ThermocyclerStep'
+
+interface ThermocyclerProfileSubstepsProps {
+ stepId: string
+}
+export function ThermocyclerProfileSubsteps(
+ props: ThermocyclerProfileSubstepsProps
+): JSX.Element {
+ const { stepId } = props
+
+ const { t } = useTranslation('protocol_steps')
+
+ const savedStepForms = useSelector(getSavedStepForms)
+ const step = savedStepForms[stepId]
+ const orderedSubsteps = step.orderedProfileItems.map(
+ (id: string) => step.profileItemsById[id]
+ )
+
+ return (
+
+ {orderedSubsteps.map(
+ (substep: ThermocyclerStepType | ThermocyclerCycleType) => {
+ const content =
+ substep.type === 'profileCycle' ? (
+
+ {substep.steps.map((profileStep: ProfileStepItem) => {
+ const {
+ temperature,
+ durationMinutes,
+ durationSeconds,
+ } = profileStep
+ return (
+
+ )
+ })}
+
+ {t('thermocycler_module.repeat', {
+ repetitions: substep.repetitions,
+ })}
+
+
+ ) : (
+
+ )
+ return (
+
+ {content}
+
+ )
+ }
+ )}
+
+ )
+}
+
+interface ThermocyclerSubstepProps {
+ temperature: string
+ duration: string
+}
+
+function ThermocyclerSubstep(props: ThermocyclerSubstepProps): JSX.Element {
+ const { temperature, duration } = props
+ const { t } = useTranslation(['application', 'protocol_steps'])
+ return (
+
+ ,
+ tagTemperature: (
+
+ ),
+ tagDuration: ,
+ }}
+ />
+
+ )
}
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/ThermocyclerProfileSubsteps.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/ThermocyclerProfileSubsteps.test.tsx
new file mode 100644
index 00000000000..b4fb8af436f
--- /dev/null
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/ThermocyclerProfileSubsteps.test.tsx
@@ -0,0 +1,78 @@
+import { describe, it, vi, beforeEach, expect } from 'vitest'
+import { screen } from '@testing-library/react'
+import { renderWithProviders } from '../../../../../__testing-utils__'
+import { i18n } from '../../../../../assets/localization'
+import { getSavedStepForms } from '../../../../../step-forms/selectors'
+import { ThermocyclerProfileSubsteps } from '../ThermocyclerProfileSubsteps'
+import type { FormData } from '../../../../../form-types'
+
+const render = (
+ props: React.ComponentProps
+) => {
+ return renderWithProviders(, {
+ i18nInstance: i18n,
+ })[0]
+}
+vi.mock('../../../../../step-forms/selectors')
+const THERMOCYCLER_STEP_ID = 'tcStep123'
+const MOCK_THERMOCYCLER_ORDERED_SUBSTEP_IDS = [
+ '292b0d70-fa06-4ab1-adc9-f26c589babf4',
+ '0965e0de-2d01-4e4e-8fb3-1e66306fe7e5',
+]
+const MOCK_THERMOCYCLER_SUBSTEP_ITEMS = {
+ '292b0d70-fa06-4ab1-adc9-f26c589babf4': {
+ id: '292b0d70-fa06-4ab1-adc9-f26c589babf4',
+ title: '',
+ steps: [
+ {
+ durationMinutes: '00',
+ durationSeconds: '30',
+ id: 'f90cc374-2eeb-4205-80c6-63c5c77215a5',
+ temperature: '10',
+ title: 'cyclestep1',
+ type: 'profileStep',
+ },
+ {
+ durationMinutes: '1',
+ durationSeconds: '30',
+ id: '462b3d8f-bb8a-4e11-ae98-8f1d46e8507e',
+ temperature: '55',
+ title: 'cyclestep2',
+ type: 'profileStep',
+ },
+ ],
+ type: 'profileCycle',
+ repetitions: '28',
+ },
+ '0965e0de-2d01-4e4e-8fb3-1e66306fe7e5': {
+ durationMinutes: '5',
+ durationSeconds: '00',
+ id: '0965e0de-2d01-4e4e-8fb3-1e66306fe7e5',
+ temperature: '39',
+ title: 'last step',
+ type: 'profileStep',
+ },
+}
+
+describe('TimelineToolbox', () => {
+ let props: React.ComponentProps
+ beforeEach(() => {
+ props = { stepId: THERMOCYCLER_STEP_ID }
+ vi.mocked(getSavedStepForms).mockReturnValue({
+ [THERMOCYCLER_STEP_ID]: ({
+ orderedProfileItems: MOCK_THERMOCYCLER_ORDERED_SUBSTEP_IDS,
+ profileItemsById: MOCK_THERMOCYCLER_SUBSTEP_ITEMS,
+ } as unknown) as FormData,
+ })
+ })
+ it('renders all profile steps, including cycles and steps', () => {
+ render(props)
+ expect(screen.getAllByText('Set block temperature to').length === 3)
+ screen.getByText('10°C')
+ screen.getByText('55°C')
+ screen.getByText('39°C')
+ screen.getByText('00:30')
+ screen.getByText('1:30')
+ screen.getByText('5:00')
+ })
+})
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx
index e5a37810a3f..f68928c3488 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx
@@ -1,15 +1,23 @@
-import { describe, it, vi, beforeEach, expect } from 'vitest'
+import { describe, it, vi, beforeEach } from 'vitest'
import '@testing-library/jest-dom/vitest'
import { fireEvent, screen } from '@testing-library/react'
import { i18n } from '../../../../assets/localization'
import { renderWithProviders } from '../../../../__testing-utils__'
-import { getUnsavedForm } from '../../../../step-forms/selectors'
-import { getSelectedSubstep } from '../../../../ui/steps/selectors'
+import {
+ getSavedStepForms,
+ getUnsavedForm,
+} from '../../../../step-forms/selectors'
+import {
+ getSelectedStepId,
+ getSelectedSubstep,
+} from '../../../../ui/steps/selectors'
+import { getDesignerTab } from '../../../../file-data/selectors'
import { getEnableHotKeysDisplay } from '../../../../feature-flags/selectors'
import { DeckSetupContainer } from '../../DeckSetup'
import { OffDeck } from '../../Offdeck'
import { ProtocolSteps } from '..'
import { SubstepsToolbox, TimelineToolbox } from '../Timeline'
+import type { SavedStepFormState } from '../../../../step-forms'
vi.mock('../../Offdeck')
vi.mock('../../../../step-forms/selectors')
@@ -17,17 +25,37 @@ vi.mock('../../../../ui/steps/selectors')
vi.mock('../../../../ui/labware/selectors')
vi.mock('../StepForm')
vi.mock('../../DeckSetup')
+vi.mock('../StepSummary.tsx')
vi.mock('../Timeline')
vi.mock('../../../../feature-flags/selectors')
-
+vi.mock('../../../../file-data/selectors')
+vi.mock('../../../../organisms/Alerts')
const render = () => {
return renderWithProviders(, {
i18nInstance: i18n,
})[0]
}
+const MOCK_STEP_FORMS = {
+ '0522fde8-25a3-4840-b84a-af7282bd80d5': {
+ moduleId: '781599b2-1eff-4594-8c96-06fcd54f4faa:heaterShakerModuleType',
+ pauseAction: 'untilTime',
+ pauseHour: '22',
+ pauseMessage: 'sdfg',
+ pauseMinute: '22',
+ pauseSecond: '11',
+ pauseTemperature: null,
+ pauseTime: null,
+ id: '0522fde8-25a3-4840-b84a-af7282bd80d5',
+ stepType: 'pause',
+ stepName: 'custom pause',
+ stepDetails: '',
+ },
+}
+
describe('ProtocolSteps', () => {
beforeEach(() => {
+ vi.mocked(getDesignerTab).mockReturnValue('protocolSteps')
vi.mocked(TimelineToolbox).mockReturnValue(mock TimelineToolbox
)
vi.mocked(DeckSetupContainer).mockReturnValue(
mock DeckSetupContainer
@@ -37,6 +65,12 @@ describe('ProtocolSteps', () => {
vi.mocked(getSelectedSubstep).mockReturnValue(null)
vi.mocked(SubstepsToolbox).mockReturnValue(mock SubstepsToolbox
)
vi.mocked(getEnableHotKeysDisplay).mockReturnValue(true)
+ vi.mocked(getSavedStepForms).mockReturnValue(
+ MOCK_STEP_FORMS as SavedStepFormState
+ )
+ vi.mocked(getSelectedStepId).mockReturnValue(
+ '0522fde8-25a3-4840-b84a-af7282bd80d5'
+ )
})
it('renders each component in ProtocolSteps', () => {
@@ -52,15 +86,6 @@ describe('ProtocolSteps', () => {
screen.getByText('mock OffDeck')
})
- it('renders no toggle when formData does not equal moveLabware type', () => {
- vi.mocked(getUnsavedForm).mockReturnValue({
- stepType: 'magnet',
- id: 'mockId',
- })
- render()
- expect(screen.queryByText('Off-deck')).not.toBeInTheDocument()
- })
-
it('renders the substepToolbox when selectedSubstep is not null', () => {
vi.mocked(getSelectedSubstep).mockReturnValue('mockId')
render()
@@ -73,4 +98,9 @@ describe('ProtocolSteps', () => {
screen.getByText('Shift + Click to select all')
screen.getByText('Command + Click for multi-select')
})
+
+ it('renders the current step name', () => {
+ render()
+ screen.getByText('Custom pause')
+ })
})
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx
index 468ea45e01f..3d51d8ef1f1 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react'
+import { useState } from 'react'
import { useSelector } from 'react-redux'
import { useTranslation } from 'react-i18next'
import {
@@ -7,11 +7,13 @@ import {
COLORS,
DIRECTION_COLUMN,
Flex,
+ JUSTIFY_CENTER,
JUSTIFY_FLEX_END,
JUSTIFY_FLEX_START,
JUSTIFY_SPACE_BETWEEN,
POSITION_FIXED,
SPACING,
+ StyledText,
Tag,
ToggleGroup,
} from '@opentrons/components'
@@ -32,27 +34,22 @@ import { TimelineToolbox, SubstepsToolbox } from './Timeline'
import { StepForm } from './StepForm'
import { StepSummary } from './StepSummary'
import { BatchEditToolbox } from './BatchEditToolbox'
+import { getDesignerTab } from '../../../file-data/selectors'
+import { TimelineAlerts } from '../../../organisms'
export function ProtocolSteps(): JSX.Element {
- const { t } = useTranslation('starting_deck_state')
+ const { i18n, t } = useTranslation('starting_deck_state')
const formData = useSelector(getUnsavedForm)
const isMultiSelectMode = useSelector(getIsMultiSelectMode)
const selectedSubstep = useSelector(getSelectedSubstep)
const enableHoyKeyDisplay = useSelector(getEnableHotKeysDisplay)
+ const tab = useSelector(getDesignerTab)
const leftString = t('onDeck')
const rightString = t('offDeck')
const [deckView, setDeckView] = useState<
typeof leftString | typeof rightString
>(leftString)
- const formType = formData?.stepType
-
- useEffect(() => {
- if (formData != null && formType !== 'moveLabware') {
- setDeckView(leftString)
- }
- }, [formData, formType, deckView])
-
const currentHoveredStepId = useSelector(getHoveredStepId)
const currentSelectedStepId = useSelector(getSelectedStepId)
const currentstepIdForStepSummary =
@@ -71,34 +68,50 @@ export function ProtocolSteps(): JSX.Element {
gridGap={SPACING.spacing16}
height="calc(100vh - 4rem)"
justifyContent={JUSTIFY_SPACE_BETWEEN}
+ padding={SPACING.spacing12}
>
- {selectedSubstep ? : null}
- {formData == null || formType === 'moveLabware' ? (
-
- {
- setDeckView(leftString)
- }}
- rightClick={() => {
- setDeckView(rightString)
- }}
- />
+ {tab === 'protocolSteps' ? (
+
+
) : null}
+
+ {currentStep != null ? (
+
+ {i18n.format(currentStep.stepName, 'capitalize')}
+
+ ) : null}
+ {
+ setDeckView(leftString)
+ }}
+ rightClick={() => {
+ setDeckView(rightString)
+ }}
+ />
+
{enableHoyKeyDisplay ? (
-
+
@@ -127,6 +140,9 @@ export function ProtocolSteps(): JSX.Element {
) : null}
+ {formData == null && selectedSubstep ? (
+
+ ) : null}
{isMultiSelectMode ? : null}
diff --git a/protocol-designer/src/pages/Designer/__tests__/Designer.test.tsx b/protocol-designer/src/pages/Designer/__tests__/Designer.test.tsx
index 2b496cbe5e1..d7a05a8efe0 100644
--- a/protocol-designer/src/pages/Designer/__tests__/Designer.test.tsx
+++ b/protocol-designer/src/pages/Designer/__tests__/Designer.test.tsx
@@ -5,7 +5,7 @@ import { i18n } from '../../../assets/localization'
import { renderWithProviders } from '../../../__testing-utils__'
import { getDeckSetupForActiveItem } from '../../../top-selectors/labware-locations'
import { selectors } from '../../../labware-ingred/selectors'
-import { getFileMetadata } from '../../../file-data/selectors'
+import { getDesignerTab, getFileMetadata } from '../../../file-data/selectors'
import { generateNewProtocol } from '../../../labware-ingred/actions'
import { DeckSetupContainer } from '../DeckSetup'
import { Designer } from '../index'
@@ -44,6 +44,7 @@ const render = () => {
describe('Designer', () => {
beforeEach(() => {
+ vi.mocked(getDesignerTab).mockReturnValue('startingDeck')
vi.mocked(ProtocolSteps).mockReturnValue(mock ProtocolSteps
)
vi.mocked(getFileMetadata).mockReturnValue({
protocolName: 'mockProtocolName',
@@ -103,6 +104,7 @@ describe('Designer', () => {
})
it('renders the protocol steps page', () => {
+ vi.mocked(getDesignerTab).mockReturnValue('protocolSteps')
render()
fireEvent.click(screen.getByText('Protocol steps'))
screen.getByText('mock ProtocolSteps')
diff --git a/protocol-designer/src/pages/Designer/index.tsx b/protocol-designer/src/pages/Designer/index.tsx
index f57fe016076..6dfd952ba97 100644
--- a/protocol-designer/src/pages/Designer/index.tsx
+++ b/protocol-designer/src/pages/Designer/index.tsx
@@ -23,7 +23,8 @@ import { useKitchen } from '../../organisms/Kitchen/hooks'
import { getDeckSetupForActiveItem } from '../../top-selectors/labware-locations'
import { generateNewProtocol } from '../../labware-ingred/actions'
import { DefineLiquidsModal, ProtocolMetadataNav } from '../../organisms'
-import { getFileMetadata } from '../../file-data/selectors'
+import { selectDesignerTab } from '../../file-data/actions'
+import { getDesignerTab, getFileMetadata } from '../../file-data/selectors'
import { DeckSetupContainer } from './DeckSetup'
import { selectors } from '../../labware-ingred/selectors'
import { OffDeck } from './Offdeck'
@@ -53,9 +54,7 @@ export function Designer(): JSX.Element {
const isNewProtocol = useSelector(selectors.getIsNewProtocol)
const [liquidOverflowMenu, showLiquidOverflowMenu] = useState(false)
const [showDefineLiquidModal, setDefineLiquidModal] = useState(false)
- const [tab, setTab] = useState<'startingDeck' | 'protocolSteps'>(
- 'startingDeck'
- )
+ const tab = useSelector(getDesignerTab)
const leftString = t('onDeck')
const rightString = t('offDeck')
@@ -73,7 +72,7 @@ export function Designer(): JSX.Element {
text: t('protocol_starting_deck'),
isActive: tab === 'startingDeck',
onClick: () => {
- setTab('startingDeck')
+ dispatch(selectDesignerTab({ tab: 'startingDeck' }))
},
}
const protocolStepTab = {
@@ -81,7 +80,7 @@ export function Designer(): JSX.Element {
isActive: tab === 'protocolSteps',
onClick: () => {
if (hasTrashEntity) {
- setTab('protocolSteps')
+ dispatch(selectDesignerTab({ tab: 'protocolSteps' }))
} else {
makeSnackbar(t('trash_required') as string)
}
diff --git a/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx b/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx
index ad713cb02fb..4ca8430796f 100644
--- a/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx
+++ b/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx
@@ -6,11 +6,21 @@ import { renderWithProviders } from '../../../__testing-utils__'
import { loadProtocolFile } from '../../../load-file/actions'
import { getFileMetadata } from '../../../file-data/selectors'
import { toggleNewProtocolModal } from '../../../navigation/actions'
+import { useKitchen } from '../../../organisms/Kitchen/hooks'
+import { useAnnouncements } from '../../../organisms/AnnouncementModal/announcements'
+import { getHasOptedIn } from '../../../analytics/selectors'
import { Landing } from '../index'
vi.mock('../../../load-file/actions')
vi.mock('../../../file-data/selectors')
vi.mock('../../../navigation/actions')
+vi.mock('../../../organisms/AnnouncementModal/announcements')
+vi.mock('../../../organisms/Kitchen/hooks')
+vi.mock('../../../analytics/selectors')
+
+const mockMakeSnackbar = vi.fn()
+const mockEatToast = vi.fn()
+const mockBakeToast = vi.fn()
const render = () => {
return renderWithProviders(
@@ -25,9 +35,17 @@ const render = () => {
describe('Landing', () => {
beforeEach(() => {
+ vi.mocked(getHasOptedIn).mockReturnValue(false)
vi.mocked(getFileMetadata).mockReturnValue({})
vi.mocked(loadProtocolFile).mockReturnValue(vi.fn())
+ vi.mocked(useAnnouncements).mockReturnValue({} as any)
+ vi.mocked(useKitchen).mockReturnValue({
+ makeSnackbar: mockMakeSnackbar,
+ bakeToast: mockBakeToast,
+ eatToast: mockEatToast,
+ })
})
+
it('renders the landing page image and text', () => {
render()
screen.getByLabelText('welcome image')
@@ -40,4 +58,9 @@ describe('Landing', () => {
screen.getByText('Edit existing protocol')
screen.getByRole('img', { name: 'welcome image' })
})
+
+ it('render toast when there is an announcement', () => {
+ render()
+ expect(mockBakeToast).toHaveBeenCalled()
+ })
})
diff --git a/protocol-designer/src/pages/Landing/index.tsx b/protocol-designer/src/pages/Landing/index.tsx
index 4c6206d3861..315787cd9ea 100644
--- a/protocol-designer/src/pages/Landing/index.tsx
+++ b/protocol-designer/src/pages/Landing/index.tsx
@@ -1,4 +1,4 @@
-import * as React from 'react'
+import { useEffect, useState } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import { useDispatch, useSelector } from 'react-redux'
@@ -8,7 +8,9 @@ import {
COLORS,
CURSOR_POINTER,
DIRECTION_COLUMN,
+ EndUserAgreementFooter,
Flex,
+ INFO_TOAST,
JUSTIFY_CENTER,
LargeButton,
SPACING,
@@ -16,9 +18,14 @@ import {
TYPOGRAPHY,
} from '@opentrons/components'
import { BUTTON_LINK_STYLE } from '../../atoms'
+import { AnnouncementModal } from '../../organisms'
import { actions as loadFileActions } from '../../load-file'
import { getFileMetadata } from '../../file-data/selectors'
import { toggleNewProtocolModal } from '../../navigation/actions'
+import { useKitchen } from '../../organisms/Kitchen/hooks'
+import { getHasOptedIn } from '../../analytics/selectors'
+import { useAnnouncements } from '../../organisms/AnnouncementModal/announcements'
+import { getLocalStorageItem, localStorageAnnouncementKey } from '../../persist'
import welcomeImage from '../../assets/images/welcome_page.png'
import type { ThunkDispatch } from '../../types'
@@ -28,8 +35,42 @@ export function Landing(): JSX.Element {
const dispatch: ThunkDispatch = useDispatch()
const metadata = useSelector(getFileMetadata)
const navigate = useNavigate()
+ const [showAnnouncementModal, setShowAnnouncementModal] = useState(
+ false
+ )
+ const hasOptedIn = useSelector(getHasOptedIn)
+ const { bakeToast, eatToast } = useKitchen()
+ const announcements = useAnnouncements()
+ const lastAnnouncement = announcements[announcements.length - 1]
+ const announcementKey = lastAnnouncement
+ ? lastAnnouncement.announcementKey
+ : null
+
+ const userHasNotSeenAnnouncement =
+ getLocalStorageItem(localStorageAnnouncementKey) !== announcementKey &&
+ hasOptedIn != null
+
+ useEffect(() => {
+ if (userHasNotSeenAnnouncement) {
+ const toastId = bakeToast(
+ t('learn_more', { version: process.env.OT_PD_VERSION }) as string,
+ INFO_TOAST,
+ {
+ heading: t('updated_protocol_designer'),
+ closeButton: true,
+ linkText: t('view_release_notes'),
+ onLinkClick: () => {
+ eatToast(toastId)
+ setShowAnnouncementModal(true)
+ },
+ disableTimeout: true,
+ justifyContent: JUSTIFY_CENTER,
+ }
+ )
+ }
+ }, [userHasNotSeenAnnouncement])
- React.useEffect(() => {
+ useEffect(() => {
if (metadata?.created != null) {
console.warn('protocol already exists, navigating to overview')
navigate('/overview')
@@ -43,57 +84,68 @@ export function Landing(): JSX.Element {
}
return (
-
-
-
+ {showAnnouncementModal ? (
+ {
+ setShowAnnouncementModal(false)
+ }}
/>
-
-
- {t('welcome')}
-
-
+
+
+
- {t('no-code-required')}
-
+
+ {t('welcome')}
+
+
+ {t('no-code-required')}
+
+
+
+ {
+ dispatch(toggleNewProtocolModal(true))
+ }}
+ buttonText={{t('create_a_protocol')}}
+ />
+
+
+
+
+ {t('edit_existing')}
+
+
+
+
-
- {
- dispatch(toggleNewProtocolModal(true))
- }}
- buttonText={{t('create_a_protocol')}}
- />
-
-
-
-
- {t('edit_existing')}
-
-
-
-
-
+
+ >
)
}
diff --git a/protocol-designer/src/pages/ProtocolOverview/DeckThumbnailDetails.tsx b/protocol-designer/src/pages/ProtocolOverview/DeckThumbnailDetails.tsx
index 36caf29c4ad..5dde2a5f4d6 100644
--- a/protocol-designer/src/pages/ProtocolOverview/DeckThumbnailDetails.tsx
+++ b/protocol-designer/src/pages/ProtocolOverview/DeckThumbnailDetails.tsx
@@ -10,7 +10,6 @@ import {
inferModuleOrientationFromXCoordinate,
isAddressableAreaStandardSlot,
THERMOCYCLER_MODULE_TYPE,
- SPAN7_8_10_11_SLOT,
} from '@opentrons/shared-data'
import { LabwareOnDeck } from '../../components/DeckSetup/LabwareOnDeck'
import { getStagingAreaAddressableAreas } from '../../utils'
@@ -67,11 +66,7 @@ export const DeckThumbnailDetails = (
<>
{/* all modules */}
{allModules.map(({ id, slot, model, type, moduleState }) => {
- const slotId =
- slot === SPAN7_8_10_11_SLOT && type === THERMOCYCLER_MODULE_TYPE
- ? '7'
- : slot
-
+ const slotId = slot
const slotPosition = getPositionFromSlotId(slotId, deckDef)
if (slotPosition == null) {
console.warn(`no slot ${slotId} for module ${id}`)
diff --git a/protocol-designer/src/pages/ProtocolOverview/InstrumentsInfo.tsx b/protocol-designer/src/pages/ProtocolOverview/InstrumentsInfo.tsx
index e87b6550904..cd1b5215ab1 100644
--- a/protocol-designer/src/pages/ProtocolOverview/InstrumentsInfo.tsx
+++ b/protocol-designer/src/pages/ProtocolOverview/InstrumentsInfo.tsx
@@ -92,7 +92,7 @@ export function InstrumentsInfo({
@@ -118,7 +118,7 @@ export function InstrumentsInfo({
{robotType === FLEX_ROBOT_TYPE ? (
diff --git a/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx b/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx
index 03c274ce104..fc767242929 100644
--- a/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx
+++ b/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx
@@ -1,5 +1,4 @@
import { useTranslation } from 'react-i18next'
-import { css } from 'styled-components'
import {
ALIGN_CENTER,
DIRECTION_COLUMN,
@@ -8,10 +7,10 @@ import {
LiquidIcon,
ListItem,
ListItemDescriptor,
- OVERFLOW_HIDDEN,
SPACING,
StyledText,
} from '@opentrons/components'
+import { LINE_CLAMP_TEXT_STYLE } from '../../atoms'
import type { AllIngredGroupFields } from '../../labware-ingred/types'
@@ -36,7 +35,7 @@ export function LiquidDefinitions({
key={`${liquid.name}_${liquid.displayColor}_${index}`}
>
@@ -44,7 +43,7 @@ export function LiquidDefinitions({
desktopStyle="bodyDefaultRegular"
overflowWrap="anywhere"
id="liquid-name"
- css={LIQUID_DEFINITION_TEXT}
+ css={LINE_CLAMP_TEXT_STYLE(3)}
>
{liquid.name}
@@ -61,11 +60,3 @@ export function LiquidDefinitions({
)
}
-
-const LIQUID_DEFINITION_TEXT = css`
- display: -webkit-box;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 3;
- overflow: ${OVERFLOW_HIDDEN};
- text-overflow: ellipsis;
-`
diff --git a/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx b/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx
index 7bb26264b8f..69d8697765b 100644
--- a/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx
+++ b/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx
@@ -61,7 +61,7 @@ export function ProtocolMetadata({
return (
@@ -70,7 +70,7 @@ export function ProtocolMetadata({
})}
{protocolName != null && protocolName !== ''
? protocolName
@@ -414,19 +415,11 @@ export function ProtocolOverview(): JSX.Element {
+
)
}
-const PROTOCOL_NAME_TEXT_STYLE = css`
- display: -webkit-box;
- -webkit-box-orient: vertical;
- overflow: hidden;
- text-overflow: ellipsis;
- word-wrap: break-word;
- -webkit-line-clamp: 3;
-`
-
const MIN_OVERVIEW_WIDTH = '64rem'
const COLUMN_GRID_GAP = '5rem'
const COLUMN_STYLE = css`
diff --git a/protocol-designer/src/steplist/fieldLevel/errors.ts b/protocol-designer/src/steplist/fieldLevel/errors.ts
index f0833fa652f..6ddc0d3dfd2 100644
--- a/protocol-designer/src/steplist/fieldLevel/errors.ts
+++ b/protocol-designer/src/steplist/fieldLevel/errors.ts
@@ -35,7 +35,7 @@ export const requiredField: ErrorChecker = (value: unknown) =>
!value ? FIELD_ERRORS.REQUIRED : null
export const isTimeFormat: ErrorChecker = (value: unknown): string | null => {
const timeRegex = new RegExp(/^\d{1,2}:\d{1,2}:\d{1,2}$/g)
- return (typeof value === 'string' && timeRegex.test(value)) || value == null
+ return (typeof value === 'string' && timeRegex.test(value)) || !value
? null
: FIELD_ERRORS.BAD_TIME_HMS
}
@@ -43,7 +43,7 @@ export const isTimeFormatMinutesSeconds: ErrorChecker = (
value: unknown
): string | null => {
const timeRegex = new RegExp(/^\d{1,2}:\d{1,2}$/g)
- return (typeof value === 'string' && timeRegex.test(value)) || value == null
+ return (typeof value === 'string' && timeRegex.test(value)) || !value
? null
: FIELD_ERRORS.BAD_TIME_MS
}
diff --git a/protocol-designer/src/steplist/fieldLevel/index.ts b/protocol-designer/src/steplist/fieldLevel/index.ts
index b9367adaa60..96e04bfed07 100644
--- a/protocol-designer/src/steplist/fieldLevel/index.ts
+++ b/protocol-designer/src/steplist/fieldLevel/index.ts
@@ -8,6 +8,7 @@ import {
temperatureRangeFieldValue,
realNumber,
isTimeFormat,
+ isTimeFormatMinutesSeconds,
} from './errors'
import {
maskToInteger,
@@ -345,6 +346,11 @@ const stepFieldHelperMap: Record = {
maskValue: composeMaskers(maskToInteger, onlyPositiveNumbers),
castValue: Number,
},
+ heaterShakerTimer: {
+ maskValue: composeMaskers(maskToTime),
+ getErrors: composeErrors(isTimeFormatMinutesSeconds),
+ castValue: String,
+ },
pauseAction: {
getErrors: composeErrors(requiredField),
},
diff --git a/protocol-designer/src/steplist/formLevel/errors.ts b/protocol-designer/src/steplist/formLevel/errors.ts
index ada2cef64fc..e5203cdc84e 100644
--- a/protocol-designer/src/steplist/formLevel/errors.ts
+++ b/protocol-designer/src/steplist/formLevel/errors.ts
@@ -15,7 +15,7 @@ import {
import { getPipetteCapacity } from '../../pipettes/pipetteData'
import { canPipetteUseLabware } from '../../utils'
import { getWellRatio } from '../utils'
-import { getTimeFromPauseForm } from '../utils/getTimeFromPauseForm'
+import { getTimeFromForm } from '../utils/getTimeFromForm'
import type { LabwareDefinition2, PipetteV2Specs } from '@opentrons/shared-data'
import type { LabwareEntities, PipetteEntity } from '@opentrons/step-generation'
@@ -137,6 +137,22 @@ const LID_TEMPERATURE_HOLD_REQUIRED: FormError = {
title: 'Temperature is required',
dependentFields: ['lidIsActiveHold', 'lidTargetTempHold'],
}
+const SHAKE_SPEED_REQUIRED: FormError = {
+ title: 'Shake speed required',
+ dependentFields: ['setShake', 'targetSpeed'],
+}
+const SHAKE_TIME_REQUIRED: FormError = {
+ title: 'Shake duration required',
+ dependentFields: ['heaterShakerSetTimer', 'heaterShakerTimer'],
+}
+const HS_TEMPERATURE_REQUIRED: FormError = {
+ title: 'Temperature required',
+ dependentFields: [
+ 'targetHeaterShakerTemperature',
+ 'setHeaterShakerTemperature',
+ ],
+}
+
export interface HydratedFormData {
[key: string]: any
}
@@ -198,7 +214,13 @@ export const pauseForTimeOrUntilTold = (
const { pauseAction, moduleId, pauseTemperature } = fields
if (pauseAction === PAUSE_UNTIL_TIME) {
- const { hours, minutes, seconds } = getTimeFromPauseForm(fields)
+ const { hours, minutes, seconds } = getTimeFromForm(
+ fields,
+ 'pauseTime',
+ 'pauseSeconds',
+ 'pauseMinutes',
+ 'pauseSeconds'
+ )
// user selected pause for amount of time
const totalSeconds = hours * 3600 + minutes * 60 + seconds
return totalSeconds <= 0 ? TIME_PARAM_REQUIRED : null
@@ -342,6 +364,26 @@ export const lidTemperatureHoldRequired = (
? LID_TEMPERATURE_HOLD_REQUIRED
: null
}
+export const shakeSpeedRequired = (
+ fields: HydratedFormData
+): FormError | null => {
+ const { targetSpeed, setShake } = fields
+ return setShake && !targetSpeed ? SHAKE_SPEED_REQUIRED : null
+}
+export const shakeTimeRequired = (
+ fields: HydratedFormData
+): FormError | null => {
+ const { heaterShakerTimer, heaterShakerSetTimer } = fields
+ return heaterShakerSetTimer && !heaterShakerTimer ? SHAKE_TIME_REQUIRED : null
+}
+export const temperatureRequired = (
+ fields: HydratedFormData
+): FormError | null => {
+ const { setHeaterShakerTemperature, targetHeaterShakerTemperature } = fields
+ return setHeaterShakerTemperature && !targetHeaterShakerTemperature
+ ? HS_TEMPERATURE_REQUIRED
+ : null
+}
export const engageHeightRangeExceeded = (
fields: HydratedFormData
): FormError | null => {
diff --git a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts
index b669b865e4e..40b35b3ccad 100644
--- a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts
+++ b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts
@@ -120,6 +120,7 @@ export function getDefaultsForStepType(
return {
moduleId: null,
pauseAction: null,
+ // TODO: (nd: 10/23/2024) remove individual time unit fields
pauseHour: null,
pauseMessage: '',
pauseMinute: null,
@@ -151,8 +152,10 @@ export function getDefaultsForStepType(
case 'heaterShaker':
return {
heaterShakerSetTimer: null,
+ // TODO: (nd: 10/23/2024) remove individual time unit fields
heaterShakerTimerMinutes: null,
heaterShakerTimerSeconds: null,
+ heaterShakerTimer: null,
latchOpen: false,
moduleId: null,
setHeaterShakerTemperature: null,
diff --git a/protocol-designer/src/steplist/formLevel/index.ts b/protocol-designer/src/steplist/formLevel/index.ts
index efa9334315e..1d67206c82b 100644
--- a/protocol-designer/src/steplist/formLevel/index.ts
+++ b/protocol-designer/src/steplist/formLevel/index.ts
@@ -17,6 +17,9 @@ import {
blockTemperatureHoldRequired,
lidTemperatureHoldRequired,
volumeTooHigh,
+ shakeSpeedRequired,
+ temperatureRequired,
+ shakeTimeRequired,
} from './errors'
import {
@@ -51,6 +54,13 @@ interface FormHelpers {
getWarnings?: (arg: unknown) => FormWarning[]
}
const stepFormHelperMap: Partial> = {
+ heaterShaker: {
+ getErrors: composeErrors(
+ shakeSpeedRequired,
+ shakeTimeRequired,
+ temperatureRequired
+ ),
+ },
mix: {
getErrors: composeErrors(incompatibleLabware, volumeTooHigh),
getWarnings: composeWarnings(
diff --git a/protocol-designer/src/steplist/formLevel/stepFormToArgs/heaterShakerFormToArgs.ts b/protocol-designer/src/steplist/formLevel/stepFormToArgs/heaterShakerFormToArgs.ts
index 8366864f190..eda1e073fb2 100644
--- a/protocol-designer/src/steplist/formLevel/stepFormToArgs/heaterShakerFormToArgs.ts
+++ b/protocol-designer/src/steplist/formLevel/stepFormToArgs/heaterShakerFormToArgs.ts
@@ -1,3 +1,4 @@
+import { getTimeFromForm } from '../../utils/getTimeFromForm'
import type { HeaterShakerArgs } from '@opentrons/step-generation'
import type { HydratedHeaterShakerFormData } from '../../../form-types'
@@ -22,6 +23,14 @@ export const heaterShakerFormToArgs = (
setShake ? !Number.isNaN(targetSpeed) : true,
'heaterShakerFormToArgs expected targeShake to be a number when setShake is true'
)
+ const { minutes, seconds } = getTimeFromForm(
+ formData,
+ 'heaterShakerTimer',
+ 'heaterShakerTimerSeconds',
+ 'heaterShakerTimerMinutes'
+ )
+
+ const isNullTime = minutes === 0 && seconds === 0
const targetTemperature =
setHeaterShakerTemperature && targetHeaterShakerTemperature != null
@@ -36,13 +45,7 @@ export const heaterShakerFormToArgs = (
targetTemperature: targetTemperature,
rpm: targetShake,
latchOpen: latchOpen,
- timerMinutes:
- formData.heaterShakerTimerMinutes != null
- ? parseInt(formData.heaterShakerTimerMinutes)
- : null,
- timerSeconds:
- formData.heaterShakerTimerSeconds != null
- ? parseInt(formData.heaterShakerTimerSeconds)
- : null,
+ timerMinutes: isNullTime ? null : minutes,
+ timerSeconds: isNullTime ? null : seconds,
}
}
diff --git a/protocol-designer/src/steplist/formLevel/stepFormToArgs/pauseFormToArgs.ts b/protocol-designer/src/steplist/formLevel/stepFormToArgs/pauseFormToArgs.ts
index cc7a32a13e3..88de52bacec 100644
--- a/protocol-designer/src/steplist/formLevel/stepFormToArgs/pauseFormToArgs.ts
+++ b/protocol-designer/src/steplist/formLevel/stepFormToArgs/pauseFormToArgs.ts
@@ -1,4 +1,4 @@
-import { getTimeFromPauseForm } from '../../utils/getTimeFromPauseForm'
+import { getTimeFromForm } from '../../utils/getTimeFromForm'
import {
PAUSE_UNTIL_TIME,
PAUSE_UNTIL_TEMP,
@@ -13,8 +13,14 @@ import type {
export const pauseFormToArgs = (
formData: FormData
): PauseArgs | WaitForTemperatureArgs | null => {
- const { hours, minutes, seconds } = getTimeFromPauseForm(formData)
- const totalSeconds = hours * 3600 + minutes * 60 + seconds
+ const { hours, minutes, seconds } = getTimeFromForm(
+ formData,
+ 'pauseTime',
+ 'pauseSecond',
+ 'pauseMinute',
+ 'pauseHour'
+ )
+ const totalSeconds = (hours ?? 0) * 3600 + minutes * 60 + seconds
const temperature = parseFloat(formData.pauseTemperature as string)
const message = formData.pauseMessage ?? ''
diff --git a/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts b/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts
index 5af511e500d..ba0897607ac 100644
--- a/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts
+++ b/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts
@@ -164,6 +164,7 @@ describe('getDefaultsForStepType', () => {
heaterShakerSetTimer: null,
heaterShakerTimerMinutes: null,
heaterShakerTimerSeconds: null,
+ heaterShakerTimer: null,
})
})
})
diff --git a/protocol-designer/src/steplist/utils/getTimeFromPauseForm.ts b/protocol-designer/src/steplist/utils/getTimeFromForm.ts
similarity index 62%
rename from protocol-designer/src/steplist/utils/getTimeFromPauseForm.ts
rename to protocol-designer/src/steplist/utils/getTimeFromForm.ts
index ed1b2243b49..ff35bdaeb09 100644
--- a/protocol-designer/src/steplist/utils/getTimeFromPauseForm.ts
+++ b/protocol-designer/src/steplist/utils/getTimeFromForm.ts
@@ -4,28 +4,33 @@ import type { HydratedFormData } from '../formLevel/errors'
const TIME_DELIMITER = ':'
interface TimeData {
- hours: number
minutes: number
seconds: number
+ hours: number
}
-export const getTimeFromPauseForm = (
- formData: FormData | HydratedFormData
+export const getTimeFromForm = (
+ formData: FormData | HydratedFormData,
+ timeField: string,
+ secondsField: string,
+ minutesField: string,
+ hoursField?: string
): TimeData => {
let hoursFromForm
let minutesFromForm
let secondsFromForm
// importing results in stringified "null" value
- if (formData.pauseTime != null && formData.pauseTime !== 'null') {
- const timeSplit = formData.pauseTime.split(TIME_DELIMITER)
- ;[hoursFromForm, minutesFromForm, secondsFromForm] = timeSplit
+ if (formData[timeField] != null && formData[timeField] !== 'null') {
+ const timeSplit = formData[timeField].split(TIME_DELIMITER)
+ ;[hoursFromForm, minutesFromForm, secondsFromForm] =
+ timeSplit.length === 3 ? timeSplit : [0, ...timeSplit]
} else {
// TODO (nd 09/23/2024): remove individual time units after redesign FF is removed
;[hoursFromForm, minutesFromForm, secondsFromForm] = [
- formData.pauseHour,
- formData.pauseMinute,
- formData.pauseSecond,
+ hoursField != null ? formData[hoursField] : null,
+ formData[minutesField],
+ formData[secondsField],
]
}
const hours = isNaN(parseFloat(hoursFromForm as string))
diff --git a/protocol-designer/src/top-selectors/labware-locations/index.ts b/protocol-designer/src/top-selectors/labware-locations/index.ts
index 7741f3a7314..df674025b74 100644
--- a/protocol-designer/src/top-selectors/labware-locations/index.ts
+++ b/protocol-designer/src/top-selectors/labware-locations/index.ts
@@ -235,7 +235,7 @@ export const getUnoccupiedLabwareLocationOptions: Selector<
)
})
.map(slotId => ({ name: slotId, value: slotId }))
- const offDeck = { name: 'Off-deck', value: 'offDeck' }
+ const offDeck = { name: 'Off-Deck', value: 'offDeck' }
const wasteChuteSlot = {
name: 'Waste Chute in D3',
value: WASTE_CHUTE_CUTOUT,
diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py
index 639e6d91628..788ca44aa1c 100644
--- a/robot-server/robot_server/runs/router/base_router.py
+++ b/robot-server/robot_server/runs/router/base_router.py
@@ -132,9 +132,9 @@ class AllRunsLinks(BaseModel):
class CurrentStateLinks(BaseModel):
"""Links returned with the current state of a run."""
- current: Optional[CommandLinkNoMeta] = Field(
+ lastCompleted: Optional[CommandLinkNoMeta] = Field(
None,
- description="Path to the current command when current state was reported, if any.",
+ description="Path to the last completed command when current state was reported, if any.",
)
@@ -564,7 +564,7 @@ async def get_run_commands_error(
"""
),
responses={
- status.HTTP_200_OK: {"model": SimpleBody[RunCurrentState]},
+ status.HTTP_200_OK: {"model": Body[RunCurrentState, CurrentStateLinks]},
status.HTTP_409_CONFLICT: {"model": ErrorBody[RunStopped]},
},
)
@@ -590,17 +590,18 @@ async def get_current_state(
for pipetteId, nozzle_map in active_nozzle_maps.items()
}
- current_command = run_data_manager.get_current_command(run_id=runId)
+ last_completed_command = run_data_manager.get_last_completed_command(
+ run_id=runId
+ )
except RunNotCurrentError as e:
raise RunStopped(detail=str(e)).as_error(status.HTTP_409_CONFLICT)
- # TODO(jh, 03-11-24): Use `last_completed_command` instead of `current_command` to avoid concurrency gotchas.
links = CurrentStateLinks.construct(
- current=CommandLinkNoMeta.construct(
- id=current_command.command_id,
- href=f"/runs/{runId}/commands/{current_command.command_id}",
+ lastCompleted=CommandLinkNoMeta.construct(
+ id=last_completed_command.command_id,
+ href=f"/runs/{runId}/commands/{last_completed_command.command_id}",
)
- if current_command is not None
+ if last_completed_command is not None
else None
)
diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py
index 3edf89ef163..d30f5c33979 100644
--- a/robot-server/robot_server/runs/run_data_manager.py
+++ b/robot-server/robot_server/runs/run_data_manager.py
@@ -456,10 +456,20 @@ def get_current_command(self, run_id: str) -> Optional[CommandPointer]:
if self._run_orchestrator_store.current_run_id == run_id:
return self._run_orchestrator_store.get_current_command()
else:
- # todo(mm, 2024-05-20):
- # For historical runs to behave consistently with the current run,
- # this should be the most recently completed command, not `None`.
- return None
+ return self._get_historical_run_last_command(run_id=run_id)
+
+ def get_last_completed_command(self, run_id: str) -> Optional[CommandPointer]:
+ """Get the "last" command, if any.
+
+ See `ProtocolEngine.state_view.commands.get_most_recently_finalized_command()` for the definition of "last."
+
+ Args:
+ run_id: ID of the run.
+ """
+ if self._run_orchestrator_store.current_run_id == run_id:
+ return self._run_orchestrator_store.get_most_recently_finalized_command()
+ else:
+ return self._get_historical_run_last_command(run_id=run_id)
def get_recovery_target_command(self, run_id: str) -> Optional[CommandPointer]:
"""Get the current error recovery target.
@@ -554,3 +564,22 @@ def _get_run_time_parameters(self, run_id: str) -> List[RunTimeParameter]:
return self._run_orchestrator_store.get_run_time_parameters()
else:
return self._run_store.get_run_time_parameters(run_id=run_id)
+
+ def _get_historical_run_last_command(self, run_id: str) -> Optional[CommandPointer]:
+ command_slice = self._run_store.get_commands_slice(
+ run_id=run_id, cursor=None, length=1, include_fixit_commands=True
+ )
+ if not command_slice.commands:
+ return None
+ command = command_slice.commands[-1]
+
+ return (
+ CommandPointer(
+ command_id=command.id,
+ command_key=command.key,
+ created_at=command.createdAt,
+ index=command_slice.cursor,
+ )
+ if command
+ else None
+ )
diff --git a/robot-server/robot_server/runs/run_orchestrator_store.py b/robot-server/robot_server/runs/run_orchestrator_store.py
index e05bd3bd349..efa97347ae9 100644
--- a/robot-server/robot_server/runs/run_orchestrator_store.py
+++ b/robot-server/robot_server/runs/run_orchestrator_store.py
@@ -335,9 +335,13 @@ def get_run_time_parameters(self) -> List[RunTimeParameter]:
return self.run_orchestrator.get_run_time_parameters()
def get_current_command(self) -> Optional[CommandPointer]:
- """Get the current running command."""
+ """Get the current running command, if any."""
return self.run_orchestrator.get_current_command()
+ def get_most_recently_finalized_command(self) -> Optional[CommandPointer]:
+ """Get the most recently finalized command, if any."""
+ return self.run_orchestrator.get_most_recently_finalized_command()
+
def get_command_slice(
self, cursor: Optional[int], length: int, include_fixit_commands: bool
) -> CommandSlice:
diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py
index 25c91f70ade..894950343e4 100644
--- a/robot-server/tests/runs/router/test_base_router.py
+++ b/robot-server/tests/runs/router/test_base_router.py
@@ -876,10 +876,12 @@ async def test_get_current_state_success(
decoy.when(mock_run_data_manager.get_nozzle_maps(run_id=run_id)).then_return(
mock_nozzle_maps
)
- decoy.when(mock_run_data_manager.get_current_command(run_id=run_id)).then_return(
+ decoy.when(
+ mock_run_data_manager.get_last_completed_command(run_id=run_id)
+ ).then_return(
CommandPointer(
- command_id="current-command-id",
- command_key="current-command-key",
+ command_id="last-command-id",
+ command_key="last-command-key",
created_at=datetime(year=2024, month=4, day=4),
index=101,
)
@@ -901,9 +903,9 @@ async def test_get_current_state_success(
}
)
assert result.content.links == CurrentStateLinks(
- current=CommandLinkNoMeta(
- href="/runs/test-run-id/commands/current-command-id",
- id="current-command-id",
+ lastCompleted=CommandLinkNoMeta(
+ href="/runs/test-run-id/commands/last-command-id",
+ id="last-command-id",
)
)
diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py
index 869f1c1c643..5e4aed1f3e2 100644
--- a/robot-server/tests/runs/test_run_data_manager.py
+++ b/robot-server/tests/runs/test_run_data_manager.py
@@ -1004,12 +1004,105 @@ def test_get_current_command_not_current_run(
subject: RunDataManager,
mock_run_store: RunStore,
mock_run_orchestrator_store: RunOrchestratorStore,
+ run_command: commands.Command,
) -> None:
- """Should return None because the run is not current."""
+ """Should get the last command from the run store for a historical run."""
+ last_command_slice = commands.WaitForResume(
+ id="command-id-1",
+ key="command-key",
+ createdAt=datetime(year=2021, month=1, day=1),
+ status=commands.CommandStatus.SUCCEEDED,
+ params=commands.WaitForResumeParams(message="Hello"),
+ )
+
+ expected_last_command = CommandPointer(
+ command_id="command-id-1",
+ command_key="command-key",
+ created_at=datetime(year=2021, month=1, day=1),
+ index=0,
+ )
+
+ command_slice = CommandSlice(
+ commands=[last_command_slice], cursor=0, total_length=1
+ )
+
decoy.when(mock_run_orchestrator_store.current_run_id).then_return("not-run-id")
+ decoy.when(
+ mock_run_store.get_commands_slice(
+ run_id="run-id", cursor=None, length=1, include_fixit_commands=True
+ )
+ ).then_return(command_slice)
result = subject.get_current_command("run-id")
- assert result is None
+ assert result == expected_last_command
+
+
+def test_get_last_completed_command_current_run(
+ decoy: Decoy,
+ subject: RunDataManager,
+ mock_run_orchestrator_store: RunOrchestratorStore,
+ run_command: commands.Command,
+) -> None:
+ """Should get the last command from the engine store for the current run."""
+ run_id = "current-run-id"
+ expected_last_command = CommandPointer(
+ command_id=run_command.id,
+ command_key=run_command.key,
+ created_at=run_command.createdAt,
+ index=1,
+ )
+
+ decoy.when(mock_run_orchestrator_store.current_run_id).then_return(run_id)
+ decoy.when(
+ mock_run_orchestrator_store.get_most_recently_finalized_command()
+ ).then_return(expected_last_command)
+
+ result = subject.get_last_completed_command(run_id)
+
+ assert result == expected_last_command
+
+
+def test_get_last_completed_command_not_current_run(
+ decoy: Decoy,
+ subject: RunDataManager,
+ mock_run_orchestrator_store: RunOrchestratorStore,
+ mock_run_store: RunStore,
+ run_command: commands.Command,
+) -> None:
+ """Should get the last command from the run store for a historical run."""
+ run_id = "historical-run-id"
+
+ last_command_slice = commands.WaitForResume(
+ id="command-id-1",
+ key="command-key",
+ createdAt=datetime(year=2021, month=1, day=1),
+ status=commands.CommandStatus.SUCCEEDED,
+ params=commands.WaitForResumeParams(message="Hello"),
+ )
+
+ expected_last_command = CommandPointer(
+ command_id="command-id-1",
+ command_key="command-key",
+ created_at=datetime(year=2021, month=1, day=1),
+ index=1,
+ )
+
+ decoy.when(mock_run_orchestrator_store.current_run_id).then_return(
+ "different-run-id"
+ )
+
+ command_slice = CommandSlice(
+ commands=[last_command_slice], cursor=1, total_length=1
+ )
+ decoy.when(
+ mock_run_store.get_commands_slice(
+ run_id=run_id, cursor=None, length=1, include_fixit_commands=True
+ )
+ ).then_return(command_slice)
+
+ result = subject.get_last_completed_command(run_id)
+
+ assert result == expected_last_command
def test_get_command_from_engine(
diff --git a/shared-data/Makefile b/shared-data/Makefile
index a06c7979d9e..2e972110c19 100644
--- a/shared-data/Makefile
+++ b/shared-data/Makefile
@@ -9,20 +9,31 @@ tests ?=
cov_opts ?= --coverage=true
test_opts ?=
+# warning suppression variables for tests and linting
+quiet ?= false
+
+FORMAT_FILE_GLOB = "**/*.@(ts|tsx|js|json|md|yml)"
+
# Top level targets
.PHONY: all
all: clean dist
.PHONY: setup
-setup: setup-py setup-js
+setup: setup-py
.PHONY: dist
-dist: dist-js dist-py
+dist: dist-py
.PHONY: clean
clean: clean-py
+.PHONY: format
+format: format-js format-py
+
+.PHONY: lint
+lint: lint-js lint-py
+
# JavaScript targets
.PHONY: lib-js
@@ -34,6 +45,22 @@ lib-js:
build-ts:
yarn tsc --build --emitDeclarationOnly
+.PHONY: format-js
+format-js:
+ yarn prettier --ignore-path ../.eslintignore --write $(FORMAT_FILE_GLOB)
+
+.PHONY: lint-js
+lint-js: lint-js-eslint lint-js-prettier
+
+.PHONY: lint-js-eslint
+lint-js-eslint:
+ yarn eslint --ignore-path ../.eslintignore --quiet=$(quiet) "**/*.@(js|ts|tsx)"
+
+.PHONY: lint-js-prettier
+lint-js-prettier:
+ yarn prettier --ignore-path ../.eslintignore --check $(FORMAT_FILE_GLOB)
+
+
# Python targets
.PHONY: setup-py
diff --git a/shared-data/js/labware.ts b/shared-data/js/labware.ts
index c12f9419f62..a085ffac89c 100644
--- a/shared-data/js/labware.ts
+++ b/shared-data/js/labware.ts
@@ -110,7 +110,9 @@ import opentronsFlex96Tiprack1000UlV1Uncasted from '../labware/definitions/2/ope
import opentronsFlex96Tiprack200UlV1Uncasted from '../labware/definitions/2/opentrons_flex_96_tiprack_200ul/1.json'
import opentronsFlex96Tiprack50UlV1Uncasted from '../labware/definitions/2/opentrons_flex_96_tiprack_50ul/1.json'
import opentronsFlex96TiprackAdapterV1Uncasted from '../labware/definitions/2/opentrons_flex_96_tiprack_adapter/1.json'
+import opentronsFlexDeckRiserV1Uncasted from '../labware/definitions/2/opentrons_flex_deck_riser/1.json'
import opentronsFlexLidAbsorbancePlateReaderModuleV1Uncasted from '../labware/definitions/2/opentrons_flex_lid_absorbance_plate_reader_module/1.json'
+import opentronsToughPcrAutoSealingLidV1Uncasted from '../labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json'
import opentronsUniversalFlatAdapterV1Uncasted from '../labware/definitions/2/opentrons_universal_flat_adapter/1.json'
import opentronsUniversalFlatAdapterCorning384Wellplate112UlFlatV1Uncasted from '../labware/definitions/2/opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat/1.json'
import opentrons96DeepWellTempModAdapterV1Uncasted from '../labware/definitions/2/opentrons_96_deep_well_temp_mod_adapter/1.json'
@@ -285,7 +287,9 @@ const opentronsFlex96Tiprack1000UlV1 = opentronsFlex96Tiprack1000UlV1Uncasted as
const opentronsFlex96Tiprack200UlV1 = opentronsFlex96Tiprack200UlV1Uncasted as LabwareDefinition2
const opentronsFlex96Tiprack50UlV1 = opentronsFlex96Tiprack50UlV1Uncasted as LabwareDefinition2
const opentronsFlex96TiprackAdapterV1 = opentronsFlex96TiprackAdapterV1Uncasted as LabwareDefinition2
+const opentronsFlexDeckRiserV1 = opentronsFlexDeckRiserV1Uncasted as LabwareDefinition2
const opentronsFlexLidAbsorbancePlateReaderModuleV1 = opentronsFlexLidAbsorbancePlateReaderModuleV1Uncasted as LabwareDefinition2
+const opentronsToughPcrAutoSealingLidV1 = opentronsToughPcrAutoSealingLidV1Uncasted as LabwareDefinition2
const opentronsUniversalFlatAdapterV1 = opentronsUniversalFlatAdapterV1Uncasted as LabwareDefinition2
const opentronsUniversalFlatAdapterCorning384Wellplate112UlFlatV1 = opentronsUniversalFlatAdapterCorning384Wellplate112UlFlatV1Uncasted as LabwareDefinition2
const thermoscientificnunc96Wellplate1300UlV1 = thermoscientificnunc96Wellplate1300UlV1Uncasted as LabwareDefinition2
@@ -452,7 +456,9 @@ const latestDefs = {
opentronsFlex96Tiprack200UlV1,
opentronsFlex96Tiprack50UlV1,
opentronsFlex96TiprackAdapterV1,
+ opentronsFlexDeckRiserV1,
opentronsFlexLidAbsorbancePlateReaderModuleV1,
+ opentronsToughPcrAutoSealingLidV1,
opentronsUniversalFlatAdapterV1,
opentronsUniversalFlatAdapterCorning384Wellplate112UlFlatV1,
thermoscientificnunc96Wellplate1300UlV1,
diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts
index 37d3794c9dc..720f422cb87 100644
--- a/shared-data/js/types.ts
+++ b/shared-data/js/types.ts
@@ -86,7 +86,7 @@ export type LabwareDisplayCategory =
| 'trash'
| 'other'
| 'adapter'
-
+ | 'lid'
export type LabwareVolumeUnits = 'µL' | 'mL' | 'L'
// TODO(mc, 2019-05-29): Remove this enum in favor of string + exported
diff --git a/shared-data/labware/definitions/2/opentrons_flex_deck_riser/1.json b/shared-data/labware/definitions/2/opentrons_flex_deck_riser/1.json
new file mode 100644
index 00000000000..6c0cd91a11b
--- /dev/null
+++ b/shared-data/labware/definitions/2/opentrons_flex_deck_riser/1.json
@@ -0,0 +1,41 @@
+{
+ "ordering": [],
+ "brand": {
+ "brand": "Opentrons",
+ "brandId": []
+ },
+ "metadata": {
+ "displayName": "Opentrons Flex Deck Riser",
+ "displayCategory": "adapter",
+ "displayVolumeUnits": "\u00b5L",
+ "tags": []
+ },
+ "dimensions": {
+ "xDimension": 140,
+ "yDimension": 98,
+ "zDimension": 21
+ },
+ "wells": {},
+ "groups": [
+ {
+ "metadata": {},
+ "wells": []
+ }
+ ],
+ "parameters": {
+ "format": "96Standard",
+ "quirks": [],
+ "isTiprack": false,
+ "isMagneticModuleCompatible": false,
+ "loadName": "opentrons_flex_deck_riser"
+ },
+ "namespace": "opentrons",
+ "version": 1,
+ "schemaVersion": 2,
+ "allowedRoles": ["adapter"],
+ "cornerOffsetFromSlot": {
+ "x": 0,
+ "y": 0,
+ "z": 0
+ }
+}
diff --git a/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json b/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json
index ca39c122b47..d5d56101e7f 100644
--- a/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json
+++ b/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json
@@ -7,7 +7,7 @@
},
"metadata": {
"displayName": "Opentrons Tough PCR Auto-Sealing Lid",
- "displayCategory": "other",
+ "displayCategory": "lid",
"displayVolumeUnits": "\u00b5L",
"tags": []
},
@@ -17,7 +17,12 @@
"zDimension": 12.8
},
"wells": {},
- "groups": [],
+ "groups": [
+ {
+ "metadata": {},
+ "wells": []
+ }
+ ],
"cornerOffsetFromSlot": {
"x": 0,
"y": 0,
@@ -60,6 +65,11 @@
"x": 0,
"y": 0,
"z": 8.193
+ },
+ "opentrons_flex_deck_riser": {
+ "x": 0,
+ "y": 0,
+ "z": 0
}
},
"gripForce": 15,
diff --git a/shared-data/labware/schemas/2.json b/shared-data/labware/schemas/2.json
index 203009be9f5..6f39c6af175 100644
--- a/shared-data/labware/schemas/2.json
+++ b/shared-data/labware/schemas/2.json
@@ -41,7 +41,8 @@
"wellPlate",
"aluminumBlock",
"adapter",
- "other"
+ "other",
+ "lid"
]
},
"safeString": {
diff --git a/shared-data/python/Config.in b/shared-data/python/Config.in
index 43ea8cf2688..8b9affb20ee 100644
--- a/shared-data/python/Config.in
+++ b/shared-data/python/Config.in
@@ -3,6 +3,7 @@ config BR2_PACKAGE_PYTHON_OPENTRONS_SHARED_DATA
depends on BR2_PACKAGE_PYTHON3
select BR2_PACKAGE_PYTHON_JSONSCHEMA # runtime
select BR2_PACKAGE_PYTHON_TYPING_EXTENSIONS # runtime
+ select BR2_PACKAGE_PYTHON_NUMPY # runtime
help
Opentrons data sources. Used on an OT-2 robot.
diff --git a/shared-data/python/Pipfile b/shared-data/python/Pipfile
index 0d11a1d68c9..dff2c2318bd 100644
--- a/shared-data/python/Pipfile
+++ b/shared-data/python/Pipfile
@@ -28,3 +28,4 @@ pytest-clarity = "~=1.0.0"
opentrons-shared-data = { editable = true, path = "." }
jsonschema = "==4.21.1"
pydantic = "==1.10.12"
+numpy = "==1.22.3"
diff --git a/shared-data/python/Pipfile.lock b/shared-data/python/Pipfile.lock
index a125943127f..43ce052e327 100644
--- a/shared-data/python/Pipfile.lock
+++ b/shared-data/python/Pipfile.lock
@@ -39,6 +39,33 @@
"markers": "python_version >= '3.8'",
"version": "==2023.12.1"
},
+ "numpy": {
+ "hashes": [
+ "sha256:07a8c89a04997625236c5ecb7afe35a02af3896c8aa01890a849913a2309c676",
+ "sha256:08d9b008d0156c70dc392bb3ab3abb6e7a711383c3247b410b39962263576cd4",
+ "sha256:201b4d0552831f7250a08d3b38de0d989d6f6e4658b709a02a73c524ccc6ffce",
+ "sha256:2c10a93606e0b4b95c9b04b77dc349b398fdfbda382d2a39ba5a822f669a0123",
+ "sha256:3ca688e1b9b95d80250bca34b11a05e389b1420d00e87a0d12dc45f131f704a1",
+ "sha256:48a3aecd3b997bf452a2dedb11f4e79bc5bfd21a1d4cc760e703c31d57c84b3e",
+ "sha256:568dfd16224abddafb1cbcce2ff14f522abe037268514dd7e42c6776a1c3f8e5",
+ "sha256:5bfb1bb598e8229c2d5d48db1860bcf4311337864ea3efdbe1171fb0c5da515d",
+ "sha256:639b54cdf6aa4f82fe37ebf70401bbb74b8508fddcf4797f9fe59615b8c5813a",
+ "sha256:8251ed96f38b47b4295b1ae51631de7ffa8260b5b087808ef09a39a9d66c97ab",
+ "sha256:92bfa69cfbdf7dfc3040978ad09a48091143cffb778ec3b03fa170c494118d75",
+ "sha256:97098b95aa4e418529099c26558eeb8486e66bd1e53a6b606d684d0c3616b168",
+ "sha256:a3bae1a2ed00e90b3ba5f7bd0a7c7999b55d609e0c54ceb2b076a25e345fa9f4",
+ "sha256:c34ea7e9d13a70bf2ab64a2532fe149a9aced424cd05a2c4ba662fd989e3e45f",
+ "sha256:dbc7601a3b7472d559dc7b933b18b4b66f9aa7452c120e87dfb33d02008c8a18",
+ "sha256:e7927a589df200c5e23c57970bafbd0cd322459aa7b1ff73b7c2e84d6e3eae62",
+ "sha256:f8c1f39caad2c896bc0018f699882b345b2a63708008be29b1f355ebf6f933fe",
+ "sha256:f950f8845b480cffe522913d35567e29dd381b0dc7e4ce6a4a9f9156417d2430",
+ "sha256:fade0d4f4d292b6f39951b6836d7a3c7ef5b2347f3c420cd9820a1d90d794802",
+ "sha256:fdf3c08bce27132395d3c3ba1503cac12e17282358cb4bddc25cc46b0aca07aa"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.8'",
+ "version": "==1.22.3"
+ },
"opentrons-shared-data": {
"editable": true,
"markers": "python_version >= '3.7'",
diff --git a/shared-data/python/opentrons_shared_data/labware/constants.py b/shared-data/python/opentrons_shared_data/labware/constants.py
index 9973604937b..4ce6974d600 100644
--- a/shared-data/python/opentrons_shared_data/labware/constants.py
+++ b/shared-data/python/opentrons_shared_data/labware/constants.py
@@ -8,9 +8,11 @@
WELL_NAME_PATTERN: Final["re.Pattern[str]"] = re.compile(r"^([A-Z]+)([0-9]+)$", re.X)
# These shapes are for wellshape definitions and describe the top of the well
-Circular = Literal["circular"]
-Rectangular = Literal["rectangular"]
-WellShape = Union[Circular, Rectangular]
+CircularType = Literal["circular"]
+Circular: CircularType = "circular"
+RectangularType = Literal["rectangular"]
+Rectangular: RectangularType = "rectangular"
+WellShape = Union[Literal["circular"], Literal["rectangular"]]
# These shapes are used to describe the 3D primatives used to build wells
Conical = Literal["conical"]
diff --git a/shared-data/python/opentrons_shared_data/labware/labware_definition.py b/shared-data/python/opentrons_shared_data/labware/labware_definition.py
index be4c1a17d01..6e20cc64809 100644
--- a/shared-data/python/opentrons_shared_data/labware/labware_definition.py
+++ b/shared-data/python/opentrons_shared_data/labware/labware_definition.py
@@ -7,6 +7,9 @@
from enum import Enum
from typing import TYPE_CHECKING, Dict, List, Optional, Union
+from math import sqrt, asin
+from numpy import pi, trapz
+from functools import cached_property
from pydantic import (
BaseModel,
@@ -26,6 +29,8 @@
SquaredCone,
Spherical,
WellShape,
+ Circular,
+ Rectangular,
)
SAFE_STRING_REGEX = "^[a-z0-9._]+$"
@@ -112,6 +117,7 @@ class DisplayCategory(str, Enum):
aluminumBlock = "aluminumBlock"
adapter = "adapter"
other = "other"
+ lid = "lid"
class LabwareRole(str, Enum):
@@ -349,6 +355,87 @@ class SquaredConeSegment(BaseModel):
description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well",
)
+ @staticmethod
+ def _area_trap_points(
+ total_frustum_height: float,
+ circle_diameter: float,
+ rectangle_x: float,
+ rectangle_y: float,
+ dx: float,
+ ) -> List[float]:
+ """Grab a bunch of data points of area at given heights."""
+
+ def _area_arcs(r: float, c: float, d: float) -> float:
+ """Return the area of all 4 arc segments."""
+ theata_y = asin(c / r)
+ theata_x = asin(d / r)
+ theata_arc = (pi / 2) - theata_y - theata_x
+ # area of all 4 arcs is 4 * pi*r^2*(theata/2pi)
+ return 2 * r**2 * theata_arc
+
+ def _area(r: float) -> float:
+ """Return the area of a given r_y."""
+ # distance from the center of y axis of the rectangle to where the arc intercepts that side
+ c: float = (
+ sqrt(r**2 - (rectangle_y / 2) ** 2) if (rectangle_y / 2) < r else 0
+ )
+ # distance from the center of x axis of the rectangle to where the arc intercepts that side
+ d: float = (
+ sqrt(r**2 - (rectangle_x / 2) ** 2) if (rectangle_x / 2) < r else 0
+ )
+ arc_area = _area_arcs(r, c, d)
+ y_triangles: float = rectangle_y * c
+ x_triangles: float = rectangle_x * d
+ return arc_area + y_triangles + x_triangles
+
+ r_0 = circle_diameter / 2
+ r_h = sqrt(rectangle_x**2 + rectangle_y**2) / 2
+
+ num_steps = int(total_frustum_height / dx)
+ points = [0.0]
+ for i in range(num_steps + 1):
+ r_y = (i * dx / total_frustum_height) * (r_h - r_0) + r_0
+ points.append(_area(r_y))
+ return points
+
+ @cached_property
+ def height_to_volume_table(self) -> Dict[float, float]:
+ """Return a lookup table of heights to volumes."""
+ # the accuracy of this method is approximately +- 10*dx so for dx of 0.001 we have a +- 0.01 ul
+ dx = 0.001
+ total_height = self.topHeight - self.bottomHeight
+ points = SquaredConeSegment._area_trap_points(
+ total_height,
+ self.circleDiameter,
+ self.rectangleXDimension,
+ self.rectangleYDimension,
+ dx,
+ )
+ if self.bottomCrossSection is Rectangular:
+ # The points function assumes the circle is at the bottom but if its flipped we just reverse the points
+ points.reverse()
+ elif self.bottomCrossSection is not Circular:
+ raise NotImplementedError(
+ "If you see this error a new well shape has been added without updating this code"
+ )
+ y = 0.0
+ table: Dict[float, float] = {}
+ # fill in the table
+ while y < total_height:
+ table[y] = trapz(points[0 : int(y / dx)], dx=dx)
+ y = y + dx
+
+ # we always want to include the volume at the max height
+ table[total_height] = trapz(points, dx=dx)
+ return table
+
+ @cached_property
+ def volume_to_height_table(self) -> Dict[float, float]:
+ return dict((v, k) for k, v in self.height_to_volume_table.items())
+
+ class Config:
+ keep_untouched = (cached_property,)
+
"""
module filitedCuboidSquare(bottom_shape, diameter, width, length, height, steps) {
diff --git a/shared-data/python/opentrons_shared_data/labware/types.py b/shared-data/python/opentrons_shared_data/labware/types.py
index 5a6aebf4ff7..20fb6664485 100644
--- a/shared-data/python/opentrons_shared_data/labware/types.py
+++ b/shared-data/python/opentrons_shared_data/labware/types.py
@@ -7,8 +7,8 @@
from typing_extensions import Literal, TypedDict, NotRequired
from .labware_definition import InnerWellGeometry
from .constants import (
- Circular,
- Rectangular,
+ CircularType,
+ RectangularType,
)
LabwareUri = NewType("LabwareUri", str)
@@ -22,6 +22,7 @@
Literal["aluminumBlock"],
Literal["adapter"],
Literal["other"],
+ Literal["lid"],
]
LabwareFormat = Union[
@@ -83,7 +84,7 @@ class LabwareDimensions(TypedDict):
class CircularWellDefinition(TypedDict):
- shape: Circular
+ shape: CircularType
depth: float
totalLiquidVolume: float
x: float
@@ -94,7 +95,7 @@ class CircularWellDefinition(TypedDict):
class RectangularWellDefinition(TypedDict):
- shape: Rectangular
+ shape: RectangularType
depth: float
totalLiquidVolume: float
x: float
diff --git a/step-generation/src/__tests__/moveLabware.test.ts b/step-generation/src/__tests__/moveLabware.test.ts
index ddc8e6008de..1f1a973a520 100644
--- a/step-generation/src/__tests__/moveLabware.test.ts
+++ b/step-generation/src/__tests__/moveLabware.test.ts
@@ -372,7 +372,7 @@ describe('moveLabware', () => {
)
expect(result.warnings).toEqual([
{
- message: 'Disposing of a tiprack with tips',
+ message: 'Disposing unused tips',
type: 'TIPRACK_IN_WASTE_CHUTE_HAS_TIPS',
},
])
diff --git a/step-generation/src/__tests__/waitForTemperature.test.ts b/step-generation/src/__tests__/waitForTemperature.test.ts
index 7cdd64333d6..5d730bb03ee 100644
--- a/step-generation/src/__tests__/waitForTemperature.test.ts
+++ b/step-generation/src/__tests__/waitForTemperature.test.ts
@@ -46,7 +46,7 @@ describe('waitForTemperature', () => {
invariantContext = stateAndContext.invariantContext
robotState = stateAndContext.robotState
})
- it('temperature module id exists and temp status is approaching temp', () => {
+ it('temperature module id exists and temp status is approaching temp with a warning that the temp might not be hit', () => {
const temperature = 20
const args: WaitForTemperatureArgs = {
module: temperatureModuleId,
@@ -70,6 +70,12 @@ describe('waitForTemperature', () => {
},
},
],
+ warnings: [
+ {
+ type: 'TEMPERATURE_IS_POTENTIALLY_UNREACHABLE',
+ message: expect.any(String),
+ },
+ ],
}
const result = waitForTemperature(
args,
diff --git a/step-generation/src/commandCreators/atomic/aspirate.ts b/step-generation/src/commandCreators/atomic/aspirate.ts
index 663dac9388b..3d6726b8be2 100644
--- a/step-generation/src/commandCreators/atomic/aspirate.ts
+++ b/step-generation/src/commandCreators/atomic/aspirate.ts
@@ -64,7 +64,6 @@ export const aspirate: CommandCreator = (
if (!pipetteSpec) {
errors.push(
errorCreators.pipetteDoesNotExist({
- actionName,
pipette,
})
)
diff --git a/step-generation/src/commandCreators/atomic/blowout.ts b/step-generation/src/commandCreators/atomic/blowout.ts
index b56c57fc9db..8e97fc3114b 100644
--- a/step-generation/src/commandCreators/atomic/blowout.ts
+++ b/step-generation/src/commandCreators/atomic/blowout.ts
@@ -27,7 +27,6 @@ export const blowout: CommandCreator = (
if (!pipetteData) {
errors.push(
errorCreators.pipetteDoesNotExist({
- actionName,
pipette: pipetteId,
})
)
diff --git a/step-generation/src/commandCreators/atomic/dispense.ts b/step-generation/src/commandCreators/atomic/dispense.ts
index 31adbe7a5ab..c06e0035f7b 100644
--- a/step-generation/src/commandCreators/atomic/dispense.ts
+++ b/step-generation/src/commandCreators/atomic/dispense.ts
@@ -61,7 +61,6 @@ export const dispense: CommandCreator = (
if (!pipetteSpec) {
errors.push(
errorCreators.pipetteDoesNotExist({
- actionName,
pipette,
})
)
diff --git a/step-generation/src/commandCreators/atomic/moveToWell.ts b/step-generation/src/commandCreators/atomic/moveToWell.ts
index 34c36a1eb01..03ed52231bb 100644
--- a/step-generation/src/commandCreators/atomic/moveToWell.ts
+++ b/step-generation/src/commandCreators/atomic/moveToWell.ts
@@ -44,7 +44,6 @@ export const moveToWell: CommandCreator = (
if (!pipetteSpec) {
errors.push(
errorCreators.pipetteDoesNotExist({
- actionName,
pipette,
})
)
diff --git a/step-generation/src/commandCreators/atomic/replaceTip.ts b/step-generation/src/commandCreators/atomic/replaceTip.ts
index 57828fe61e5..c516a1a4012 100644
--- a/step-generation/src/commandCreators/atomic/replaceTip.ts
+++ b/step-generation/src/commandCreators/atomic/replaceTip.ts
@@ -162,7 +162,6 @@ export const replaceTip: CommandCreator = (
return {
errors: [
errorCreators.pipetteDoesNotExist({
- actionName: 'replaceTip',
pipette,
}),
],
diff --git a/step-generation/src/commandCreators/atomic/touchTip.ts b/step-generation/src/commandCreators/atomic/touchTip.ts
index c84ccd61762..75047c0b610 100644
--- a/step-generation/src/commandCreators/atomic/touchTip.ts
+++ b/step-generation/src/commandCreators/atomic/touchTip.ts
@@ -18,7 +18,6 @@ export const touchTip: CommandCreator = (
if (!pipetteData) {
errors.push(
pipetteDoesNotExist({
- actionName,
pipette,
})
)
diff --git a/step-generation/src/commandCreators/atomic/waitForTemperature.ts b/step-generation/src/commandCreators/atomic/waitForTemperature.ts
index e01fbdf6d5f..e12ed70c0ec 100644
--- a/step-generation/src/commandCreators/atomic/waitForTemperature.ts
+++ b/step-generation/src/commandCreators/atomic/waitForTemperature.ts
@@ -3,10 +3,19 @@ import {
TEMPERATURE_MODULE_TYPE,
} from '@opentrons/shared-data'
import { uuid } from '../../utils'
-import { TEMPERATURE_AT_TARGET, TEMPERATURE_DEACTIVATED } from '../../constants'
-import * as errorCreators from '../../errorCreators'
-import type { CommandCreator, WaitForTemperatureArgs } from '../../types'
+import {
+ TEMPERATURE_APPROACHING_TARGET,
+ TEMPERATURE_AT_TARGET,
+ TEMPERATURE_DEACTIVATED,
+} from '../../constants'
import { getModuleState } from '../../robotStateSelectors'
+import * as errorCreators from '../../errorCreators'
+import * as warningCreators from '../../warningCreators'
+import type {
+ CommandCreator,
+ CommandCreatorWarning,
+ WaitForTemperatureArgs,
+} from '../../types'
/** Set temperature target for specified module. */
export const waitForTemperature: CommandCreator = (
@@ -16,6 +25,7 @@ export const waitForTemperature: CommandCreator = (
) => {
const { module, temperature } = args
const moduleState = module ? getModuleState(prevRobotState, module) : null
+ const warnings: CommandCreatorWarning[] = []
if (module === null || !moduleState) {
return {
@@ -42,6 +52,10 @@ export const waitForTemperature: CommandCreator = (
'status' in moduleState &&
moduleState.status === TEMPERATURE_AT_TARGET &&
moduleState.targetTemperature !== temperature
+ const potentiallyUnreachableTemp =
+ 'status' in moduleState &&
+ moduleState.status === TEMPERATURE_APPROACHING_TARGET &&
+ moduleState.targetTemperature !== temperature
if (
unreachableTemp ||
@@ -52,6 +66,10 @@ export const waitForTemperature: CommandCreator = (
}
}
+ if (potentiallyUnreachableTemp) {
+ warnings.push(warningCreators.potentiallyUnreachableTemp())
+ }
+
const moduleType = invariantContext.moduleEntities[module]?.type
switch (moduleType) {
@@ -67,6 +85,7 @@ export const waitForTemperature: CommandCreator = (
},
},
],
+ warnings: warnings.length > 0 ? warnings : undefined,
}
case HEATERSHAKER_MODULE_TYPE:
@@ -81,6 +100,7 @@ export const waitForTemperature: CommandCreator = (
},
},
],
+ warnings: warnings.length > 0 ? warnings : undefined,
}
default:
diff --git a/step-generation/src/commandCreators/compound/consolidate.ts b/step-generation/src/commandCreators/compound/consolidate.ts
index f60fa7b0ab1..94d20e255f0 100644
--- a/step-generation/src/commandCreators/compound/consolidate.ts
+++ b/step-generation/src/commandCreators/compound/consolidate.ts
@@ -85,7 +85,6 @@ export const consolidate: CommandCreator = (
nozzles,
} = args
- const actionName = 'consolidate'
const pipetteData = prevRobotState.pipettes[args.pipette]
const is96Channel =
invariantContext.pipetteEntities[args.pipette]?.spec.channels === 96
@@ -95,7 +94,6 @@ export const consolidate: CommandCreator = (
return {
errors: [
errorCreators.pipetteDoesNotExist({
- actionName,
pipette: args.pipette,
}),
],
diff --git a/step-generation/src/commandCreators/compound/distribute.ts b/step-generation/src/commandCreators/compound/distribute.ts
index 824435aeaaf..9c4c2cc865e 100644
--- a/step-generation/src/commandCreators/compound/distribute.ts
+++ b/step-generation/src/commandCreators/compound/distribute.ts
@@ -88,7 +88,6 @@ export const distribute: CommandCreator = (
) {
errors.push(
errorCreators.pipetteDoesNotExist({
- actionName,
pipette: args.pipette,
})
)
diff --git a/step-generation/src/commandCreators/compound/mix.ts b/step-generation/src/commandCreators/compound/mix.ts
index dbfedcbf337..15ceb73221c 100644
--- a/step-generation/src/commandCreators/compound/mix.ts
+++ b/step-generation/src/commandCreators/compound/mix.ts
@@ -164,7 +164,6 @@ export const mix: CommandCreator = (
return {
errors: [
errorCreators.pipetteDoesNotExist({
- actionName,
pipette,
}),
],
diff --git a/step-generation/src/commandCreators/compound/transfer.ts b/step-generation/src/commandCreators/compound/transfer.ts
index f2bc471b911..b6d8a508ec2 100644
--- a/step-generation/src/commandCreators/compound/transfer.ts
+++ b/step-generation/src/commandCreators/compound/transfer.ts
@@ -118,7 +118,6 @@ export const transfer: CommandCreator = (
// bail out before doing anything else
errors.push(
errorCreators.pipetteDoesNotExist({
- actionName,
pipette: args.pipette,
})
)
diff --git a/step-generation/src/errorCreators.ts b/step-generation/src/errorCreators.ts
index a541c65e4ea..1684a129900 100644
--- a/step-generation/src/errorCreators.ts
+++ b/step-generation/src/errorCreators.ts
@@ -47,12 +47,11 @@ export function pipetteHasTip(): CommandCreatorError {
}
export function pipetteDoesNotExist(args: {
- actionName: string
pipette: string
}): CommandCreatorError {
- const { actionName, pipette } = args
+ const { pipette } = args
return {
- message: `Attempted to ${actionName} with pipette id "${pipette}", this pipette was not found under "pipettes"`,
+ message: `This step tries to use the ${pipette}. Add the pipette to your protocol or change the step to use a different pipette.`,
type: 'PIPETTE_DOES_NOT_EXIST',
}
}
@@ -77,7 +76,7 @@ export function labwareDoesNotExist(args: {
`Attempted to ${actionName} with labware id "${labware}", this labware was not found under "labware"`
)
return {
- message: 'A step involves labware that has been deleted',
+ message: `This step tries to use ${labware}. Add the labware to your protocol or change the step to use a different labware.`,
type: 'LABWARE_DOES_NOT_EXIST',
}
}
@@ -102,9 +101,9 @@ export function tipVolumeExceeded(args: {
volume: string | number
maxVolume: string | number
}): CommandCreatorError {
- const { actionName, volume, maxVolume } = args
+ const { volume, maxVolume, actionName } = args
return {
- message: `Attempted to ${actionName} volume greater than tip max volume (${volume} > ${maxVolume})`,
+ message: `This step tries to ${actionName} ${volume}μL, but the tip can only hold ${maxVolume}μL.`,
type: 'TIP_VOLUME_EXCEEDED',
}
}
@@ -119,7 +118,7 @@ export function pipetteVolumeExceeded(args: {
const message =
disposalVolume != null
? `Attemped to ${actionName} volume + disposal volume greater than pipette max volume (${volume} + ${disposalVolume} > ${maxVolume})`
- : `Attempted to ${actionName} volume greater than pipette max volume (${volume} > ${maxVolume})`
+ : `This step tries to ${actionName} ${volume}μL, but the tip can only hold ${maxVolume}μL.`
return {
message,
type: 'PIPETTE_VOLUME_EXCEEDED',
@@ -236,7 +235,7 @@ export const dropTipLocationDoesNotExist = (): CommandCreatorError => {
export const equipmentDoesNotExist = (): CommandCreatorError => {
return {
type: 'EQUIPMENT_DOES_NOT_EXIST',
- message: `The equipment does not exist`,
+ message: `Equipment does not exist.`,
}
}
diff --git a/step-generation/src/types.ts b/step-generation/src/types.ts
index 149d7060316..021b6cfa515 100644
--- a/step-generation/src/types.ts
+++ b/step-generation/src/types.ts
@@ -575,6 +575,7 @@ export type WarningType =
| 'ASPIRATE_FROM_PRISTINE_WELL'
| 'LABWARE_IN_WASTE_CHUTE_HAS_LIQUID'
| 'TIPRACK_IN_WASTE_CHUTE_HAS_TIPS'
+ | 'TEMPERATURE_IS_POTENTIALLY_UNREACHABLE'
export interface CommandCreatorWarning {
message: string
diff --git a/step-generation/src/warningCreators.ts b/step-generation/src/warningCreators.ts
index 69959fcbe15..8936b2a9f7a 100644
--- a/step-generation/src/warningCreators.ts
+++ b/step-generation/src/warningCreators.ts
@@ -2,14 +2,13 @@ import type { CommandCreatorWarning } from './types'
export function aspirateMoreThanWellContents(): CommandCreatorWarning {
return {
type: 'ASPIRATE_MORE_THAN_WELL_CONTENTS',
- message: 'Not enough liquid in well(s)',
+ message: 'Not enough liquid',
}
}
export function aspirateFromPristineWell(): CommandCreatorWarning {
return {
type: 'ASPIRATE_FROM_PRISTINE_WELL',
- message:
- 'Aspirating from a pristine well. No liquids were ever added to this well',
+ message: 'This step tries to aspirate from an empty well.',
}
}
export function labwareInWasteChuteHasLiquid(): CommandCreatorWarning {
@@ -21,6 +20,13 @@ export function labwareInWasteChuteHasLiquid(): CommandCreatorWarning {
export function tiprackInWasteChuteHasTips(): CommandCreatorWarning {
return {
type: 'TIPRACK_IN_WASTE_CHUTE_HAS_TIPS',
- message: 'Disposing of a tiprack with tips',
+ message: 'Disposing unused tips',
+ }
+}
+
+export function potentiallyUnreachableTemp(): CommandCreatorWarning {
+ return {
+ type: 'TEMPERATURE_IS_POTENTIALLY_UNREACHABLE',
+ message: 'The module set temperature is potentially unreachable.',
}
}