diff --git a/app/src/assets/localization/en/shared.json b/app/src/assets/localization/en/shared.json index fe3c9177c5c..8c8bed0a5af 100644 --- a/app/src/assets/localization/en/shared.json +++ b/app/src/assets/localization/en/shared.json @@ -6,6 +6,7 @@ "before_you_begin": "Before you begin", "browse": "browse", "cancel": "cancel", + "change_robot": "Change robot", "clear_data": "clear data", "close_robot_door": "Close the robot door before starting the run.", "close": "close", @@ -13,8 +14,10 @@ "confirm_placement": "Confirm placement", "confirm_position": "Confirm position", "confirm_terminate": "This will immediately stop the activity begun on a computer. You, or another user, may lose progress or see an error in the Opentrons App.", + "confirm_values": "Confirm values", "confirm": "Confirm", "continue_activity": "Continue activity", + "continue_to_param": "Continue to parameters", "continue": "continue", "delete": "Delete", "did_pipette_pick_up_tip": "Did pipette pick up tip successfully?", diff --git a/app/src/atoms/Slideout/MultiSlideout.tsx b/app/src/atoms/Slideout/MultiSlideout.tsx new file mode 100644 index 00000000000..71ce02f6de6 --- /dev/null +++ b/app/src/atoms/Slideout/MultiSlideout.tsx @@ -0,0 +1,37 @@ +import * as React from 'react' +import { Slideout } from './index' + +interface MultiSlideoutProps { + title: string | React.ReactElement + children: React.ReactNode + onCloseClick: () => void + currentStep: number + maxSteps: number + // isExpanded is for collapse and expand animation + isExpanded?: boolean + footer?: React.ReactNode +} + +export const MultiSlideout = (props: MultiSlideoutProps): JSX.Element => { + const { + isExpanded, + title, + onCloseClick, + children, + footer, + maxSteps, + currentStep, + } = props + + return ( + + {children} + + ) +} diff --git a/app/src/atoms/Slideout/index.tsx b/app/src/atoms/Slideout/index.tsx index 57d20e1de50..03fcc0c9723 100644 --- a/app/src/atoms/Slideout/index.tsx +++ b/app/src/atoms/Slideout/index.tsx @@ -19,14 +19,20 @@ import { import { Divider } from '../structure' import { StyledText } from '../text' +import { useTranslation } from 'react-i18next' +export interface MultiSlideoutSpecs { + currentStep: number + maxSteps: number +} export interface SlideoutProps { title: string | React.ReactElement children: React.ReactNode - onCloseClick: () => unknown + onCloseClick: () => void // isExpanded is for collapse and expand animation isExpanded?: boolean footer?: React.ReactNode + multiSlideoutSpecs?: MultiSlideoutSpecs } const SHARED_STYLE = css` @@ -108,10 +114,17 @@ const CLOSE_ICON_STYLE = css` ` export const Slideout = (props: SlideoutProps): JSX.Element => { - const { isExpanded, title, onCloseClick, children, footer } = props + const { + isExpanded, + title, + onCloseClick, + children, + footer, + multiSlideoutSpecs, + } = props + const { t } = useTranslation('shared') const slideOutRef = React.useRef(null) const [isReachedBottom, setIsReachedBottom] = React.useState(false) - const hasBeenExpanded = React.useRef(isExpanded ?? false) const handleScroll = (): void => { if (slideOutRef.current == null) return @@ -166,6 +179,19 @@ export const Slideout = (props: SlideoutProps): JSX.Element => { flexDirection={DIRECTION_COLUMN} justifyContent={JUSTIFY_SPACE_BETWEEN} > + {multiSlideoutSpecs == null ? null : ( + + {t('step', { + current: multiSlideoutSpecs.currentStep, + max: multiSlideoutSpecs.maxSteps, + })} + + )} {typeof title === 'string' ? ( { expect(vi.mocked(startDiscovery)).toHaveBeenCalled() expect(dispatch).toHaveBeenCalledWith({ type: 'mockStartDiscovery' }) }) + it('renders the multi slideout page 1', () => { + render({ + onCloseClick: vi.fn(), + isExpanded: true, + isSelectedRobotOnDifferentSoftwareVersion: false, + selectedRobot: null, + setSelectedRobot: mockSetSelectedRobot, + title: 'choose robot slideout title', + robotType: 'OT-2 Standard', + multiSlideout: { currentPage: 1 }, + }) + screen.getByText('Step 1 / 2') + }) + it('renders the multi slideout page 2', () => { + render({ + onCloseClick: vi.fn(), + isExpanded: true, + isSelectedRobotOnDifferentSoftwareVersion: false, + selectedRobot: null, + setSelectedRobot: mockSetSelectedRobot, + title: 'choose robot slideout title', + robotType: 'OT-2 Standard', + multiSlideout: { currentPage: 2 }, + }) + screen.getByText('Step 2 / 2') + }) it('defaults to first available robot and allows an available robot to be selected', () => { vi.mocked(getConnectableRobots).mockReturnValue([ { ...mockConnectableRobot, name: 'otherRobot', ip: 'otherIp' }, diff --git a/app/src/organisms/ChooseRobotSlideout/index.tsx b/app/src/organisms/ChooseRobotSlideout/index.tsx index f9c9c37730c..5c768bdb12f 100644 --- a/app/src/organisms/ChooseRobotSlideout/index.tsx +++ b/app/src/organisms/ChooseRobotSlideout/index.tsx @@ -35,6 +35,7 @@ import { } from '../../redux/discovery' import { Banner } from '../../atoms/Banner' import { Slideout } from '../../atoms/Slideout' +import { MultiSlideout } from '../../atoms/Slideout/MultiSlideout' import { StyledText } from '../../atoms/text' import { AvailableRobotOption } from './AvailableRobotOption' @@ -90,6 +91,7 @@ interface ChooseRobotSlideoutProps isAnalysisError?: boolean isAnalysisStale?: boolean showIdleOnly?: boolean + multiSlideout?: { currentPage: number } } export function ChooseRobotSlideout( @@ -112,6 +114,7 @@ export function ChooseRobotSlideout( setSelectedRobot, robotType, showIdleOnly = false, + multiSlideout, } = props const dispatch = useDispatch() const isScanning = useSelector((state: State) => getScanning(state)) @@ -171,145 +174,157 @@ export function ChooseRobotSlideout( const unavailableCount = unhealthyReachableRobots.length + unreachableRobots.length - return ( - - - {isAnalysisError ? ( - {t('protocol_failed_app_analysis')} - ) : null} - {isAnalysisStale ? ( - {t('protocol_outdated_app_analysis')} - ) : null} - - {isScanning ? ( - - - {t('app_settings:searching')} - - - - ) : ( - dispatch(startDiscovery())} - textTransform={TYPOGRAPHY.textTransformCapitalize} - role="button" - css={TYPOGRAPHY.linkPSemiBold} + const pageOneBody = ( + + {isAnalysisError ? ( + {t('protocol_failed_app_analysis')} + ) : null} + {isAnalysisStale ? ( + {t('protocol_outdated_app_analysis')} + ) : null} + + {isScanning ? ( + + - {t('shared:refresh')} - - )} - - {!isScanning && healthyReachableRobots.length === 0 ? ( - - - - {t('no_available_robots_found')} + {t('app_settings:searching')} + ) : ( - healthyReachableRobots.map(robot => { - const isSelected = - selectedRobot != null && selectedRobot.ip === robot.ip - return ( - - { - if (!isCreatingRun) { - resetCreateRun?.() - setSelectedRobot(robot) - } - }} - isError={runCreationError != null} - isSelected={isSelected} - isSelectedRobotOnDifferentSoftwareVersion={ - isSelectedRobotOnDifferentSoftwareVersion - } - showIdleOnly={showIdleOnly} - registerRobotBusyStatus={registerRobotBusyStatus} - /> - {runCreationError != null && isSelected && ( - - {runCreationErrorCode === 409 ? ( - - ), - }} - /> - ) : ( - runCreationError - )} - - )} - - ) - }) - )} - {!isScanning && unavailableCount > 0 ? ( - dispatch(startDiscovery())} + textTransform={TYPOGRAPHY.textTransformCapitalize} + role="button" + css={TYPOGRAPHY.linkPSemiBold} > - - {showIdleOnly - ? t('unavailable_or_busy_robot_not_listed', { - count: unavailableCount + reducerBusyCount, - }) - : t('unavailable_robot_not_listed', { - count: unavailableCount, - })} - - - {t('view_unavailable_robots')} - - - ) : null} + {t('shared:refresh')} + + )} + {!isScanning && healthyReachableRobots.length === 0 ? ( + + + + {t('no_available_robots_found')} + + + ) : ( + healthyReachableRobots.map(robot => { + const isSelected = + selectedRobot != null && selectedRobot.ip === robot.ip + return ( + + { + if (!isCreatingRun) { + resetCreateRun?.() + setSelectedRobot(robot) + } + }} + isError={runCreationError != null} + isSelected={isSelected} + isSelectedRobotOnDifferentSoftwareVersion={ + isSelectedRobotOnDifferentSoftwareVersion + } + showIdleOnly={showIdleOnly} + registerRobotBusyStatus={registerRobotBusyStatus} + /> + {runCreationError != null && isSelected && ( + + {runCreationErrorCode === 409 ? ( + + ), + }} + /> + ) : ( + runCreationError + )} + + )} + + ) + }) + )} + {!isScanning && unavailableCount > 0 ? ( + + + {showIdleOnly + ? t('unavailable_or_busy_robot_not_listed', { + count: unavailableCount + reducerBusyCount, + }) + : t('unavailable_robot_not_listed', { + count: unavailableCount, + })} + + + {t('view_unavailable_robots')} + + + ) : null} + + ) + + const pageTwoBody = TODO + + return multiSlideout != null ? ( + + {multiSlideout.currentPage === 1 ? pageOneBody : pageTwoBody} + + ) : ( + + {pageOneBody} ) } diff --git a/app/src/organisms/ChooseRobotToRunProtocolSlideout/__tests__/ChooseRobotToRunProtocolSlideout.test.tsx b/app/src/organisms/ChooseRobotToRunProtocolSlideout/__tests__/ChooseRobotToRunProtocolSlideout.test.tsx index 70b54a106ce..bf2cf102d76 100644 --- a/app/src/organisms/ChooseRobotToRunProtocolSlideout/__tests__/ChooseRobotToRunProtocolSlideout.test.tsx +++ b/app/src/organisms/ChooseRobotToRunProtocolSlideout/__tests__/ChooseRobotToRunProtocolSlideout.test.tsx @@ -183,16 +183,17 @@ describe('ChooseRobotToRunProtocolSlideout', () => { showSlideout: true, }) const proceedButton = screen.getByRole('button', { - name: 'Proceed to setup', + name: 'Continue to parameters', }) - expect(proceedButton).not.toBeDisabled() + const otherRobot = screen.getByText('otherRobot') fireEvent.click(otherRobot) // unselect default robot - expect(proceedButton).not.toBeDisabled() const mockRobot = screen.getByText('opentrons-robot-name') fireEvent.click(mockRobot) - expect(proceedButton).not.toBeDisabled() fireEvent.click(proceedButton) + const confirm = screen.getByRole('button', { name: 'Confirm values' }) + expect(confirm).not.toBeDisabled() + fireEvent.click(confirm) expect(mockCreateRunFromProtocolSource).toHaveBeenCalledWith({ files: [expect.any(File)], protocolKey: storedProtocolDataFixture.protocolKey, @@ -211,7 +212,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { showSlideout: true, }) const proceedButton = screen.getByRole('button', { - name: 'Proceed to setup', + name: 'Continue to parameters', }) expect(proceedButton).toBeDisabled() screen.getByText( @@ -235,15 +236,17 @@ describe('ChooseRobotToRunProtocolSlideout', () => { showSlideout: true, }) const proceedButton = screen.getByRole('button', { - name: 'Proceed to setup', + name: 'Continue to parameters', }) fireEvent.click(proceedButton) + fireEvent.click(screen.getByRole('button', { name: 'Confirm values' })) expect(mockCreateRunFromProtocolSource).toHaveBeenCalledWith({ files: [expect.any(File)], protocolKey: storedProtocolDataFixture.protocolKey, }) expect(mockTrackCreateProtocolRunEvent).toHaveBeenCalled() - expect(screen.getByText('run creation error')).toBeInTheDocument() + // TODO( jr, 3.13.24): fix this when page 2 is completed of the multislideout + // expect(screen.getByText('run creation error')).toBeInTheDocument() }) it('renders error state when run creation error code is 409', () => { @@ -260,20 +263,22 @@ describe('ChooseRobotToRunProtocolSlideout', () => { showSlideout: true, }) const proceedButton = screen.getByRole('button', { - name: 'Proceed to setup', + name: 'Continue to parameters', }) + const link = screen.getByRole('link', { name: 'Go to Robot' }) + fireEvent.click(link) + expect(link.getAttribute('href')).toEqual('/devices/opentrons-robot-name') fireEvent.click(proceedButton) + fireEvent.click(screen.getByRole('button', { name: 'Confirm values' })) expect(mockCreateRunFromProtocolSource).toHaveBeenCalledWith({ files: [expect.any(File)], protocolKey: storedProtocolDataFixture.protocolKey, }) expect(mockTrackCreateProtocolRunEvent).toHaveBeenCalled() - screen.getByText( - 'This robot is busy and can’t run this protocol right now.' - ) - const link = screen.getByRole('link', { name: 'Go to Robot' }) - fireEvent.click(link) - expect(link.getAttribute('href')).toEqual('/devices/opentrons-robot-name') + // TODO( jr, 3.13.24): fix this when page 2 is completed of the multislideout + // screen.getByText( + // 'This robot is busy and can’t run this protocol right now.' + // ) }) it('renders apply historic offsets as determinate if candidates available', () => { @@ -311,9 +316,10 @@ describe('ChooseRobotToRunProtocolSlideout', () => { ) expect(screen.getByRole('checkbox')).toBeChecked() const proceedButton = screen.getByRole('button', { - name: 'Proceed to setup', + name: 'Continue to parameters', }) fireEvent.click(proceedButton) + fireEvent.click(screen.getByRole('button', { name: 'Confirm values' })) expect(mockCreateRunFromProtocolSource).toHaveBeenCalledWith({ files: [expect.any(File)], protocolKey: storedProtocolDataFixture.protocolKey, @@ -350,9 +356,10 @@ describe('ChooseRobotToRunProtocolSlideout', () => { expect(screen.getByRole('checkbox')).toBeChecked() const proceedButton = screen.getByRole('button', { - name: 'Proceed to setup', + name: 'Continue to parameters', }) fireEvent.click(proceedButton) + fireEvent.click(screen.getByRole('button', { name: 'Confirm values' })) expect(vi.mocked(useCreateRunFromProtocol)).nthCalledWith( 2, expect.any(Object), diff --git a/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx b/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx index fab0fbcd756..dbb5f7196b1 100644 --- a/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx +++ b/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx @@ -10,6 +10,9 @@ import { DIRECTION_COLUMN, SIZE_1, PrimaryButton, + DIRECTION_ROW, + SecondaryButton, + SPACING, } from '@opentrons/components' import { getRobotUpdateDisplayInfo } from '../../redux/robot-update' @@ -50,7 +53,7 @@ export function ChooseRobotToRunProtocolSlideoutComponent( srcFiles, mostRecentAnalysis, } = storedProtocolData - + const [currentPage, setCurrentPage] = React.useState(1) const [selectedRobot, setSelectedRobot] = React.useState(null) const { trackCreateProtocolRunEvent } = useTrackCreateProtocolRunEvent( storedProtocolData, @@ -142,6 +145,7 @@ export function ChooseRobotToRunProtocolSlideoutComponent( return ( - - - {isCreatingRun ? ( - - ) : ( - t('shared:proceed_to_setup') - )} - + {currentPage === 1 ? ( + <> + + setCurrentPage(2)} + width="100%" + disabled={ + isCreatingRun || + selectedRobot == null || + isSelectedRobotOnDifferentSoftwareVersion + } + > + {t('shared:continue_to_param')} + + + ) : ( + + setCurrentPage(1)} width="50%"> + {t('shared:change_robot')} + + + {isCreatingRun ? ( + + ) : ( + t('shared:confirm_values') + )} + + + )} } selectedRobot={selectedRobot}