diff --git a/.github/workflows/app-test-build-deploy.yaml b/.github/workflows/app-test-build-deploy.yaml index d3c03e1f500..f0bfe7d8946 100644 --- a/.github/workflows/app-test-build-deploy.yaml +++ b/.github/workflows/app-test-build-deploy.yaml @@ -148,7 +148,7 @@ jobs: yarn config set cache-folder ${{ github.workspace }}/.yarn-cache make setup-js - name: 'test native(er) packages' - run: make test-js-internal tests="${{}matrix.shell}/src" cov_opts="--coverage=true" + run: make test-js-internal tests="${{matrix.shell}}/src" cov_opts="--coverage=true" - name: 'Upload coverage report' uses: 'codecov/codecov-action@v3' with: diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index b9403c12c68..75cb660462c 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -113,7 +113,7 @@ export interface GetRunsParams { } export interface Runs { - data: RunData[] + data: readonly RunData[] links: RunsLinks } @@ -128,12 +128,15 @@ export const RUN_ACTION_TYPE_PAUSE: 'pause' = 'pause' export const RUN_ACTION_TYPE_STOP: 'stop' = 'stop' export const RUN_ACTION_TYPE_RESUME_FROM_RECOVERY: 'resume-from-recovery' = 'resume-from-recovery' +export const RUN_ACTION_TYPE_RESUME_FROM_RECOVERY_ASSUMING_FALSE_POSITIVE: 'resume-from-recovery-assuming-false-positive' = + 'resume-from-recovery-assuming-false-positive' export type RunActionType = | typeof RUN_ACTION_TYPE_PLAY | typeof RUN_ACTION_TYPE_PAUSE | typeof RUN_ACTION_TYPE_STOP | typeof RUN_ACTION_TYPE_RESUME_FROM_RECOVERY + | typeof RUN_ACTION_TYPE_RESUME_FROM_RECOVERY_ASSUMING_FALSE_POSITIVE export interface RunAction { id: string @@ -175,7 +178,11 @@ export type RunError = RunCommandError * Error Policy */ -export type IfMatchType = 'ignoreAndContinue' | 'failRun' | 'waitForRecovery' +export type IfMatchType = + | 'assumeFalsePositiveAndContinue' + | 'ignoreAndContinue' + | 'failRun' + | 'waitForRecovery' export interface ErrorRecoveryPolicy { policyRules: Array<{ diff --git a/api/docs/v2/new_examples.rst b/api/docs/v2/new_examples.rst index 28490e03135..1aae3b633d0 100644 --- a/api/docs/v2/new_examples.rst +++ b/api/docs/v2/new_examples.rst @@ -383,7 +383,7 @@ Opentrons electronic pipettes can do some things that a human cannot do with a p location=3) p300 = protocol.load_instrument( instrument_name="p300_single", - mount="right", + mount="left", tip_racks=[tiprack_1]) p300.pick_up_tip() @@ -442,13 +442,13 @@ This protocol dispenses diluent to all wells of a Corning 96-well plate. Next, i source = reservoir.wells()[i] row = plate.rows()[i] - # transfer 30 µL of source to first well in column - pipette.transfer(30, source, row[0], mix_after=(3, 25)) + # transfer 30 µL of source to first well in column + pipette.transfer(30, source, row[0], mix_after=(3, 25)) - # dilute the sample down the column - pipette.transfer( - 30, row[:11], row[1:], - mix_after=(3, 25)) + # dilute the sample down the column + pipette.transfer( + 30, row[:11], row[1:], + mix_after=(3, 25)) .. tab:: OT-2 @@ -474,7 +474,7 @@ This protocol dispenses diluent to all wells of a Corning 96-well plate. Next, i location=4) p300 = protocol.load_instrument( instrument_name="p300_single", - mount="right", + mount="left", tip_racks=[tiprack_1, tiprack_2]) # Dispense diluent p300.distribute(50, reservoir["A12"], plate.wells()) @@ -483,16 +483,15 @@ This protocol dispenses diluent to all wells of a Corning 96-well plate. Next, i for i in range(8): # save the source well and destination column to variables source = reservoir.wells()[i] - source = reservoir.wells()[i] row = plate.rows()[i] - # transfer 30 µL of source to first well in column - p300.transfer(30, source, row[0], mix_after=(3, 25)) + # transfer 30 µL of source to first well in column + p300.transfer(30, source, row[0], mix_after=(3, 25)) - # dilute the sample down the column - p300.transfer( - 30, row[:11], row[1:], - mix_after=(3, 25)) + # dilute the sample down the column + p300.transfer( + 30, row[:11], row[1:], + mix_after=(3, 25)) Notice here how the code sample loops through the rows and uses slicing to distribute the diluent. For information about these features, see the Loops and Air Gaps examples above. See also, the :ref:`tutorial-commands` section of the Tutorial. diff --git a/api/src/opentrons/hardware_control/api.py b/api/src/opentrons/hardware_control/api.py index 909a50a3d8c..ec019ef2f1d 100644 --- a/api/src/opentrons/hardware_control/api.py +++ b/api/src/opentrons/hardware_control/api.py @@ -1189,11 +1189,6 @@ async def tip_pickup_moves( await self.retract(mount, spec.retract_target) - def cache_tip(self, mount: top_types.Mount, tip_length: float) -> None: - instrument = self.get_pipette(mount) - instrument.add_tip(tip_length=tip_length) - instrument.set_current_volume(0) - async def pick_up_tip( self, mount: top_types.Mount, diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py index 907788d6dda..c1389ea6a5b 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py @@ -430,6 +430,14 @@ def add_tip(self, mount: MountType, tip_length: float) -> None: f"attach tip called while tip already attached to {instr}" ) + def cache_tip(self, mount: MountType, tip_length: float) -> None: + instrument = self.get_pipette(mount) + if instrument.has_tip: + # instrument.add_tip() would raise an AssertionError if we tried to overwrite an existing tip. + instrument.remove_tip() + instrument.add_tip(tip_length=tip_length) + instrument.set_current_volume(0) + def remove_tip(self, mount: MountType) -> None: instr = self._attached_instruments[mount] attached = self.attached_instruments diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py index 9f44f7b0ab8..f64078fcbff 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py @@ -440,6 +440,14 @@ def add_tip(self, mount: OT3Mount, tip_length: float) -> None: "attach tip called while tip already attached to {instr}" ) + def cache_tip(self, mount: OT3Mount, tip_length: float) -> None: + instrument = self.get_pipette(mount) + if instrument.has_tip: + # instrument.add_tip() would raise an AssertionError if we tried to overwrite an existing tip. + instrument.remove_tip() + instrument.add_tip(tip_length=tip_length) + instrument.set_current_volume(0) + def remove_tip(self, mount: OT3Mount) -> None: instr = self._attached_instruments[mount] attached = self.attached_instruments diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 856b755565c..f90a0a539dc 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -2236,15 +2236,6 @@ async def _tip_motor_action( ) await self.home_gear_motors() - def cache_tip( - self, mount: Union[top_types.Mount, OT3Mount], tip_length: float - ) -> None: - realmount = OT3Mount.from_mount(mount) - instrument = self._pipette_handler.get_pipette(realmount) - - instrument.add_tip(tip_length=tip_length) - instrument.set_current_volume(0) - async def pick_up_tip( self, mount: Union[top_types.Mount, OT3Mount], @@ -2613,6 +2604,11 @@ def add_tip( ) -> None: self._pipette_handler.add_tip(OT3Mount.from_mount(mount), tip_length) + def cache_tip( + self, mount: Union[top_types.Mount, OT3Mount], tip_length: float + ) -> None: + self._pipette_handler.cache_tip(OT3Mount.from_mount(mount), tip_length) + def remove_tip(self, mount: Union[top_types.Mount, OT3Mount]) -> None: self._pipette_handler.remove_tip(OT3Mount.from_mount(mount)) diff --git a/api/src/opentrons/hardware_control/protocols/instrument_configurer.py b/api/src/opentrons/hardware_control/protocols/instrument_configurer.py index c1292620b74..5cd85716e36 100644 --- a/api/src/opentrons/hardware_control/protocols/instrument_configurer.py +++ b/api/src/opentrons/hardware_control/protocols/instrument_configurer.py @@ -142,17 +142,24 @@ def get_instrument_max_height( """ ... - # todo(mm, 2024-10-17): Consider deleting this in favor of cache_tip(), which is - # the same except for `assert`s, if we can do so without breaking anything. + # todo(mm, 2024-10-17): Consider deleting this in favor of cache_tip() + # if we can do so without breaking anything. def add_tip(self, mount: MountArgType, tip_length: float) -> None: """Inform the hardware that a tip is now attached to a pipette. + If a tip is already attached, this no-ops. + This changes the critical point of the pipette to make sure that the end of the tip is what moves around, and allows liquid handling. """ ... def cache_tip(self, mount: MountArgType, tip_length: float) -> None: + """Inform the hardware that a tip is now attached to a pipette. + + This is like `add_tip()`, except that if a tip is already attached, + this replaces it instead of no-opping. + """ ... def remove_tip(self, mount: MountArgType) -> None: diff --git a/api/src/opentrons/protocol_engine/execution/hardware_stopper.py b/api/src/opentrons/protocol_engine/execution/hardware_stopper.py index 28c310acd70..24055f6b03b 100644 --- a/api/src/opentrons/protocol_engine/execution/hardware_stopper.py +++ b/api/src/opentrons/protocol_engine/execution/hardware_stopper.py @@ -78,7 +78,9 @@ async def _drop_tip(self) -> None: try: if self._state_store.labware.get_fixed_trash_id() == FIXED_TRASH_ID: # OT-2 and Flex 2.15 protocols will default to the Fixed Trash Labware - await self._tip_handler.add_tip(pipette_id=pipette_id, tip=tip) + await self._tip_handler.cache_tip( + pipette_id=pipette_id, tip=tip + ) await self._movement_handler.move_to_well( pipette_id=pipette_id, labware_id=FIXED_TRASH_ID, @@ -90,7 +92,9 @@ async def _drop_tip(self) -> None: ) elif self._state_store.config.robot_type == "OT-2 Standard": # API 2.16 and above OT2 protocols use addressable areas - await self._tip_handler.add_tip(pipette_id=pipette_id, tip=tip) + await self._tip_handler.cache_tip( + pipette_id=pipette_id, tip=tip + ) await self._movement_handler.move_to_addressable_area( pipette_id=pipette_id, addressable_area_name="fixedTrash", diff --git a/api/src/opentrons/protocol_engine/execution/tip_handler.py b/api/src/opentrons/protocol_engine/execution/tip_handler.py index 0fe2462ee5e..a963dd9abac 100644 --- a/api/src/opentrons/protocol_engine/execution/tip_handler.py +++ b/api/src/opentrons/protocol_engine/execution/tip_handler.py @@ -83,7 +83,7 @@ async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: TipAttachedError """ - async def add_tip(self, pipette_id: str, tip: TipGeometry) -> None: + async def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: """Tell the Hardware API that a tip is attached.""" async def get_tip_presence(self, pipette_id: str) -> TipPresenceStatus: @@ -234,6 +234,11 @@ async def pick_up_tip( labware_definition=self._state_view.labware.get_definition(labware_id), nominal_fallback=nominal_tip_geometry.length, ) + tip_geometry = TipGeometry( + length=actual_tip_length, + diameter=nominal_tip_geometry.diameter, + volume=nominal_tip_geometry.volume, + ) await self._hardware_api.tip_pickup_moves( mount=hw_mount, presses=None, increment=None @@ -241,24 +246,11 @@ async def pick_up_tip( # Allow TipNotAttachedError to propagate. await self.verify_tip_presence(pipette_id, TipPresenceStatus.PRESENT) - self._hardware_api.cache_tip(hw_mount, actual_tip_length) - await self._hardware_api.prepare_for_aspirate(hw_mount) - - self._hardware_api.set_current_tiprack_diameter( - mount=hw_mount, - tiprack_diameter=nominal_tip_geometry.diameter, - ) + await self.cache_tip(pipette_id, tip_geometry) - self._hardware_api.set_working_volume( - mount=hw_mount, - tip_volume=nominal_tip_geometry.volume, - ) + await self._hardware_api.prepare_for_aspirate(hw_mount) - return TipGeometry( - length=actual_tip_length, - diameter=nominal_tip_geometry.diameter, - volume=nominal_tip_geometry.volume, - ) + return tip_geometry async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: """See documentation on abstract base class.""" @@ -279,11 +271,11 @@ async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: self._hardware_api.remove_tip(hw_mount) self._hardware_api.set_current_tiprack_diameter(hw_mount, 0) - async def add_tip(self, pipette_id: str, tip: TipGeometry) -> None: + async def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: """See documentation on abstract base class.""" hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() - self._hardware_api.add_tip(mount=hw_mount, tip_length=tip.length) + self._hardware_api.cache_tip(mount=hw_mount, tip_length=tip.length) self._hardware_api.set_current_tiprack_diameter( mount=hw_mount, @@ -422,12 +414,12 @@ async def drop_tip( expected_has_tip=True, ) - async def add_tip(self, pipette_id: str, tip: TipGeometry) -> None: + async def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: """Add a tip using a virtual pipette. This should not be called when using virtual pipettes. """ - assert False, "TipHandler.add_tip should not be used with virtual pipettes" + assert False, "TipHandler.cache_tip should not be used with virtual pipettes" async def verify_tip_presence( self, diff --git a/api/src/opentrons/util/logging_config.py b/api/src/opentrons/util/logging_config.py index 944f4d3d5ed..0a36468f3bc 100644 --- a/api/src/opentrons/util/logging_config.py +++ b/api/src/opentrons/util/logging_config.py @@ -5,7 +5,11 @@ from opentrons.config import CONFIG, ARCHITECTURE, SystemArchitecture -from opentrons_hardware.sensors import SENSOR_LOG_NAME +if ARCHITECTURE is SystemArchitecture.BUILDROOT: + from opentrons_hardware.sensors import SENSOR_LOG_NAME +else: + # we don't use the sensor log on ot2 or host + SENSOR_LOG_NAME = "unused" def _host_config(level_value: int) -> Dict[str, Any]: @@ -125,7 +129,7 @@ def _buildroot_config(level_value: int) -> Dict[str, Any]: }, "sensor": { "class": "logging.handlers.RotatingFileHandler", - "formatter": "basic", + "formatter": "message_only", "filename": sensor_log_filename, "maxBytes": 1000000, "level": logging.DEBUG, diff --git a/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py b/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py index 4c3e629d2ed..d6c69d0b170 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py +++ b/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py @@ -158,7 +158,7 @@ async def test_hardware_stopping_sequence_no_tip_drop( decoy.verify(await hardware_api.stop(home_after=False), times=1) decoy.verify( - await mock_tip_handler.add_tip( + await mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), @@ -181,7 +181,7 @@ async def test_hardware_stopping_sequence_no_pipette( ) decoy.when( - await mock_tip_handler.add_tip( + await mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), @@ -271,7 +271,7 @@ async def test_hardware_stopping_sequence_with_fixed_trash( await movement.home( axes=[MotorAxis.X, MotorAxis.Y, MotorAxis.LEFT_Z, MotorAxis.RIGHT_Z] ), - await mock_tip_handler.add_tip( + await mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), @@ -320,7 +320,7 @@ async def test_hardware_stopping_sequence_with_OT2_addressable_area( await movement.home( axes=[MotorAxis.X, MotorAxis.Y, MotorAxis.LEFT_Z, MotorAxis.RIGHT_Z] ), - await mock_tip_handler.add_tip( + await mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), diff --git a/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py b/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py index af5c49faf6a..8ddb8840597 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py @@ -289,10 +289,10 @@ async def test_add_tip( MountType.LEFT ) - await subject.add_tip(pipette_id="pipette-id", tip=tip) + await subject.cache_tip(pipette_id="pipette-id", tip=tip) decoy.verify( - mock_hardware_api.add_tip(mount=Mount.LEFT, tip_length=50), + mock_hardware_api.cache_tip(mount=Mount.LEFT, tip_length=50), mock_hardware_api.set_current_tiprack_diameter( mount=Mount.LEFT, tiprack_diameter=5, diff --git a/app/src/assets/localization/en/pipette_wizard_flows.json b/app/src/assets/localization/en/pipette_wizard_flows.json index 53ae23d07e2..78dc2b852a6 100644 --- a/app/src/assets/localization/en/pipette_wizard_flows.json +++ b/app/src/assets/localization/en/pipette_wizard_flows.json @@ -49,7 +49,7 @@ "install_probe": "Take the calibration probe from its storage location. Ensure its collar is unlocked. Push the pipette ejector up and press the probe firmly onto the {{location}} pipette nozzle. Twist the collar to lock the probe. Test that the probe is secure by gently pulling it back and forth.", "loose_detach": "Loosen screws and detach ", "move_gantry_to_front": "Move gantry to front", - "must_detach_mounting_plate": "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.", + "must_detach_mounting_plate": "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.", "name_and_volume_detected": "{{name}} Pipette Detected", "next": "next", "ninety_six_channel": "{{ninetySix}} pipette", diff --git a/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts b/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts index 597f2129e42..39f6d5e59b8 100644 --- a/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts +++ b/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts @@ -6,11 +6,13 @@ export function useHistoricRunDetails( hostOverride?: HostConfig | null ): RunData[] { const { data: allHistoricRuns } = useNotifyAllRunsQuery({}, {}, hostOverride) - return allHistoricRuns == null ? [] - : allHistoricRuns.data.sort( - (a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ) + : // TODO(sf): figure out why .toSorted() doesn't work in vitest + allHistoricRuns.data + .map(t => t) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) } diff --git a/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx b/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx index 3b258d2c199..daa1fc10251 100644 --- a/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx +++ b/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx @@ -52,9 +52,9 @@ describe('RecentProtocolRuns', () => { }) it('renders table headers if there are runs', () => { vi.mocked(useIsRobotViewable).mockReturnValue(true) - vi.mocked(useNotifyAllRunsQuery).mockReturnValue({ + vi.mocked(useNotifyAllRunsQuery).mockReturnValue(({ data: { - data: [ + data: ([ { createdAt: '2022-05-04T18:24:40.833862+00:00', current: false, @@ -62,9 +62,9 @@ describe('RecentProtocolRuns', () => { protocolId: 'test_protocol_id', status: 'succeeded', }, - ], + ] as any) as Runs, }, - } as UseQueryResult) + } as any) as UseQueryResult) render() screen.getByText('Recent Protocol Runs') screen.getByText('Run') 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/ODD/ProtocolDashboard/index.tsx b/app/src/pages/ODD/ProtocolDashboard/index.tsx index ba2efa23949..de775795ded 100644 --- a/app/src/pages/ODD/ProtocolDashboard/index.tsx +++ b/app/src/pages/ODD/ProtocolDashboard/index.tsx @@ -98,7 +98,7 @@ export function ProtocolDashboard(): JSX.Element { } const runData = runs.data?.data != null ? runs.data?.data : [] - const allRunsNewestFirst = runData.sort( + const allRunsNewestFirst = runData.toSorted( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ) const sortedProtocols = sortProtocols( diff --git a/app/src/pages/ODD/RobotDashboard/index.tsx b/app/src/pages/ODD/RobotDashboard/index.tsx index b699f6ab569..aa255717388 100644 --- a/app/src/pages/ODD/RobotDashboard/index.tsx +++ b/app/src/pages/ODD/RobotDashboard/index.tsx @@ -41,8 +41,7 @@ export function RobotDashboard(): JSX.Element { ) const recentRunsOfUniqueProtocols = (allRunsQueryData?.data ?? []) - .reverse() // newest runs first - .reduce((acc, run) => { + .reduceRight((acc, run) => { if ( acc.some(collectedRun => collectedRun.protocolId === run.protocolId) ) { diff --git a/app/src/redux/config/__tests__/config.test.ts b/app/src/redux/config/__tests__/config.test.ts index d99eb95c36e..bf5b4e98004 100644 --- a/app/src/redux/config/__tests__/config.test.ts +++ b/app/src/redux/config/__tests__/config.test.ts @@ -28,6 +28,7 @@ describe('config', () => { expect(Cfg.configInitialized(state.config as any)).toEqual({ type: 'config:INITIALIZED', payload: { config: state.config }, + meta: { shell: true }, }) }) @@ -35,6 +36,7 @@ describe('config', () => { expect(Cfg.configValueUpdated('foo.bar', false)).toEqual({ type: 'config:VALUE_UPDATED', payload: { path: 'foo.bar', value: false }, + meta: { shell: true }, }) }) diff --git a/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx b/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx index 12b494ea894..dcedecaa9f8 100644 --- a/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx +++ b/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx @@ -8,15 +8,14 @@ import { SPACING } from '../../../ui-style-constants' interface ListItemDescriptorProps { type: 'default' | 'large' - description: JSX.Element | string - content: JSX.Element | string - isInSlideout?: boolean + description: JSX.Element + content: JSX.Element } export const ListItemDescriptor = ( props: ListItemDescriptorProps ): JSX.Element => { - const { description, content, type, isInSlideout = false } = props + const { description, content, type } = props return ( - - {description} - - {content} + {description} + {content} ) } 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/index.ts b/components/src/organisms/index.ts index 2aee78e806c..8775f49abc3 100644 --- a/components/src/organisms/index.ts +++ b/components/src/organisms/index.ts @@ -1,2 +1,3 @@ export * from './DeckLabelSet' +export * from './EndUserAgreementFooter' export * from './Toolbox' 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 1d69984c345..6bf5b633936 100644 --- a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json +++ b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json @@ -10,6 +10,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", diff --git a/opentrons-ai-client/src/molecules/Footer/index.tsx b/opentrons-ai-client/src/molecules/Footer/index.tsx index 5ef44bc733f..c8bbc4054fd 100644 --- a/opentrons-ai-client/src/molecules/Footer/index.tsx +++ b/opentrons-ai-client/src/molecules/Footer/index.tsx @@ -44,7 +44,7 @@ export function Footer(): JSX.Element { > ({ + 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_image_alt')} + + + {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 74f79c437b9..908f46539be 100644 --- a/protocol-designer/src/ProtocolRoutes.tsx +++ b/protocol-designer/src/ProtocolRoutes.tsx @@ -11,6 +11,7 @@ import { Kitchen, FileUploadMessagesModal, LabwareUploadModal, + GateModal, } from './organisms' import type { RouteProps } from './types' @@ -56,12 +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 66a0a406a47..248d70a0aec 100644 --- a/protocol-designer/src/assets/localization/en/alert.json +++ b/protocol-designer/src/assets/localization/en/alert.json @@ -219,6 +219,10 @@ "TIPRACK_IN_WASTE_CHUTE_HAS_TIPS": { "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..1f3f4bc9e34 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", @@ -207,20 +209,20 @@ }, "thermocyclerState": { "block": { - "engage": "Engage block temperature", + "engage": "Block temperature", "label": "Thermocycler block", "temperature": "Block temperature", - "toggleOff": "Deactivated", - "toggleOn": "Active", + "toggleOff": "Deactivate", + "toggleOn": "Activate", "valid_range": "Valid range between 4 and 96ºC" }, "ending_hold": "Ending hold", "lid": { - "engage": "Engage lid temperature", + "engage": "Lid temperature", "label": "Lid", "temperature": "Lid temperature", - "toggleOff": "Deactivated", - "toggleOn": "Active", + "toggleOff": "Deactivate", + "toggleOn": "Activate", "valid_range": "Valid range between 37 and 110ºC" }, "lidPosition": { diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json index b2f00290059..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", diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json index 499bec09040..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", @@ -102,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", @@ -116,6 +120,7 @@ "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", @@ -129,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/assets/localization/en/starting_deck_state.json b/protocol-designer/src/assets/localization/en/starting_deck_state.json index 1e284990c62..5fc398de084 100644 --- a/protocol-designer/src/assets/localization/en/starting_deck_state.json +++ b/protocol-designer/src/assets/localization/en/starting_deck_state.json @@ -8,10 +8,14 @@ "add_liquid": "Add liquid", "add_module": "Add a module", "add_rest": "Add labware and liquids to complete deck setup", + "alter_pause": "You may also need to alter the time you pause while your magnet is engaged.", "aluminumBlock": "Aluminum block", "clear_labware": "Clear labware", "clear_slot": "Clear slot", "clear": "Clear", + "command_click_to_multi_select": "Command + Click for multi-select", + "convert_gen1_to_gen2": "To convert engage heights from GEN1 to GEN2, divide your engage height by 2.", + "convert_gen2_to_gen1": "To convert engage heights from GEN2 to GEN1, multiply your engage height by 2.", "custom": "Custom labware definitions", "customize_slot": "Customize slot", "deck_hardware": "Deck hardware", @@ -25,9 +29,11 @@ "edit_protocol": "Edit protocol", "edit_slot": "Edit slot", "edit": "Edit", + "gen1_gen2_different_units": "Switching between GEN1 and GEN2 Magnetic Modules will clear all non-default engage heights from existing magnet steps in your protocol. GEN1 and GEN2 Magnetic Modules do not use the same units.", "heater_shaker_adjacent_to": "A module is adjacent to this slot. The Heater-Shaker cannot be placed next to a module", "heater_shaker_adjacent": "A Heater-Shaker is adjacent to this slot. Modules cannot be placed next to a Heater-Shaker", "heater_shaker_trash": "The heater-shaker cannot be next to the trash bin", + "here": "here.", "labware": "Labware", "liquids": "Liquids", "no_offdeck_labware": "No off-deck labware added", @@ -37,8 +43,8 @@ "onDeck": "On deck", "one_item": "No more than 1 {{hardware}} allowed on the deck at one time", "only_display_rec": "Only display recommended labware", - "command_click_to_multi_select": "Command + Click for multi-select", "protocol_starting_deck": "Protocol starting deck", + "read_more_gen1_gen2": "Read more about the differences between GEN1 and GEN2 Magnetic Modules", "rename_lab": "Rename labware", "reservoir": "Reservoir", "shift_click_to_select_all": "Shift + Click to select all", 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/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/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..05e3883e575 100644 --- a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx +++ b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx @@ -1,6 +1,8 @@ import { ALIGN_CENTER, + Btn, COLORS, + Check, DIRECTION_COLUMN, Flex, JUSTIFY_SPACE_BETWEEN, @@ -18,12 +20,12 @@ 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 + toggleElement?: 'toggle' | 'checkbox' } export function ToggleExpandStepFormField( props: ToggleExpandStepFormFieldProps @@ -38,19 +40,29 @@ export function ToggleExpandStepFormField( toggleUpdateValue, toggleValue, caption, - islabel, + toggleElement = 'toggle', ...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) } } + const label = isSelected ? onLabel : offLabel ?? null return ( {title} - - {islabel ? ( + + {label != null ? ( - {isSelected ? onLabel : offLabel} + {isSelected ? onLabel : offLabel ?? null} ) : null} - - { - onToggleUpdateValue() - }} - label={isSelected ? onLabel : offLabel} - toggledOn={isSelected} - /> + {toggleElement === 'toggle' ? ( + + ) : ( + + + + )} 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/FormAlerts.tsx b/protocol-designer/src/organisms/Alerts/FormAlerts.tsx index 5aa2833ee60..290295305af 100644 --- a/protocol-designer/src/organisms/Alerts/FormAlerts.tsx +++ b/protocol-designer/src/organisms/Alerts/FormAlerts.tsx @@ -32,7 +32,7 @@ interface FormAlertsProps { dirtyFields?: StepFieldName[] } -function FormAlertsComponent(props: FormAlertsProps): JSX.Element { +function FormAlertsComponent(props: FormAlertsProps): JSX.Element | null { const { focusedField, dirtyFields } = props const { t } = useTranslation('alert') const dispatch = useDispatch() @@ -95,7 +95,7 @@ function FormAlertsComponent(props: FormAlertsProps): JSX.Element { } const makeAlert: MakeAlert = (alertType, data, key) => ( - + {data.title} - - {data.description} - + {data.description != null ? ( + + {data.description} + + ) : null} @@ -151,15 +154,19 @@ function FormAlertsComponent(props: FormAlertsProps): JSX.Element { ) } } - return ( - + return [...formErrors, ...formWarnings, ...timelineWarnings].length > 0 ? ( + {formErrors.map((error, key) => makeAlert('error', error, key))} {formWarnings.map((warning, key) => makeAlert('warning', warning, key))} {timelineWarnings.map((warning, key) => makeAlert('warning', warning, key) )} - ) + ) : null } export const FormAlerts = memo(FormAlertsComponent) diff --git a/protocol-designer/src/organisms/Alerts/TimelineAlerts.tsx b/protocol-designer/src/organisms/Alerts/TimelineAlerts.tsx index 4df442b44f7..fbc36ddac76 100644 --- a/protocol-designer/src/organisms/Alerts/TimelineAlerts.tsx +++ b/protocol-designer/src/organisms/Alerts/TimelineAlerts.tsx @@ -11,10 +11,12 @@ import { } from '@opentrons/components' import { getRobotStateTimeline } from '../../file-data/selectors' import { ErrorContents } from './ErrorContents' + +import type { StyleProps } from '@opentrons/components' import type { CommandCreatorError } from '@opentrons/step-generation' import type { MakeAlert } from './types' -function TimelineAlertsComponent(): JSX.Element { +function TimelineAlertsComponent(props: StyleProps): JSX.Element | null { const { t } = useTranslation('alert') const timeline = useSelector(getRobotStateTimeline) @@ -26,6 +28,10 @@ function TimelineAlertsComponent(): JSX.Element { }) ) + if (timelineErrors.length === 0) { + return null + } + const makeAlert: MakeAlert = (alertType, data, key) => ( {timelineErrors.map((error, key) => makeAlert('error', error, key))} + + {timelineErrors.map((error, key) => makeAlert('error', error, key))} + ) } diff --git a/protocol-designer/src/organisms/BlockingHintModal/__tests__/BlockingHintModal.test.tsx b/protocol-designer/src/organisms/BlockingHintModal/__tests__/BlockingHintModal.test.tsx new file mode 100644 index 00000000000..b92295ff060 --- /dev/null +++ b/protocol-designer/src/organisms/BlockingHintModal/__tests__/BlockingHintModal.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { i18n } from '../../../assets/localization' +import { renderWithProviders } from '../../../__testing-utils__' +import { removeHint } from '../../../tutorial/actions' +import { BlockingHintModal } from '..' +import type { ComponentProps } from 'react' + +vi.mock('../../../tutorial/actions') + +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('BlockingHintModal', () => { + let props: ComponentProps + + beforeEach(() => { + props = { + content:
mock content
, + handleCancel: vi.fn(), + handleContinue: vi.fn(), + hintKey: 'change_magnet_module_model', + } + }) + it('renders the hint with buttons and checkbox', () => { + render(props) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(props.handleCancel).toHaveBeenCalled() + expect(vi.mocked(removeHint)).toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(props.handleContinue).toHaveBeenCalled() + expect(vi.mocked(removeHint)).toHaveBeenCalled() + screen.getByText('mock content') + }) +}) diff --git a/protocol-designer/src/organisms/BlockingHintModal/index.tsx b/protocol-designer/src/organisms/BlockingHintModal/index.tsx new file mode 100644 index 00000000000..be33b06742f --- /dev/null +++ b/protocol-designer/src/organisms/BlockingHintModal/index.tsx @@ -0,0 +1,86 @@ +import { useCallback, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { + ALIGN_CENTER, + COLORS, + Check, + Flex, + JUSTIFY_SPACE_BETWEEN, + Modal, + PrimaryButton, + SPACING, + SecondaryButton, + StyledText, +} from '@opentrons/components' +import { actions } from '../../tutorial' +import { getMainPagePortalEl } from '../../components/portals/MainPageModalPortal' +import type { ReactNode } from 'react' +import type { HintKey } from '../../tutorial' + +export interface HintProps { + hintKey: HintKey + handleCancel: () => void + handleContinue: () => void + content: ReactNode +} + +export function BlockingHintModal(props: HintProps): JSX.Element { + const { content, hintKey, handleCancel, handleContinue } = props + const { t, i18n } = useTranslation(['alert', 'shared']) + const dispatch = useDispatch() + + const [rememberDismissal, setRememberDismissal] = useState(false) + + const toggleRememberDismissal = useCallback(() => { + setRememberDismissal(prevDismissal => !prevDismissal) + }, []) + + const onCancelClick = (): void => { + dispatch(actions.removeHint(hintKey, rememberDismissal)) + handleCancel() + } + + const onContinueClick = (): void => { + dispatch(actions.removeHint(hintKey, rememberDismissal)) + handleContinue() + } + + return createPortal( + + + + + {t('hint.dont_show_again')} + + + + + {t('shared:cancel')} + + + {i18n.format(t('shared:continue'), 'capitalize')} + + +
+ } + > + {content} + , + getMainPagePortalEl() + ) +} diff --git a/protocol-designer/src/organisms/BlockingHintModal/useBlockingHint.tsx b/protocol-designer/src/organisms/BlockingHintModal/useBlockingHint.tsx new file mode 100644 index 00000000000..19926e2d51b --- /dev/null +++ b/protocol-designer/src/organisms/BlockingHintModal/useBlockingHint.tsx @@ -0,0 +1,40 @@ +import { useSelector } from 'react-redux' +import { getDismissedHints } from '../../tutorial/selectors' +import { BlockingHintModal } from './index' +import type { HintKey } from '../../tutorial' + +export interface HintProps { + /** `enabled` should be a condition that the parent uses to toggle whether the hint should be active or not. + * If the hint is enabled but has been dismissed, it will automatically call `handleContinue` when enabled. + * useBlockingHint expects the parent to disable the hint on cancel/continue */ + enabled: boolean + hintKey: HintKey + content: React.ReactNode + handleCancel: () => void + handleContinue: () => void +} + +export const useBlockingHint = (args: HintProps): JSX.Element | null => { + const { enabled, hintKey, handleCancel, handleContinue, content } = args + const isDismissed = useSelector(getDismissedHints).includes(hintKey) + + if (isDismissed) { + if (enabled) { + handleContinue() + } + return null + } + + if (!enabled) { + return null + } + + return ( + + ) +} 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 6fa69307eef..14324594969 100644 --- a/protocol-designer/src/organisms/MaterialsListModal/index.tsx +++ b/protocol-designer/src/organisms/MaterialsListModal/index.tsx @@ -11,7 +11,6 @@ import { DIRECTION_ROW, Flex, InfoScreen, - JUSTIFY_SPACE_BETWEEN, LiquidIcon, ListItem, ListItemDescriptor, @@ -32,13 +31,14 @@ import { getInitialDeckSetup } from '../../step-forms/selectors' import { getTopPortalEl } from '../../components/portals/TopPortal' import { selectors as labwareIngredSelectors } from '../../labware-ingred/selectors' import { HandleEnter } from '../../atoms/HandleEnter' +import { LINE_CLAMP_TEXT_STYLE } from '../../atoms' import type { AdditionalEquipmentName } from '@opentrons/step-generation' import type { LabwareOnDeck, ModuleOnDeck } from '../../step-forms' import type { OrderedLiquids } from '../../labware-ingred/types' // ToDo (kk:09/04/2024) this should be removed when break-point is set up -const MODAL_MIN_WIDTH = '36.1875rem' +const MODAL_MIN_WIDTH = '37.125rem' export interface FixtureInList { name: AdditionalEquipmentName @@ -82,6 +82,7 @@ export function MaterialsListModal({ title={t('materials_list')} marginLeft="0rem" minWidth={MODAL_MIN_WIDTH} + childrenPadding={SPACING.spacing24} > @@ -95,13 +96,18 @@ export function MaterialsListModal({ - ) : ( - '' - ) + + {fixture.location != null ? ( + + ) : ( + '' + )} + } content={ + + + } content={ + + + + } + content={ + + {lw.def.metadata.displayName} + } - content={lw.def.metadata.displayName} />
) @@ -246,29 +262,31 @@ export function MaterialsListModal({ } else { return ( - - - - - {liquid.name ?? t('n/a')} - - - - + + + + {liquid.name ?? t('n/a')} + + + } + content={ - - + } + /> ) } diff --git a/protocol-designer/src/organisms/SlotInformation/index.tsx b/protocol-designer/src/organisms/SlotInformation/index.tsx index cd3550ed7d5..a945ebffcd7 100644 --- a/protocol-designer/src/organisms/SlotInformation/index.tsx +++ b/protocol-designer/src/organisms/SlotInformation/index.tsx @@ -1,4 +1,3 @@ -import type * as React from 'react' import { useTranslation } from 'react-i18next' import { useLocation } from 'react-router-dom' import { @@ -12,6 +11,7 @@ import { StyledText, TYPOGRAPHY, } from '@opentrons/components' +import type { FC } from 'react' import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import type { RobotType } from '@opentrons/shared-data' @@ -25,7 +25,7 @@ interface SlotInformationProps { fixtures?: string[] } -export const SlotInformation: React.FC = ({ +export const SlotInformation: FC = ({ location, robotType, liquids = [], @@ -50,10 +50,10 @@ export const SlotInformation: React.FC = ({ {liquids.length > 1 ? ( - + {liquids.join(', ')}} description={t('liquid')} /> @@ -115,18 +115,14 @@ function StackInfo({ title, stackInformation }: StackInfoProps): JSX.Element { - {stackInformation} - - ) : ( - t('none') - ) + + {stackInformation ?? t('none')} + } - description={title} + description={{title}} /> ) diff --git a/protocol-designer/src/organisms/index.ts b/protocol-designer/src/organisms/index.ts index dc07179d0da..0fd6e481e3e 100644 --- a/protocol-designer/src/organisms/index.ts +++ b/protocol-designer/src/organisms/index.ts @@ -1,11 +1,13 @@ export * from './Alerts' export * from './AnnouncementModal' export * from './AssignLiquidsModal' +export * from './BlockingHintModal' export * from './DefineLiquidsModal' export * from './EditInstrumentsModal' export * from './EditNickNameModal' export * from './EditProtocolMetadataModal' export * from './FileUploadMessagesModal/' +export * from './GateModal' export * from './IncompatibleTipsModal' export * from './Kitchen' export * from './LabwareUploadModal' diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/constants.ts b/protocol-designer/src/pages/CreateNewProtocolWizard/constants.ts index ae220daf450..6e762e48f0a 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/constants.ts +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/constants.ts @@ -8,7 +8,6 @@ import { MAGNETIC_MODULE_V1, MAGNETIC_MODULE_V2, OT2_ROBOT_TYPE, - SPAN7_8_10_11_SLOT, TEMPERATURE_MODULE_TYPE, TEMPERATURE_MODULE_V1, TEMPERATURE_MODULE_V2, @@ -132,7 +131,7 @@ export const DEFAULT_SLOT_MAP_FLEX: { } export const DEFAULT_SLOT_MAP_OT2: { [moduleType in ModuleType]?: string } = { - [THERMOCYCLER_MODULE_TYPE]: SPAN7_8_10_11_SLOT, + [THERMOCYCLER_MODULE_TYPE]: '7', [HEATERSHAKER_MODULE_TYPE]: '1', [MAGNETIC_MODULE_TYPE]: '1', [TEMPERATURE_MODULE_TYPE]: '3', diff --git a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx index e062fa4784d..6c000ad0428 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx @@ -17,6 +17,9 @@ import { FLEX_ROBOT_TYPE, getModuleDisplayName, getModuleType, + MAGNETIC_MODULE_TYPE, + MAGNETIC_MODULE_V1, + MAGNETIC_MODULE_V2, OT2_ROBOT_TYPE, } from '@opentrons/shared-data' @@ -38,12 +41,15 @@ import { selectZoomedIntoSlot, } from '../../../labware-ingred/actions' import { getEnableAbsorbanceReader } from '../../../feature-flags/selectors' +import { useBlockingHint } from '../../../organisms/BlockingHintModal/useBlockingHint' import { selectors } from '../../../labware-ingred/selectors' import { useKitchen } from '../../../organisms/Kitchen/hooks' +import { getDismissedHints } from '../../../tutorial/selectors' import { createContainerAboveModule } from '../../../step-forms/actions/thunks' import { FIXTURES, MOAM_MODELS } from './constants' import { getSlotInformation } from '../utils' import { getModuleModelsBySlot, getDeckErrors } from './utils' +import { MagnetModuleChangeContent } from './MagnetModuleChangeContent' import { LabwareTools } from './LabwareTools' import type { ModuleModel } from '@opentrons/shared-data' @@ -65,6 +71,9 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { const { makeSnackbar } = useKitchen() const selectedSlotInfo = useSelector(selectors.getZoomedInSlotInfo) const robotType = useSelector(getRobotType) + const isDismissedModuleHint = useSelector(getDismissedHints).includes( + 'change_magnet_module_model' + ) const dispatch = useDispatch>() const enableAbsorbanceReader = useSelector(getEnableAbsorbanceReader) const deckSetup = useSelector(getDeckSetupForActiveItem) @@ -76,6 +85,9 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { selectedNestedLabwareDefUri, } = selectedSlotInfo const { slot, cutout } = selectedSlot + const [changeModuleWarningInfo, displayModuleWarning] = useState( + false + ) const [selectedHardware, setSelectedHardware] = useState< ModuleModel | Fixture | null >(null) @@ -95,6 +107,34 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { const [tab, setTab] = useState<'hardware' | 'labware'>( moduleModels?.length === 0 || slot === 'offDeck' ? 'labware' : 'hardware' ) + const hasMagneticModule = Object.values(deckSetup.modules).some( + module => module.type === MAGNETIC_MODULE_TYPE + ) + const moduleOnSlotIsMagneticModuleV1 = + Object.values(deckSetup.modules).find(module => module.slot === slot) + ?.model === MAGNETIC_MODULE_V1 + + const changeModuleWarning = useBlockingHint({ + hintKey: 'change_magnet_module_model', + handleCancel: () => { + displayModuleWarning(false) + }, + handleContinue: () => { + setSelectedHardware( + moduleOnSlotIsMagneticModuleV1 ? MAGNETIC_MODULE_V2 : MAGNETIC_MODULE_V1 + ) + dispatch( + selectModule({ + moduleModel: moduleOnSlotIsMagneticModuleV1 + ? MAGNETIC_MODULE_V2 + : MAGNETIC_MODULE_V1, + }) + ) + displayModuleWarning(false) + }, + content: , + enabled: changeModuleWarningInfo, + }) if (slot == null || (onDeckProps == null && slot !== 'offDeck')) { return null @@ -236,194 +276,211 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { dispatch(selectZoomedIntoSlot({ slot: null, cutout: null })) onCloseClick() } - return ( - - - - {t('customize_slot')} + <> + {changeModuleWarning} + + + + {t('customize_slot')} + + + } + closeButton={ + + {t('clear')} - - } - closeButton={ - {t('clear')} - } - onCloseClick={() => { - handleClear() - handleResetToolbox() - }} - onConfirmClick={() => { - handleConfirm() - }} - confirmButtonText={t('done')} - > - - {slot !== 'offDeck' ? : null} - {tab === 'hardware' ? ( - - - - {t('add_module')} - - - {moduleModels?.map(model => { - const modelSomewhereOnDeck = Object.values( - deckSetupModules - ).filter( - module => module.model === model && module.slot !== slot - ) - const typeSomewhereOnDeck = Object.values( - deckSetupModules - ).filter( - module => - module.type === getModuleType(model) && - module.slot !== slot - ) - const moamModels = MOAM_MODELS - - const collisionError = getDeckErrors({ - modules: deckSetupModules, - selectedSlot: slot, - selectedModel: model, - labware: deckSetupLabware, - robotType, - }) - - return ( - { - if (onDeckProps?.setHoveredModule != null) { - onDeckProps.setHoveredModule(null) - } - }} - setHovered={() => { - if (onDeckProps?.setHoveredModule != null) { - onDeckProps.setHoveredFixture(null) - onDeckProps.setHoveredModule(model) - } - }} - largeDesktopBorderRadius - buttonLabel={ - - - - {getModuleDisplayName(model)} - - - } - key={`${model}_${slot}`} - buttonValue={model} - onChange={() => { - if ( - modelSomewhereOnDeck.length === 1 && - !moamModels.includes(model) && - robotType === FLEX_ROBOT_TYPE - ) { - makeSnackbar( - t('one_item', { - hardware: getModuleDisplayName(model), - }) as string - ) - } else if ( - typeSomewhereOnDeck.length > 0 && - robotType === OT2_ROBOT_TYPE - ) { - makeSnackbar( - t('one_item', { - hardware: t( - `shared:${getModuleType(model).toLowerCase()}` - ), - }) as string - ) - } else if (collisionError != null) { - makeSnackbar(t(`${collisionError}`) as string) - } else { - setSelectedHardware(model) - dispatch(selectFixture({ fixture: null })) - dispatch(selectModule({ moduleModel: model })) - dispatch(selectLabware({ labwareDefUri: null })) - dispatch( - selectNestedLabware({ nestedLabwareDefUri: null }) - ) - } - }} - isSelected={model === selectedHardware} - /> - ) - })} - - - {robotType === OT2_ROBOT_TYPE || fixtures.length === 0 ? null : ( + } + onCloseClick={() => { + handleClear() + handleResetToolbox() + }} + onConfirmClick={() => { + handleConfirm() + }} + confirmButtonText={t('done')} + > + + {slot !== 'offDeck' ? ( + + ) : null} + {tab === 'hardware' ? ( + - {t('add_fixture')} + {t('add_module')} - {fixtures.map(fixture => ( - { - if (onDeckProps?.setHoveredFixture != null) { - onDeckProps.setHoveredFixture(null) - } - }} - setHovered={() => { - if (onDeckProps?.setHoveredFixture != null) { - onDeckProps.setHoveredModule(null) - onDeckProps.setHoveredFixture(fixture) - } - }} - largeDesktopBorderRadius - buttonLabel={t(`shared:${fixture}`)} - key={`${fixture}_${slot}`} - buttonValue={fixture} - onChange={() => { - // delete this when multiple trash bins are supported - if (fixture === 'trashBin' && hasTrash) { - makeSnackbar( - t('one_item', { - hardware: t('shared:trashBin'), - }) as string - ) - } else { - setSelectedHardware(fixture) - dispatch(selectModule({ moduleModel: null })) - dispatch(selectFixture({ fixture })) - dispatch(selectLabware({ labwareDefUri: null })) - dispatch( - selectNestedLabware({ nestedLabwareDefUri: null }) - ) + {moduleModels?.map(model => { + const modelSomewhereOnDeck = Object.values( + deckSetupModules + ).filter( + module => module.model === model && module.slot !== slot + ) + const typeSomewhereOnDeck = Object.values( + deckSetupModules + ).filter( + module => + module.type === getModuleType(model) && + module.slot !== slot + ) + const moamModels = MOAM_MODELS + + const collisionError = getDeckErrors({ + modules: deckSetupModules, + selectedSlot: slot, + selectedModel: model, + labware: deckSetupLabware, + robotType, + }) + + return ( + { + if (onDeckProps?.setHoveredModule != null) { + onDeckProps.setHoveredModule(null) + } + }} + setHovered={() => { + if (onDeckProps?.setHoveredModule != null) { + onDeckProps.setHoveredModule(model) + } + }} + largeDesktopBorderRadius + buttonLabel={ + + + + {getModuleDisplayName(model)} + + } - }} - isSelected={fixture === selectedHardware} - /> - ))} + key={`${model}_${slot}`} + buttonValue={model} + onChange={() => { + if ( + modelSomewhereOnDeck.length === 1 && + !moamModels.includes(model) && + robotType === FLEX_ROBOT_TYPE + ) { + makeSnackbar( + t('one_item', { + hardware: getModuleDisplayName(model), + }) as string + ) + } else if ( + typeSomewhereOnDeck.length > 0 && + robotType === OT2_ROBOT_TYPE + ) { + makeSnackbar( + t('one_item', { + hardware: t( + `shared:${getModuleType(model).toLowerCase()}` + ), + }) as string + ) + } else if (collisionError != null) { + makeSnackbar(t(`${collisionError}`) as string) + } else if ( + hasMagneticModule && + (model === 'magneticModuleV1' || + model === 'magneticModuleV2') && + !isDismissedModuleHint + ) { + displayModuleWarning(true) + } else { + setSelectedHardware(model) + dispatch(selectFixture({ fixture: null })) + dispatch(selectModule({ moduleModel: model })) + dispatch(selectLabware({ labwareDefUri: null })) + dispatch( + selectNestedLabware({ nestedLabwareDefUri: null }) + ) + } + }} + isSelected={model === selectedHardware} + /> + ) + })} - )} - - ) : ( - - )} - - + {robotType === OT2_ROBOT_TYPE || fixtures.length === 0 ? null : ( + + + {t('add_fixture')} + + + {fixtures.map(fixture => ( + { + if (onDeckProps?.setHoveredFixture != null) { + onDeckProps.setHoveredFixture(null) + } + }} + setHovered={() => { + if (onDeckProps?.setHoveredFixture != null) { + onDeckProps.setHoveredFixture(fixture) + } + }} + largeDesktopBorderRadius + buttonLabel={t(`shared:${fixture}`)} + key={`${fixture}_${slot}`} + buttonValue={fixture} + onChange={() => { + // delete this when multiple trash bins are supported + if (fixture === 'trashBin' && hasTrash) { + makeSnackbar( + t('one_item', { + hardware: t('shared:trashBin'), + }) as string + ) + } else { + setSelectedHardware(fixture) + dispatch(selectModule({ moduleModel: null })) + dispatch(selectFixture({ fixture })) + dispatch(selectLabware({ labwareDefUri: null })) + dispatch( + selectNestedLabware({ nestedLabwareDefUri: null }) + ) + } + }} + isSelected={fixture === selectedHardware} + /> + ))} + + + )} + + ) : ( + + )} + + + ) } diff --git a/protocol-designer/src/pages/Designer/DeckSetup/MagnetModuleChangeContent.tsx b/protocol-designer/src/pages/Designer/DeckSetup/MagnetModuleChangeContent.tsx new file mode 100644 index 00000000000..0a5e9c18471 --- /dev/null +++ b/protocol-designer/src/pages/Designer/DeckSetup/MagnetModuleChangeContent.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next' +import { + DIRECTION_COLUMN, + Flex, + Link, + SPACING, + StyledText, +} from '@opentrons/components' + +export function MagnetModuleChangeContent(): JSX.Element { + const { t } = useTranslation('starting_deck_state') + + return ( + + + {t('gen1_gen2_different_units')} + + + {t('convert_gen1_to_gen2')} + + + {t('convert_gen2_to_gen1')} + + + {t('alter_pause')} + + + + {t('read_more_gen1_gen2')}{' '} + + {t('here')} + + + + + ) +} diff --git a/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx index cb85cf12693..ffb4c968acd 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx @@ -18,6 +18,7 @@ import { deleteDeckFixture, } from '../../../../step-forms/actions/additionalItems' import { selectors } from '../../../../labware-ingred/selectors' +import { getDismissedHints } from '../../../../tutorial/selectors' import { getDeckSetupForActiveItem } from '../../../../top-selectors/labware-locations' import { DeckSetupTools } from '../DeckSetupTools' import { LabwareTools } from '../LabwareTools' @@ -32,6 +33,7 @@ vi.mock('../../../../labware-ingred/actions') vi.mock('../../../../step-forms/actions') vi.mock('../../../../step-forms/actions/additionalItems') vi.mock('../../../../labware-ingred/selectors') +vi.mock('../../../../tutorial/selectors') const render = (props: React.ComponentProps) => { return renderWithProviders(, { i18nInstance: i18n, @@ -66,6 +68,7 @@ describe('DeckSetupTools', () => { additionalEquipmentOnDeck: {}, pipettes: {}, }) + vi.mocked(getDismissedHints).mockReturnValue([]) }) it('should render the relevant modules and fixtures for slot D3 on Flex with tabs', () => { render(props) diff --git a/protocol-designer/src/pages/Designer/DeckSetup/__tests__/MagnetModuleChangeContent.test.tsx b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/MagnetModuleChangeContent.test.tsx new file mode 100644 index 00000000000..1767d0cea18 --- /dev/null +++ b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/MagnetModuleChangeContent.test.tsx @@ -0,0 +1,32 @@ +import { describe, it } from 'vitest' +import { screen } from '@testing-library/react' +import { i18n } from '../../../../assets/localization' +import { renderWithProviders } from '../../../../__testing-utils__' +import { MagnetModuleChangeContent } from '../MagnetModuleChangeContent' + +const render = () => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('MagnetModuleChangeContent', () => { + it('renders the text for the modal content', () => { + render() + screen.getByText( + 'Switching between GEN1 and GEN2 Magnetic Modules will clear all non-default engage heights from existing magnet steps in your protocol. GEN1 and GEN2 Magnetic Modules do not use the same units.' + ) + screen.getByText( + 'To convert engage heights from GEN1 to GEN2, divide your engage height by 2.' + ) + screen.getByText( + 'To convert engage heights from GEN2 to GEN1, multiply your engage height by 2.' + ) + screen.getByText( + 'You may also need to alter the time you pause while your magnet is engaged.' + ) + screen.getByText( + 'Read more about the differences between GEN1 and GEN2 Magnetic Modules' + ) + }) +}) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx index f101237cb45..9c60605163c 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx @@ -99,6 +99,10 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { ? 1 : 0 ) + const [ + showFormErrorsAndWarnings, + setShowFormErrorsAndWarnings, + ] = useState(false) const [isRename, setIsRename] = useState(false) const icon = stepIconsByType[formData.stepType] @@ -126,18 +130,22 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { const numErrors = timeline.errors?.length ?? 0 const handleSaveClick = (): void => { - handleSave() - makeSnackbar( - getSaveStepSnackbarText({ - numWarnings, - numErrors, - stepTypeDisplayName: i18n.format( - t(`stepType.${formData.stepType}`), - 'capitalize' - ), - t, - }) as string - ) + if (canSave) { + handleSave() + makeSnackbar( + getSaveStepSnackbarText({ + numWarnings, + numErrors, + stepTypeDisplayName: i18n.format( + t(`stepType.${formData.stepType}`), + 'capitalize' + ), + t, + }) as string + ) + } else { + setShowFormErrorsAndWarnings(true) + } } return ( @@ -195,9 +203,6 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { } : handleSaveClick } - disabled={ - isMultiStepToolbox && toolboxStep === 0 ? false : !canSave - } width="100%" > {isMultiStepToolbox && toolboxStep === 0 @@ -215,7 +220,9 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { } > - + {showFormErrorsAndWarnings ? ( + + ) : 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')} + toggleElement="checkbox" + /> ) 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 e98727dd045..2468923d9c2 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 @@ -83,7 +83,6 @@ export function MagnetTools(props: StepFormProps): JSX.Element { 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/StepTools/ThermocyclerTools/ThermocyclerState.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerState.tsx index 4872610a284..a5f1676f2c8 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerState.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerState.tsx @@ -77,11 +77,9 @@ export function ThermocyclerState(props: ThermocyclerStateProps): JSX.Element { fieldTitle={i18n.format(t('stepType.temperature'), 'capitalize')} units={t('units.degrees')} isSelected={formData[lidFieldActive] === true} - onLabel={t( - 'form:step_edit_form.field.thermocyclerState.lidPosition.toggleOn' - )} + onLabel={t('form:step_edit_form.field.thermocyclerState.lid.toggleOn')} offLabel={t( - 'form:step_edit_form.field.thermocyclerState.lidPosition.toggleOff' + 'form:step_edit_form.field.thermocyclerState.lid.toggleOff' )} /> - {targetSpeed != null ? ( + {targetSpeed ? ( - {heaterShakerTimerMinutes != null && - heaterShakerTimerSeconds != null ? ( + {heaterShakerTimer ? ( ) : null} - + {tab === 'protocolSteps' ? ( - - - + ) : null} - + {deckView === leftString ? ( ) : ( diff --git a/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx b/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx index 315496d755e..4ca8430796f 100644 --- a/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx +++ b/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx @@ -8,6 +8,7 @@ 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') @@ -15,6 +16,7 @@ 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() @@ -33,6 +35,7 @@ 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) diff --git a/protocol-designer/src/pages/Landing/index.tsx b/protocol-designer/src/pages/Landing/index.tsx index a4e0be187f5..315787cd9ea 100644 --- a/protocol-designer/src/pages/Landing/index.tsx +++ b/protocol-designer/src/pages/Landing/index.tsx @@ -8,6 +8,7 @@ import { COLORS, CURSOR_POINTER, DIRECTION_COLUMN, + EndUserAgreementFooter, Flex, INFO_TOAST, JUSTIFY_CENTER, @@ -22,6 +23,7 @@ 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' @@ -36,17 +38,17 @@ export function Landing(): JSX.Element { 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 showGateModal = - process.env.NODE_ENV === 'production' || process.env.OT_PD_SHOW_GATE + const userHasNotSeenAnnouncement = getLocalStorageItem(localStorageAnnouncementKey) !== announcementKey && - !showGateModal + hasOptedIn != null useEffect(() => { if (userHasNotSeenAnnouncement) { @@ -96,7 +98,7 @@ export function Landing(): JSX.Element { flexDirection={DIRECTION_COLUMN} alignItems={ALIGN_CENTER} justifyContent={JUSTIFY_CENTER} - height="calc(100vh - 3.5rem)" + height="calc(100vh - 9rem)" width="100%" gridGap={SPACING.spacing32} > @@ -142,6 +144,7 @@ export function Landing(): JSX.Element { + ) } 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 cd1b5215ab1..9f661bb4137 100644 --- a/protocol-designer/src/pages/ProtocolOverview/InstrumentsInfo.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/InstrumentsInfo.tsx @@ -1,15 +1,16 @@ import { useTranslation } from 'react-i18next' import { - Flex, - StyledText, Btn, + COLORS, DIRECTION_COLUMN, - SPACING, + Flex, JUSTIFY_SPACE_BETWEEN, - TYPOGRAPHY, ListItem, ListItemDescriptor, + SPACING, + StyledText, + TYPOGRAPHY, } from '@opentrons/components' import { getPipetteSpecsV2, FLEX_ROBOT_TYPE } from '@opentrons/shared-data' @@ -93,34 +94,88 @@ export function InstrumentsInfo({ + {' '} + + {t('robotType')} + + + } content={ - robotType === FLEX_ROBOT_TYPE - ? t('shared:opentrons_flex') - : t('shared:ot2') + + {robotType === FLEX_ROBOT_TYPE + ? t('shared:opentrons_flex') + : t('shared:ot2')} + } /> + {' '} + + {t('left_pip')} + + + } + content={ + + {pipetteInfo(leftPipette)} + + } /> + {' '} + + {t('right_pip')} + + + } + content={ + + {pipetteInfo(rightPipette)} + + } /> {robotType === FLEX_ROBOT_TYPE ? ( + {' '} + + {t('extension')} + + + } + content={ + + {isGripperAttached ? t('gripper') : t('na')} + + } /> ) : null} diff --git a/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx b/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx index fc767242929..dc753bee12f 100644 --- a/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx @@ -37,11 +37,15 @@ export function LiquidDefinitions({ + @@ -49,7 +53,14 @@ export function LiquidDefinitions({ } - content={liquid.description ?? t('na')} + content={ + + {liquid.description ?? t('na')} + + } /> )) diff --git a/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx b/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx index 69d8697765b..e24f016e07c 100644 --- a/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx @@ -1,14 +1,15 @@ import { useTranslation } from 'react-i18next' import { - Flex, + Btn, + COLORS, DIRECTION_COLUMN, - SPACING, + Flex, JUSTIFY_SPACE_BETWEEN, - TYPOGRAPHY, - StyledText, ListItem, ListItemDescriptor, - Btn, + SPACING, + StyledText, + TYPOGRAPHY, } from '@opentrons/components' import { BUTTON_LINK_STYLE } from '../../atoms' @@ -62,8 +63,21 @@ export function ProtocolMetadata({ + + {t(`${title}`)} + + + } + content={ + + {value ?? t('na')} + + } /> ) @@ -71,10 +85,23 @@ export function ProtocolMetadata({ + + {t('required_app_version')} + + + } + content={ + + {t('app_version', { + version: REQUIRED_APP_VERSION, + })} + + } /> diff --git a/protocol-designer/src/pages/ProtocolOverview/StepsInfo.tsx b/protocol-designer/src/pages/ProtocolOverview/StepsInfo.tsx index 3eacf7beeba..090db7e78d4 100644 --- a/protocol-designer/src/pages/ProtocolOverview/StepsInfo.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/StepsInfo.tsx @@ -35,12 +35,14 @@ export function StepsInfo({ savedStepForms }: StepsInfoProps): JSX.Element { - {t('number_of_steps')} - + + + {t('number_of_steps')} + + } content={ diff --git a/protocol-designer/src/pages/ProtocolOverview/index.tsx b/protocol-designer/src/pages/ProtocolOverview/index.tsx index 29e72aa171f..4d68ba7e0d3 100644 --- a/protocol-designer/src/pages/ProtocolOverview/index.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/index.tsx @@ -10,6 +10,7 @@ import { ALIGN_CENTER, Btn, DIRECTION_COLUMN, + EndUserAgreementFooter, Flex, JUSTIFY_END, JUSTIFY_FLEX_END, @@ -414,6 +415,7 @@ export function ProtocolOverview(): JSX.Element { + ) } diff --git a/protocol-designer/src/steplist/fieldLevel/errors.ts b/protocol-designer/src/steplist/fieldLevel/errors.ts index f0833fa652f..7405f097643 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 } @@ -58,20 +58,20 @@ export const minimumWellCount = (minimum: number): ErrorChecker => ( export const minFieldValue = (minimum: number): ErrorChecker => ( value: unknown ): string | null => - value === null || Number(value) >= minimum + !value || Number(value) >= minimum ? null : `${FIELD_ERRORS.UNDER_RANGE_MINIMUM} ${minimum}` export const maxFieldValue = (maximum: number): ErrorChecker => ( value: unknown ): string | null => - value === null || Number(value) <= maximum + !value || Number(value) <= maximum ? null : `${FIELD_ERRORS.OVER_RANGE_MAXIMUM} ${maximum}` export const temperatureRangeFieldValue = ( minimum: number, maximum: number ): ErrorChecker => (value: unknown): string | null => - value === null || (Number(value) <= maximum && Number(value) >= minimum) + !value || (Number(value) <= maximum && Number(value) >= minimum) ? null : `${FIELD_ERRORS.OUTSIDE_OF_RANGE} ${minimum} and ${maximum} °C` export const realNumber: ErrorChecker = (value: unknown) => 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/robot-server/robot_server/runs/action_models.py b/robot-server/robot_server/runs/action_models.py index ede27d823c6..c640fb33cae 100644 --- a/robot-server/robot_server/runs/action_models.py +++ b/robot-server/robot_server/runs/action_models.py @@ -7,20 +7,53 @@ class RunActionType(str, Enum): - """The type of the run control action. - - * `"play"`: Start or resume a run. - * `"pause"`: Pause a run. - * `"stop"`: Stop (cancel) a run. - * `"resume-from-recovery"`: Resume normal protocol execution after a command failed, - the run was placed in `awaiting-recovery` mode, and manual recovery steps - were taken. + """The type of the run control action, which determines behavior. + + * `"play"`: Start the run, or resume it after it's been paused. + + * `"pause"`: Pause the run. + + * `"stop"`: Stop (cancel) the run. + + * `"resume-from-recovery"`: Resume normal protocol execution after the run was in + error recovery mode. Continue from however the last command left the robot. + + * `"resume-from-recovery-assuming-false-positive"`: Resume normal protocol execution + after the run was in error recovery mode. Act as if the underlying error was a + false positive. + + To see the difference between `"resume-from-recovery"` and + `"resume-from-recovery-assuming-false-positive"`, suppose we've just entered error + recovery mode after a `commandType: "pickUpTip"` command failed with an + `errorType: "tipPhysicallyMissing"` error. That normally leaves the robot thinking + it has no tip attached. If you use `"resume-from-recovery"`, the robot will run + the next protocol command from that state, acting as if there's no tip attached. + (This may cause another error, if the next command needs a tip.) + Whereas if you use `"resume-from-recovery-assuming-false-positive"`, + the robot will try to nullify the error, thereby acting as if it *does* have a tip + attached. + + Generally: + + * If you've tried to recover from the error by sending your own `intent: "fixit"` + commands to `POST /runs/{id}/commands`, use `"resume-from-recovery"`. It's your + responsibility to ensure your `POST`ed commands leave the robot in a good-enough + state to continue with the protocol. + + * Otherwise, use `"resume-from-recovery-assuming-false-positive"`. + + Do not combine `intent: "fixit"` commands with + `"resume-from-recovery-assuming-false-positive"`—the robot's built-in + false-positive recovery may compete with your own. """ PLAY = "play" PAUSE = "pause" STOP = "stop" RESUME_FROM_RECOVERY = "resume-from-recovery" + RESUME_FROM_RECOVERY_ASSUMING_FALSE_POSITIVE = ( + "resume-from-recovery-assuming-false-positive" + ) class RunActionCreate(BaseModel): @@ -41,7 +74,4 @@ class RunAction(ResourceModel): id: str = Field(..., description="A unique identifier to reference the command.") createdAt: datetime = Field(..., description="When the command was created.") - actionType: RunActionType = Field( - ..., - description="Specific type of action, which determines behavior.", - ) + actionType: RunActionType diff --git a/robot-server/robot_server/runs/error_recovery_mapping.py b/robot-server/robot_server/runs/error_recovery_mapping.py index d29ebf4b054..b548394cd8a 100644 --- a/robot-server/robot_server/runs/error_recovery_mapping.py +++ b/robot-server/robot_server/runs/error_recovery_mapping.py @@ -102,6 +102,10 @@ def _map_error_recovery_type(reaction_if_match: ReactionIfMatch) -> ErrorRecover match reaction_if_match: case ReactionIfMatch.IGNORE_AND_CONTINUE: return ErrorRecoveryType.IGNORE_AND_CONTINUE + case ReactionIfMatch.ASSUME_FALSE_POSITIVE_AND_CONTINUE: + # todo(mm, 2024-10-23): Connect to work in + # https://github.com/Opentrons/opentrons/pull/16556. + return ErrorRecoveryType.IGNORE_AND_CONTINUE case ReactionIfMatch.FAIL_RUN: return ErrorRecoveryType.FAIL_RUN case ReactionIfMatch.WAIT_FOR_RECOVERY: diff --git a/robot-server/robot_server/runs/error_recovery_models.py b/robot-server/robot_server/runs/error_recovery_models.py index a2990a007cb..1e2d4ac45aa 100644 --- a/robot-server/robot_server/runs/error_recovery_models.py +++ b/robot-server/robot_server/runs/error_recovery_models.py @@ -24,17 +24,40 @@ class ReactionIfMatch(Enum): - """How to handle a given error. + """How to handle a matching error. - * `"ignoreAndContinue"`: Ignore this error and continue with the next command. * `"failRun"`: Fail the run. - * `"waitForRecovery"`: Enter interactive error recovery mode. + * `"waitForRecovery"`: Enter interactive error recovery mode. You can then + perform error recovery with `POST /runs/{id}/commands` and exit error + recovery mode with `POST /runs/{id}/actions`. + + * `"assumeFalsePositiveAndContinue"`: Continue the run without interruption, acting + as if the error was a false positive. + + This is equivalent to doing `"waitForRecovery"` + and then sending `actionType: "resume-from-recovery-assuming-false-positive"` + to `POST /runs/{id}/actions`, except this requires no ongoing intervention from + the client. + + * `"ignoreAndContinue"`: Continue the run without interruption, accepting whatever + state the error left the robot in. + + This is equivalent to doing `"waitForRecovery"` + and then sending `actionType: "resume-from-recovery"` to `POST /runs/{id}/actions`, + except this requires no ongoing intervention from the client. + + This is probably not useful very often because it's likely to cause downstream + errors—imagine trying an `aspirate` command after a failed `pickUpTip` command. + This is provided for symmetry. """ - IGNORE_AND_CONTINUE = "ignoreAndContinue" FAIL_RUN = "failRun" WAIT_FOR_RECOVERY = "waitForRecovery" + ASSUME_FALSE_POSITIVE_AND_CONTINUE = "assumeFalsePositiveAndContinue" + # todo(mm, 2024-10-22): "ignoreAndContinue" may be a misnomer now: is + # "assumeFalsePositiveAndContinue" not also a way to "ignore"? Consider renaming. + IGNORE_AND_CONTINUE = "ignoreAndContinue" class ErrorMatcher(BaseModel): @@ -69,10 +92,7 @@ class ErrorRecoveryRule(BaseModel): ..., description="The criteria that must be met for this rule to be applied.", ) - ifMatch: ReactionIfMatch = Field( - ..., - description="How to handle errors matched by this rule.", - ) + ifMatch: ReactionIfMatch class ErrorRecoveryPolicy(BaseModel): diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index 5d86aa83ba1..23f1d6eb446 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -450,7 +450,6 @@ async def update_run( See `PATCH /errorRecovery/settings`. """ ), - status_code=status.HTTP_201_CREATED, responses={ status.HTTP_200_OK: {"model": SimpleEmptyBody}, status.HTTP_409_CONFLICT: {"model": ErrorBody[RunStopped]}, diff --git a/robot-server/robot_server/runs/run_controller.py b/robot-server/robot_server/runs/run_controller.py index 903bf22f252..1619cd20a08 100644 --- a/robot-server/robot_server/runs/run_controller.py +++ b/robot-server/robot_server/runs/run_controller.py @@ -2,6 +2,7 @@ import logging from datetime import datetime from typing import Optional +from typing_extensions import assert_never from opentrons.protocol_engine import ProtocolEngineError from opentrons_shared_data.errors.exceptions import RoboticsInteractionError @@ -96,6 +97,17 @@ def create_action( elif action_type == RunActionType.RESUME_FROM_RECOVERY: self._run_orchestrator_store.resume_from_recovery() + elif ( + action_type + == RunActionType.RESUME_FROM_RECOVERY_ASSUMING_FALSE_POSITIVE + ): + # todo(mm, 2024-10-23): Connect to work in + # https://github.com/Opentrons/opentrons/pull/16556. + self._run_orchestrator_store.resume_from_recovery() + + else: + assert_never(action_type) + except ProtocolEngineError as e: raise RunActionNotAllowedError(message=e.message, wrapping=[e]) from e diff --git a/scripts/deploy/lib/copyObject.js b/scripts/deploy/lib/copyObject.js index 1735cb4a55e..63418067b40 100644 --- a/scripts/deploy/lib/copyObject.js +++ b/scripts/deploy/lib/copyObject.js @@ -1,6 +1,7 @@ 'use strict' const mime = require('mime') +const { CopyObjectCommand } = require('@aws-sdk/client-s3') // TODO(mc, 2019-07-16): optimize cache values const getCopyParams = obj => ({ @@ -12,7 +13,7 @@ const getCopyParams = obj => ({ /** * Copy an object to an S3 bucket * - * @param {S3} s3 - AWS.S3 instance + * @param {S3Client} s3 - AWS SDK v3 S3Client instance * @param {S3Object} sourceObj - Object to copy * @param {string} destBucket - Destination bucket * @param {string} [destPath] - Destination bucket folder (root if unspecified) @@ -21,10 +22,10 @@ const getCopyParams = obj => ({ * * @typedef S3Object * @property {string} Bucket - Object bucket - * @property {String} Prefix - Deploy folder in bucket + * @property {string} Prefix - Deploy folder in bucket * @property {string} Key - Full key to object */ -module.exports = function copyObject( +module.exports = async function copyObject( s3, sourceObj, destBucket, @@ -37,18 +38,28 @@ module.exports = function copyObject( const copyParams = getCopyParams(sourceObj) console.log( - `${dryrun ? 'DRYRUN: ' : ''}Copy - Source: ${copySource} - Dest: /${destBucket}/${destKey} - Params: ${JSON.stringify(copyParams)}\n` + `${ + dryrun ? 'DRYRUN: ' : '' + }Copy\nSource: ${copySource}\nDest: /${destBucket}/${destKey}\nParams: ${JSON.stringify( + copyParams + )}\n` ) if (dryrun) return Promise.resolve() - const copyObjectParams = Object.assign( - { Bucket: destBucket, Key: destKey, CopySource: copySource }, - copyParams - ) + const copyObjectParams = { + Bucket: destBucket, + Key: destKey, + CopySource: copySource, + ...copyParams, + } - return s3.copyObject(copyObjectParams).promise() + try { + const command = new CopyObjectCommand(copyObjectParams) + await s3.send(command) + console.log(`Successfully copied to /${destBucket}/${destKey}`) + } catch (err) { + console.error(`Error copying object: ${err.message}`) + throw err + } } diff --git a/scripts/deploy/lib/removeObject.js b/scripts/deploy/lib/removeObject.js index 56bd309a6eb..728d8927496 100644 --- a/scripts/deploy/lib/removeObject.js +++ b/scripts/deploy/lib/removeObject.js @@ -1,9 +1,11 @@ 'use strict' +const { DeleteObjectCommand } = require('@aws-sdk/client-s3'); + /** * Remove an object from S3 * - * @param {AWS.S3} s3 - AWS.S3 instance + * @param {S3Client} s3 - S3Client instance * @param {S3Object} obj - Object to remove * @param {boolean} [dryrun] - Don't actually remove anything * @returns {Promise} Promise that resolves when the removal is complete @@ -13,13 +15,22 @@ * @property {String} Prefix - Deploy folder in bucket * @property {string} Key - Full key to object */ -module.exports = function removeObject(s3, obj, dryrun) { +module.exports = async function removeObject(s3, obj, dryrun) { console.log( - `${dryrun ? 'DRYRUN: ' : ''}Remove - Source: /${obj.Bucket}/${obj.Key}\n` - ) + `${dryrun ? 'DRYRUN: ' : ''}Remove\nSource: /${obj.Bucket}/${obj.Key}\n` + ); + + if (dryrun) return Promise.resolve(); - if (dryrun) return Promise.resolve() + // Construct the deleteObject command with the bucket and key + const deleteParams = { Bucket: obj.Bucket, Key: obj.Key }; - return s3.deleteObject({ Bucket: obj.Bucket, Key: obj.Key }).promise() -} + try { + // Use the send method with DeleteObjectCommand + const result = await s3.send(new DeleteObjectCommand(deleteParams)); + return result; + } catch (error) { + console.error('Error removing object:', error); + throw error; + } +}; 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/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/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/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.', } }