diff --git a/.github/workflows/app-test-build-deploy.yaml b/.github/workflows/app-test-build-deploy.yaml index f0bfe7d8946..d443fae35a0 100644 --- a/.github/workflows/app-test-build-deploy.yaml +++ b/.github/workflows/app-test-build-deploy.yaml @@ -318,12 +318,12 @@ jobs: if: startsWith(matrix.os, 'windows') && contains(needs.determine-build-type.outputs.type, 'release') shell: cmd env: - SM_HOST: ${{ secrets.SM_HOST }} + SM_HOST: ${{ secrets.SM_HOST_V2 }} SM_CLIENT_CERT_FILE: "D:\\Certificate_pkcs12.p12" - SM_CLIENT_CERT_PASSWORD: ${{secrets.SM_CLIENT_CERT_PASSWORD}} - SM_API_KEY: ${{secrets.SM_API_KEY}} + SM_CLIENT_CERT_PASSWORD: ${{secrets.SM_CLIENT_CERT_PASSWORD_V2}} + SM_API_KEY: ${{secrets.SM_API_KEY_V2}} run: | - curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download -H "x-api-key:${{secrets.SM_API_KEY}}" -o Keylockertools-windows-x64.msi + curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download -H "x-api-key:${{secrets.SM_API_KEY_V2}}" -o Keylockertools-windows-x64.msi msiexec /i Keylockertools-windows-x64.msi /quiet /qn smksp_registrar.exe list smctl.exe keypair ls @@ -331,6 +331,15 @@ jobs: smksp_cert_sync.exe smctl.exe healthcheck --all + # Do the frontend dist bundle + - name: 'bundle ${{matrix.variant}} frontend' + env: + OT_APP_MIXPANEL_ID: ${{ secrets.OT_APP_MIXPANEL_ID }} + OT_APP_INTERCOM_ID: ${{ secrets.OT_APP_INTERCOM_ID }} + OPENTRONS_PROJECT: ${{ steps.project.outputs.project }} + run: | + make -C app dist + # build the desktop app and deploy it - name: 'build ${{matrix.variant}} app for ${{ matrix.os }}' if: matrix.target == 'desktop' @@ -339,18 +348,14 @@ jobs: OT_APP_MIXPANEL_ID: ${{ secrets.OT_APP_MIXPANEL_ID }} OT_APP_INTERCOM_ID: ${{ secrets.OT_APP_INTERCOM_ID }} WINDOWS_SIGN: ${{ format('{0}', contains(needs.determine-build-type.outputs.type, 'release')) }} - SM_HOST: ${{secrets.SM_HOST}} - SM_CLIENT_CERT_FILE: "D:\\Certificate_pkcs12.p12" - SM_CLIENT_CERT_PASSWORD: ${{secrets.SM_CLIENT_CERT_PASSWORD}} - SM_API_KEY: ${{secrets.SM_API_KEY}} - SM_CODE_SIGNING_CERT_SHA1_HASH: ${{secrets.SM_CODE_SIGNING_CERT_SHA1_HASH}} - SM_KEYPAIR_ALIAS: ${{secrets.SM_KEYPAIR_ALIAS}} + SM_CODE_SIGNING_CERT_SHA1_HASH: ${{secrets.SM_CODE_SIGNING_CERT_SHA1_HASH_V2}} + SM_KEYPAIR_ALIAS: ${{secrets.SM_KEYPAIR_ALIAS_V2}} WINDOWS_CSC_FILEPATH: "D:\\opentrons_labworks_inc.crt" - CSC_LINK: ${{ secrets.OT_APP_CSC_MACOS }} - CSC_KEY_PASSWORD: ${{ secrets.OT_APP_CSC_KEY_MACOS }} - APPLE_ID: ${{ secrets.OT_APP_APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.OT_APP_APPLE_ID_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.OT_APP_APPLE_TEAM_ID }} + CSC_LINK: ${{ secrets.OT_APP_CSC_MACOS_V2 }} + CSC_KEY_PASSWORD: ${{ secrets.OT_APP_CSC_KEY_MACOS_V2 }} + APPLE_ID: ${{ secrets.OT_APP_APPLE_ID_V2 }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.OT_APP_APPLE_ID_PASSWORD_V2 }} + APPLE_TEAM_ID: ${{ secrets.OT_APP_APPLE_TEAM_ID_V2 }} HOST_PYTHON: python OPENTRONS_PROJECT: ${{ steps.project.outputs.project }} OT_APP_DEPLOY_BUCKET: ${{ steps.project.outputs.bucket }} diff --git a/app-shell-odd/Makefile b/app-shell-odd/Makefile index 543ed2de95f..5d2d7ac37bd 100644 --- a/app-shell-odd/Makefile +++ b/app-shell-odd/Makefile @@ -65,12 +65,14 @@ deps: .PHONY: package-deps package-deps: clean lib deps +# Note: keep the push dep separate from the dist target so it doesn't accidentally +# do a js dist when we want to only build electron .PHONY: dist-ot3 -dist-ot3: package-deps +dist-ot3: clean lib NO_USB_DETECTION=true OT_APP_DEPLOY_BUCKET=opentrons-app OT_APP_DEPLOY_FOLDER=builds OPENTRONS_PROJECT=$(OPENTRONS_PROJECT) $(builder) --linux --arm64 .PHONY: push-ot3 -push-ot3: dist-ot3 +push-ot3: dist-ot3 deps tar -zcvf opentrons-robot-app.tar.gz -C ./dist/linux-arm64-unpacked/ ./ scp $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) -r ./opentrons-robot-app.tar.gz root@$(host): ssh $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) root@$(host) "mount -o remount,rw / && systemctl stop opentrons-robot-app && rm -rf /opt/opentrons-app && mkdir -p /opt/opentrons-app" diff --git a/app-shell-odd/src/main.ts b/app-shell-odd/src/main.ts index ccb9ff61aa2..9eb17a016cc 100644 --- a/app-shell-odd/src/main.ts +++ b/app-shell-odd/src/main.ts @@ -197,7 +197,10 @@ function installDevtools(): void { log.debug('Installing devtools') - install(extensions, forceReinstall) + install(extensions, { + loadExtensionOptions: { allowFileAccess: true }, + forceDownload: forceReinstall, + }) .then(() => log.debug('Devtools extensions installed')) .catch((error: unknown) => { log.warn('Failed to install devtools extensions', { diff --git a/app-shell/Makefile b/app-shell/Makefile index 5daafd82f44..74e4e4b1912 100644 --- a/app-shell/Makefile +++ b/app-shell/Makefile @@ -121,32 +121,34 @@ package dist-posix dist-osx dist-linux dist-win: export BUILD_ID := $(build_id) package dist-posix dist-osx dist-linux dist-win: export NO_PYTHON := $(if $(no_python_bundle),true,false) package dist-posix dist-osx dist-linux dist-win: export USE_HARD_LINKS := false +# Note: these depend on make -C app dist having been run; do not do this automatically because we separate these +# tasks in CI and even if you have a file dep it's easy to accidentally make the dist run. .PHONY: package -package: package-deps +package: $(builder) --dir .PHONY: dist-posix -dist-posix: package-deps +dist-posix: clean lib $(builder) --linux --mac $(MAKE) _dist-collect-artifacts .PHONY: dist-osx -dist-osx: package-deps +dist-osx: clean lib $(builder) --mac --x64 $(MAKE) _dist-collect-artifacts .PHONY: dist-linux -dist-linux: package-deps +dist-linux: clean lib $(builder) --linux $(MAKE) _dist-collect-artifacts .PHONY: dist-win -dist-win: package-deps +dist-win: clean lib $(builder) --win --x64 $(MAKE) _dist-collect-artifacts .PHONY: dist-ot3 -dist-ot3: package-deps +dist-ot3: clean lib NO_PYTHON=true $(builder) --linux --arm64 --dir cd dist/linux-arm64-unpacked diff --git a/app-shell/src/main.ts b/app-shell/src/main.ts index 0f4ab41733b..e09b9d0ae4c 100644 --- a/app-shell/src/main.ts +++ b/app-shell/src/main.ts @@ -145,7 +145,10 @@ function installDevtools(): Promise { log.debug('Installing devtools') if (typeof install === 'function') { - return install(extensions, forceReinstall) + return install(extensions, { + loadExtensionOptions: { allowFileAccess: true }, + forceDownload: forceReinstall, + }) .then(() => log.debug('Devtools extensions installed')) .catch((error: unknown) => { log.warn('Failed to install devtools extensions', { diff --git a/app/src/assets/localization/en/device_details.json b/app/src/assets/localization/en/device_details.json index ee0be003723..17daa2c5955 100644 --- a/app/src/assets/localization/en/device_details.json +++ b/app/src/assets/localization/en/device_details.json @@ -74,7 +74,7 @@ "instruments_and_modules": "Instruments and Modules", "labware_bottom": "Labware Bottom", "last_run_time": "last run {{number}}", - "left_right": "Left+Right Mounts", + "left_right": "Left + Right Mounts", "left": "left", "lights": "Lights", "link_firmware_update": "View Firmware Update", diff --git a/app/src/pages/ODD/InstrumentsDashboard/__tests__/InstrumentsDashboard.test.tsx b/app/src/pages/ODD/InstrumentsDashboard/__tests__/InstrumentsDashboard.test.tsx index 7f2687e07b7..5e78f28b4c9 100644 --- a/app/src/pages/ODD/InstrumentsDashboard/__tests__/InstrumentsDashboard.test.tsx +++ b/app/src/pages/ODD/InstrumentsDashboard/__tests__/InstrumentsDashboard.test.tsx @@ -183,7 +183,7 @@ describe('InstrumentsDashboard', () => { }, } as any) render('/instruments') - screen.getByText('Left+Right Mounts') + screen.getByText('Left + Right Mounts') screen.getByText('extension Mount') }) }) diff --git a/app/vite.config.mts b/app/vite.config.mts index 0d1ccadcc19..f10fedf4f7e 100644 --- a/app/vite.config.mts +++ b/app/vite.config.mts @@ -46,7 +46,11 @@ export default defineConfig( }, }, define: { - 'process.env': process.env, + 'process.env': { + NODE_ENV: process.env.NODE_ENV, + OT_APP_MIXPANEL_ID: process.env.OT_APP_MIXPANEL_ID, + OPENTRONS_PROJECT: process.env.OPENTRONS_PROJECT, + }, global: 'globalThis', _PKG_VERSION_: JSON.stringify(version), _OPENTRONS_PROJECT_: JSON.stringify(project), diff --git a/components/src/atoms/InputField/index.tsx b/components/src/atoms/InputField/index.tsx index c6d8eff68ea..fc1e1a4560f 100644 --- a/components/src/atoms/InputField/index.tsx +++ b/components/src/atoms/InputField/index.tsx @@ -73,10 +73,18 @@ export interface InputFieldProps { size?: 'medium' | 'small' /** react useRef to control input field instead of react event */ ref?: React.MutableRefObject + /** optional IconName to display icon aligned to left of input field */ leftIcon?: IconName + /** if true, show delete icon aligned to right of input field */ showDeleteIcon?: boolean + /** callback passed to optional delete icon onClick */ onDelete?: () => void + /** if true, style the background of input field to error state */ hasBackgroundError?: boolean + /** optional prop to override input field border radius */ + borderRadius?: string + /** optional prop to override input field padding */ + padding?: string } export const InputField = React.forwardRef( @@ -91,6 +99,8 @@ export const InputField = React.forwardRef( showDeleteIcon = false, hasBackgroundError = false, onDelete, + borderRadius, + padding, ...inputProps } = props const hasError = props.error != null @@ -112,8 +122,10 @@ export const InputField = React.forwardRef( const INPUT_FIELD = css` display: flex; background-color: ${hasBackgroundError ? COLORS.red30 : COLORS.white}; - border-radius: ${BORDERS.borderRadius4}; - padding: ${SPACING.spacing8}; + border-radius: ${borderRadius != null + ? borderRadius + : BORDERS.borderRadius4}; + padding: ${padding != null ? padding : SPACING.spacing8}; border: ${hasBackgroundError ? 'none' : `1px ${BORDERS.styleSolid} @@ -280,7 +292,12 @@ export const InputField = React.forwardRef( ) : null} ) : null} - + ( ) : null} {hasError ? ( - + {props.error} ) : null} diff --git a/components/src/atoms/ListItem/ListItemChildren/ListItemCustomize.tsx b/components/src/atoms/ListItem/ListItemChildren/ListItemCustomize.tsx index aa04dd91722..b9e31ad2782 100644 --- a/components/src/atoms/ListItem/ListItemChildren/ListItemCustomize.tsx +++ b/components/src/atoms/ListItem/ListItemChildren/ListItemCustomize.tsx @@ -18,6 +18,8 @@ interface ListItemCustomizeProps { label?: string dropdown?: DropdownMenuProps tag?: TagProps + /** temporary prop for dropdown menu */ + forceDirection?: boolean } export function ListItemCustomize(props: ListItemCustomizeProps): JSX.Element { @@ -29,6 +31,7 @@ export function ListItemCustomize(props: ListItemCustomizeProps): JSX.Element { linkText, dropdown, tag, + forceDirection = false, } = props return ( @@ -49,7 +52,9 @@ export function ListItemCustomize(props: ListItemCustomizeProps): JSX.Element { {label} ) : null} - {dropdown != null ? : null} + {dropdown != null ? ( + + ) : null} {tag != null ? : null} {onClick != null && linkText != null ? ( diff --git a/components/src/icons/icon-data.ts b/components/src/icons/icon-data.ts index 9f8338c7e87..9c97c5bee91 100644 --- a/components/src/icons/icon-data.ts +++ b/components/src/icons/icon-data.ts @@ -685,6 +685,11 @@ export const ICON_DATA_BY_NAME: Record< 'M18.793 34.9163C15.3763 34.6386 12.5013 33.2358 10.168 30.708C7.83464 28.1802 6.66797 25.1802 6.66797 21.708C6.66797 19.5691 7.16102 17.5552 8.14714 15.6663C9.13325 13.7775 10.5152 12.2358 12.293 11.0413L14.0846 12.833C12.5291 13.7497 11.3207 15.0066 10.4596 16.6038C9.59852 18.2011 9.16797 19.9025 9.16797 21.708C9.16797 24.4858 10.0846 26.8886 11.918 28.9163C13.7513 30.9441 16.043 32.1108 18.793 32.4163V34.9163ZM21.293 34.9163V32.4163C24.0707 32.083 26.3624 30.9094 28.168 28.8955C29.9735 26.8816 30.8763 24.4858 30.8763 21.708C30.8763 18.6802 29.8277 16.1177 27.7305 14.0205C25.6332 11.9233 23.0707 10.8747 20.043 10.8747H19.2096L21.7096 13.3747L19.918 15.1663L14.3763 9.62467L19.918 4.08301L21.7096 5.87467L19.2096 8.37467H20.043C23.7652 8.37467 26.918 9.67329 29.5013 12.2705C32.0846 14.8677 33.3763 18.0136 33.3763 21.708C33.3763 25.1802 32.2166 28.1802 29.8971 30.708C27.5777 33.2358 24.7096 34.6386 21.293 34.9163Z', viewBox: '0 0 40 40', }, + reload: { + path: + 'M15.1406 23.6501C11.9581 23.6501 9.25062 22.5457 7.01813 20.337C4.78562 18.1282 3.66937 15.4326 3.66937 12.2501V12.0007L2.38687 13.2832C2.12562 13.5445 1.79312 13.6751 1.38937 13.6751C0.985625 13.6751 0.653125 13.5445 0.391875 13.2832C0.130625 13.022 0 12.6895 0 12.2857C0 11.882 0.130625 11.5495 0.391875 11.2882L4.09687 7.58322C4.38188 7.29822 4.71438 7.15572 5.09438 7.15572C5.47437 7.15572 5.80687 7.29822 6.09188 7.58322L9.79688 11.2882C10.0581 11.5495 10.1888 11.882 10.1888 12.2857C10.1888 12.6895 10.0581 13.022 9.79688 13.2832C9.53562 13.5445 9.20312 13.6751 8.79937 13.6751C8.39563 13.6751 8.06312 13.5445 7.80188 13.2832L6.51937 12.0007V12.2501C6.51937 14.6251 7.35656 16.6438 9.03094 18.3063C10.7053 19.9688 12.7419 20.8001 15.1406 20.8001C15.5206 20.8001 15.8947 20.7764 16.2628 20.7288C16.6309 20.6813 16.9931 20.5982 17.3494 20.4795C17.7531 20.3607 18.1331 20.3726 18.4894 20.5151C18.8456 20.6576 19.1187 20.907 19.3088 21.2632C19.4988 21.6432 19.5166 22.0173 19.3622 22.3854C19.2078 22.7535 18.9287 22.997 18.525 23.1157C17.9788 23.3057 17.4206 23.4423 16.8506 23.5254C16.2806 23.6085 15.7106 23.6501 15.1406 23.6501ZM14.9981 3.7001C14.6181 3.7001 14.2441 3.72385 13.8759 3.77135C13.5078 3.81885 13.1456 3.90197 12.7894 4.02072C12.3856 4.13947 11.9997 4.1276 11.6316 3.9851C11.2634 3.8426 10.9844 3.59322 10.7944 3.23697C10.6044 2.88072 10.5866 2.51854 10.7409 2.15041C10.8953 1.78229 11.1625 1.53885 11.5425 1.4201C12.1125 1.2301 12.6825 1.0876 13.2525 0.992598C13.8225 0.897598 14.4044 0.850098 14.9981 0.850098C18.1806 0.850098 20.8881 1.95447 23.1206 4.16322C25.3531 6.37197 26.4694 9.0676 26.4694 12.2501V12.4995L27.7519 11.217C28.0131 10.9557 28.3456 10.8251 28.7494 10.8251C29.1531 10.8251 29.4856 10.9557 29.7469 11.217C30.0081 11.4782 30.1388 11.8107 30.1388 12.2145C30.1388 12.6182 30.0081 12.9507 29.7469 13.212L26.0419 16.917C25.7569 17.202 25.4244 17.3445 25.0444 17.3445C24.6644 17.3445 24.3319 17.202 24.0469 16.917L20.3419 13.212C20.0806 12.9507 19.95 12.6182 19.95 12.2145C19.95 11.8107 20.0806 11.4782 20.3419 11.217C20.6031 10.9557 20.9356 10.8251 21.3394 10.8251C21.7431 10.8251 22.0756 10.9557 22.3369 11.217L23.6194 12.4995V12.2501C23.6194 9.8751 22.7822 7.85635 21.1078 6.19385C19.4334 4.53135 17.3969 3.7001 14.9981 3.7001Z', + viewBox: '0 0 31 24', + }, reticle: { path: 'M8.01487 8.84912C8.47511 8.84912 8.84821 8.47603 8.84821 8.01579C8.84821 7.55555 8.47511 7.18245 8.01487 7.18245C7.55464 7.18245 7.18154 7.55555 7.18154 8.01579C7.18154 8.47603 7.55464 8.84912 8.01487 8.84912Z M8.66654 0.928711V2.36089C11.27 2.66533 13.3354 4.73075 13.6398 7.33418H15.072V8.66751H13.6398C13.3354 11.2709 11.27 13.3363 8.66654 13.6408V15.073H7.3332V13.6408C4.72979 13.3363 2.66437 11.2709 2.35992 8.66751H0.927734V7.33418H2.35992C2.66436 4.73075 4.72978 2.66533 7.3332 2.36089V0.928711H8.66654ZM12.2944 7.33418H11.6184C11.2502 7.33418 10.9518 7.63266 10.9518 8.00085C10.9518 8.36904 11.2502 8.66751 11.6184 8.66751H12.2944C12.0071 10.5336 10.5326 12.008 8.66654 12.2953V11.6194C8.66654 11.2512 8.36806 10.9527 7.99987 10.9527C7.63168 10.9527 7.3332 11.2512 7.3332 11.6194V12.2953C5.46716 12.008 3.99268 10.5336 3.70536 8.66751H4.38132C4.74951 8.66751 5.04798 8.36904 5.04798 8.00085C5.04798 7.63266 4.74951 7.33418 4.38132 7.33418H3.70536C3.99267 5.46812 5.46715 3.99364 7.3332 3.70632V4.38229C7.3332 4.75048 7.63168 5.04896 7.99987 5.04896C8.36806 5.04896 8.66654 4.75048 8.66654 4.38229V3.70632C10.5326 3.99364 12.0071 5.46812 12.2944 7.33418Z', @@ -729,6 +734,11 @@ export const ICON_DATA_BY_NAME: Record< 'M10.8307 8.3335L1.66406 31.6668H4.78906L7.16406 25.4168H17.8307L20.2057 31.6668H23.3307L14.1641 8.3335H10.8307ZM16.8307 22.7502H8.16406L12.4141 11.4585H12.5807L16.8307 22.7502ZM30.1577 16.6668L24.1641 31.6668H26.2073L27.7602 27.649H34.7346L36.2875 31.6668H38.3307L32.3371 16.6668H30.1577ZM34.0807 25.9347H28.4141L31.1929 18.6758H31.3019L34.0807 25.9347Z', viewBox: '0 0 40 40', }, + 'thumbs-down': { + path: + 'M2.99062 18.9525C2.23062 18.9525 1.56562 18.6675 0.995625 18.0975C0.425625 17.5275 0.140625 16.8625 0.140625 16.1025V13.2525C0.140625 13.0862 0.164375 12.9081 0.211875 12.7181C0.259375 12.5281 0.306875 12.35 0.354375 12.1837L4.62937 2.13749C4.84312 1.66249 5.19938 1.25874 5.69812 0.92624C6.19688 0.59374 6.71938 0.42749 7.26562 0.42749H22.9406V18.9525L14.3906 27.4312C14.0344 27.7875 13.6128 27.9953 13.1259 28.0547C12.6391 28.1141 12.17 28.025 11.7188 27.7875C11.2675 27.55 10.935 27.2175 10.7212 26.79C10.5075 26.3625 10.46 25.9231 10.5787 25.4719L12.1819 18.9525H2.99062ZM20.0906 17.7412V3.27749H7.26562L2.99062 13.2525V16.1025H15.8156L13.8919 23.94L20.0906 17.7412ZM27.2156 0.42749C27.9994 0.42749 28.6703 0.706553 29.2284 1.26468C29.7866 1.8228 30.0656 2.49374 30.0656 3.27749V16.1025C30.0656 16.8862 29.7866 17.5572 29.2284 18.1153C28.6703 18.6734 27.9994 18.9525 27.2156 18.9525H22.9406V16.1025H27.2156V3.27749H22.9406V0.42749H27.2156Z', + viewBox: '0 0 31 29', + }, 'tip-position': { path: 'M10.75 2H9.25V4.75H10.75V2ZM10.75 9.25V7.25H9.25V9.25H7.25V10.75H9.25V12.75H10.75V10.75H12.75V9.25H10.75ZM10.75 18V15.25H9.25V18H10.75ZM2 9.25V10.75H4.75V9.25H2ZM18 9.25H15.25V10.75H18V9.25Z', diff --git a/components/src/molecules/DropdownMenu/index.tsx b/components/src/molecules/DropdownMenu/index.tsx index c6aa7abc1c2..5dfa6a985f4 100644 --- a/components/src/molecules/DropdownMenu/index.tsx +++ b/components/src/molecules/DropdownMenu/index.tsx @@ -69,6 +69,8 @@ export interface DropdownMenuProps { onBlur?: React.FocusEventHandler /** optional disabled */ disabled?: boolean + /** force direction for pd after release this will be fixed and remove */ + forceDirection?: boolean } // TODO: (smb: 4/15/22) refactor this to use html select for accessibility @@ -88,6 +90,7 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { disabled = false, onFocus, onBlur, + forceDirection = false, } = props const [targetProps, tooltipProps] = useHoverTooltip() const [showDropdownMenu, setShowDropdownMenu] = React.useState(false) @@ -105,6 +108,7 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { }) React.useEffect(() => { + if (forceDirection) return const handlePositionCalculation = (): void => { const dropdownRect = dropDownMenuWrapperRef.current?.getBoundingClientRect() if (dropdownRect != null) { diff --git a/opentrons-ai-client/src/assets/images/modules/MagneticBlock_GEN1_HERO.png b/opentrons-ai-client/src/assets/images/modules/MagneticBlock_GEN1_HERO.png new file mode 100644 index 00000000000..180dd977498 Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/MagneticBlock_GEN1_HERO.png differ diff --git a/opentrons-ai-client/src/assets/images/modules/heater_shaker_module_transparent.png b/opentrons-ai-client/src/assets/images/modules/heater_shaker_module_transparent.png new file mode 100644 index 00000000000..beefd651e45 Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/heater_shaker_module_transparent.png differ diff --git a/opentrons-ai-client/src/assets/images/modules/heatershaker.png b/opentrons-ai-client/src/assets/images/modules/heatershaker.png new file mode 100644 index 00000000000..1df848f09e1 Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/heatershaker.png differ diff --git a/opentrons-ai-client/src/assets/images/modules/mag_block.png b/opentrons-ai-client/src/assets/images/modules/mag_block.png new file mode 100644 index 00000000000..474f5775dd5 Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/mag_block.png differ diff --git a/opentrons-ai-client/src/assets/images/modules/magdeck_gen1.png b/opentrons-ai-client/src/assets/images/modules/magdeck_gen1.png new file mode 100644 index 00000000000..7243441981a Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/magdeck_gen1.png differ diff --git a/opentrons-ai-client/src/assets/images/modules/magdeck_gen2.png b/opentrons-ai-client/src/assets/images/modules/magdeck_gen2.png new file mode 100644 index 00000000000..ec8bd0d0d79 Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/magdeck_gen2.png differ diff --git a/opentrons-ai-client/src/assets/images/modules/magdeck_tempdeck_combined.png b/opentrons-ai-client/src/assets/images/modules/magdeck_tempdeck_combined.png new file mode 100644 index 00000000000..c2dbc55a869 Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/magdeck_tempdeck_combined.png differ diff --git a/opentrons-ai-client/src/assets/images/modules/module_pipette_collision_warning.png b/opentrons-ai-client/src/assets/images/modules/module_pipette_collision_warning.png new file mode 100644 index 00000000000..788d4b5b932 Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/module_pipette_collision_warning.png differ diff --git a/opentrons-ai-client/src/assets/images/modules/temp_deck_gen_2_transparent.png b/opentrons-ai-client/src/assets/images/modules/temp_deck_gen_2_transparent.png new file mode 100644 index 00000000000..6f8799ea74a Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/temp_deck_gen_2_transparent.png differ diff --git a/opentrons-ai-client/src/assets/images/modules/tempdeck_gen1.png b/opentrons-ai-client/src/assets/images/modules/tempdeck_gen1.png new file mode 100644 index 00000000000..668c1d7d911 Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/tempdeck_gen1.png differ diff --git a/opentrons-ai-client/src/assets/images/modules/tempdeck_gen2.png b/opentrons-ai-client/src/assets/images/modules/tempdeck_gen2.png new file mode 100644 index 00000000000..aaf5948e2ee Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/tempdeck_gen2.png differ diff --git a/opentrons-ai-client/src/assets/images/modules/thermocycler.png b/opentrons-ai-client/src/assets/images/modules/thermocycler.png new file mode 100644 index 00000000000..fdae9b79c49 Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/thermocycler.png differ diff --git a/opentrons-ai-client/src/assets/images/modules/thermocycler_gen2.png b/opentrons-ai-client/src/assets/images/modules/thermocycler_gen2.png new file mode 100644 index 00000000000..e17a723c5d9 Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/thermocycler_gen2.png differ diff --git a/opentrons-ai-client/src/assets/localization/en/create_protocol.json b/opentrons-ai-client/src/assets/localization/en/create_protocol.json index 55c225ba35b..6c891525041 100644 --- a/opentrons-ai-client/src/assets/localization/en/create_protocol.json +++ b/opentrons-ai-client/src/assets/localization/en/create_protocol.json @@ -28,6 +28,13 @@ "flex_gripper": "Flex Gripper", "flex_gripper_no_label": "No, do not use the Flex Gripper", "modules_title": "Modules", + "no_modules_added_yet": "No modules added yet", + "modules_remove_label": "remove", + "modules_adapter_label": "Adapter", + "heater_shaker_module_v1": "Heater-Shaker Module GEN1", + "temperature_module_v2": "Temperature Module GEN2", + "thermocycler_module_v2": "Thermocycler Module GEN2", + "magnetic_module_v1": "Magnetic Block GEN1", "labware_liquids_title": "Labware & Liquids", "steps_title": "Steps" } 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 e3e23b4f0d3..e321b939ade 100644 --- a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json +++ b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json @@ -3,6 +3,7 @@ "api": "API: An API level is 2.15", "application": "Application: Your protocol's name, describing what it does.", "commands": "Commands: List the protocol's steps, specifying quantities in microliters (uL) and giving exact source and destination locations.", + "cancel": "Cancel", "copyright": "Copyright © 2024 Opentrons", "copy_code": "Copy code", "choose_file": "Choose file", @@ -51,6 +52,9 @@ "robot_type": "Robot type: Choose the OT-2 or Opentrons Flex.", "robot": "Robot: OT-2.", "share_your_thoughts": "Share your thoughts here", + "send_feedback": "Send feedback", + "send_feedback_input_title": "Share why the response was not helpful", + "send_feedback_to_opentrons": "Send feedback to Opentrons", "side_panel_body": "Write a prompt in natural language to generate a Reagent Transfer or a PCR protocol for the OT-2 or Opentrons Flex using the Opentrons Python Protocol API.", "side_panel_header": "Use natural language to generate protocols with OpentronsAI powered by OpenAI", "simulate_description": "Once OpentronsAI has written your protocol, type `simulate` in the prompt box to try it out.", diff --git a/opentrons-ai-client/src/molecules/ChatDisplay/__tests__/ChatDisplay.test.tsx b/opentrons-ai-client/src/molecules/ChatDisplay/__tests__/ChatDisplay.test.tsx index afec6d800cc..7836d18f90f 100644 --- a/opentrons-ai-client/src/molecules/ChatDisplay/__tests__/ChatDisplay.test.tsx +++ b/opentrons-ai-client/src/molecules/ChatDisplay/__tests__/ChatDisplay.test.tsx @@ -5,9 +5,24 @@ import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' import { ChatDisplay } from '../index' +import { useForm, FormProvider } from 'react-hook-form' + +const RenderChatDisplay = (props: React.ComponentProps) => { + const methods = useForm({ + defaultValues: {}, + }) + + return ( + + + + ) +} const render = (props: React.ComponentProps) => { - return renderWithProviders(, { i18nInstance: i18n }) + return renderWithProviders(, { + i18nInstance: i18n, + }) } describe('ChatDisplay', () => { @@ -18,6 +33,7 @@ describe('ChatDisplay', () => { chat: { role: 'assistant', reply: 'mock text from the backend', + requestId: '12351234', }, chatId: 'mockId', } @@ -35,6 +51,7 @@ describe('ChatDisplay', () => { chat: { role: 'user', reply: 'mock text from user input', + requestId: '12351234', }, chatId: 'mockId', } diff --git a/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx b/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx index dd39d415acc..22dbee37f1a 100644 --- a/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx +++ b/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import styled from 'styled-components' import { useTranslation } from 'react-i18next' import Markdown from 'react-markdown' @@ -12,28 +12,81 @@ import { JUSTIFY_CENTER, JUSTIFY_FLEX_END, JUSTIFY_FLEX_START, - POSITION_ABSOLUTE, POSITION_RELATIVE, - PrimaryButton, SPACING, LegacyStyledText, TYPOGRAPHY, + StyledText, + DIRECTION_ROW, OVERFLOW_AUTO, } from '@opentrons/components' import type { ChatData } from '../../resources/types' +import { useAtom } from 'jotai' +import { + chatDataAtom, + feedbackModalAtom, + scrollToBottomAtom, +} from '../../resources/atoms' +import { delay } from 'lodash' +import { useFormContext } from 'react-hook-form' interface ChatDisplayProps { chat: ChatData chatId: string } +const HoverShadow = styled(Flex)` + alignitems: ${ALIGN_CENTER}; + justifycontent: ${JUSTIFY_CENTER}; + padding: ${SPACING.spacing8}; + transition: box-shadow 0.3s ease; + border-radius: ${BORDERS.borderRadius8}; + + &:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + border-radius: ${BORDERS.borderRadius8}; + } +` + +const StyledIcon = styled(Icon)` + color: ${COLORS.blue50}; +` + export function ChatDisplay({ chat, chatId }: ChatDisplayProps): JSX.Element { const { t } = useTranslation('protocol_generator') const [isCopied, setIsCopied] = useState(false) - const { role, reply } = chat + const [, setShowFeedbackModal] = useAtom(feedbackModalAtom) + const { setValue } = useFormContext() + const [chatdata] = useAtom(chatDataAtom) + const [scrollToBottom, setScrollToBottom] = useAtom(scrollToBottomAtom) + const { role, reply, requestId } = chat const isUser = role === 'user' + const setInputFieldToCorrespondingRequest = (): void => { + const prompt = chatdata.find( + chat => chat.role === 'user' && chat.requestId === requestId + )?.reply + setScrollToBottom(!scrollToBottom) + setValue('userPrompt', prompt) + } + + const handleFileDownload = (): void => { + const lastCodeBlock = document.querySelector(`#${chatId}`) + const code = lastCodeBlock?.textContent ?? '' + const blobParts: BlobPart[] = [code] + + const file = new File(blobParts, 'OpentronsAI.py', { type: 'text/python' }) + const url = URL.createObjectURL(file) + const a = document.createElement('a') + + document.body.appendChild(a) + a.href = url + a.download = 'OpentronsAI.py' + a.click() + window.URL.revokeObjectURL(url) + } + const handleClickCopy = async (): Promise => { const lastCodeBlock = document.querySelector(`#${chatId}`) const code = lastCodeBlock?.textContent ?? '' @@ -41,28 +94,32 @@ export function ChatDisplay({ chat, chatId }: ChatDisplayProps): JSX.Element { setIsCopied(true) } + useEffect(() => { + if (isCopied) + delay(() => { + setIsCopied(false) + }, 2000) + }, [isCopied]) + function CodeText(props: JSX.IntrinsicAttributes): JSX.Element { return } return ( - + - + {isUser ? t('you') : t('opentronsai')} - + {/* text should be markdown so this component will have a package or function to parse markdown */} {!isUser ? ( - - - { + setInputFieldToCorrespondingRequest() + }} + > + + + { + setShowFeedbackModal(true) + }} + > + + + { + await handleClickCopy() + }} + > + - - + + { + handleFileDownload() + }} + > + + + ) : null} diff --git a/opentrons-ai-client/src/molecules/ChatFooter/index.tsx b/opentrons-ai-client/src/molecules/ChatFooter/index.tsx index b477da1dacd..fef7596f6f4 100644 --- a/opentrons-ai-client/src/molecules/ChatFooter/index.tsx +++ b/opentrons-ai-client/src/molecules/ChatFooter/index.tsx @@ -15,9 +15,9 @@ export function ChatFooter(): JSX.Element { return ( @@ -32,5 +32,4 @@ const DISCLAIMER_TEXT_STYLE = css` font-size: ${TYPOGRAPHY.fontSize20}; line-height: ${TYPOGRAPHY.lineHeight24}; text-align: ${TYPOGRAPHY.textAlignCenter}; - padding-bottom: ${SPACING.spacing24}; ` diff --git a/opentrons-ai-client/src/molecules/ControlledEmptySelectorButtonGroup/__tests__/ControlledEmptySelectorButtonGroup.test.tsx b/opentrons-ai-client/src/molecules/ControlledEmptySelectorButtonGroup/__tests__/ControlledEmptySelectorButtonGroup.test.tsx new file mode 100644 index 00000000000..efb82f1a482 --- /dev/null +++ b/opentrons-ai-client/src/molecules/ControlledEmptySelectorButtonGroup/__tests__/ControlledEmptySelectorButtonGroup.test.tsx @@ -0,0 +1,74 @@ +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { ControlledEmptySelectorButtonGroup } from '../index' +import { describe, it, expect } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { FormProvider, useForm } from 'react-hook-form' +import { MODULES_FIELD_NAME } from '../../../organisms/ModulesSection' +import type { DisplayModules } from '../../../organisms/ModulesSection' + +const modulesMock: DisplayModules[] = [ + { + type: 'heaterShakerModuleType', + model: 'heaterShakerModuleV1', + name: 'Heater-Shaker Module GEN1', + }, + { + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + name: 'Temperature Module GEN2', + }, +] + +const TestFormProviderComponent = () => { + const methods = useForm({}) + + const selectedValue = methods.watch(MODULES_FIELD_NAME) ?? [] + + return ( + + + + {'selected values: ' + selectedValue.map((m: DisplayModules) => m.name)} + + ) +} + +const render = (): ReturnType => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('ControlledEmptySelectorButtonGroup', () => { + it('should render ControlledEmptySelectorButtonGroup component', () => { + render() + + screen.getByText('Heater-Shaker Module GEN1') + screen.getByText('Temperature Module GEN2') + }) + + it('should add the value when the button is clicked', async () => { + render() + + const button1 = screen.getByText('Heater-Shaker Module GEN1') + + expect( + screen.queryByText( + 'selected values: Heater-Shaker Module GEN1,Temperature Module GEN2' + ) + ).not.toBeInTheDocument() + + fireEvent.click(button1) + + const button2 = screen.getByText('Temperature Module GEN2') + + fireEvent.click(button2) + + expect( + await screen.findByText( + 'selected values: Heater-Shaker Module GEN1,Temperature Module GEN2' + ) + ).toBeInTheDocument() + }) +}) diff --git a/opentrons-ai-client/src/molecules/ControlledEmptySelectorButtonGroup/index.tsx b/opentrons-ai-client/src/molecules/ControlledEmptySelectorButtonGroup/index.tsx new file mode 100644 index 00000000000..ad9791f1fcd --- /dev/null +++ b/opentrons-ai-client/src/molecules/ControlledEmptySelectorButtonGroup/index.tsx @@ -0,0 +1,40 @@ +import { Flex, WRAP, SPACING, EmptySelectorButton } from '@opentrons/components' +import { Controller, useFormContext } from 'react-hook-form' +import type { DisplayModules } from '../../organisms/ModulesSection' +import { MODULES_FIELD_NAME } from '../../organisms/ModulesSection' + +export function ControlledEmptySelectorButtonGroup({ + modules, +}: { + modules: DisplayModules[] +}): JSX.Element | null { + const { watch } = useFormContext() + const modulesWatch: DisplayModules[] = watch(MODULES_FIELD_NAME) ?? [] + + return ( + { + return ( + + {modules.map(module => ( + { + if (modulesWatch.some(m => m.type === module.type)) { + return + } + field.onChange([...modulesWatch, module]) + }} + text={module.name} + textAlignment="left" + /> + ))} + + ) + }} + /> + ) +} diff --git a/opentrons-ai-client/src/molecules/FeedbackModal/__tests__/FeedbackModal.test.tsx b/opentrons-ai-client/src/molecules/FeedbackModal/__tests__/FeedbackModal.test.tsx new file mode 100644 index 00000000000..15d17938e93 --- /dev/null +++ b/opentrons-ai-client/src/molecules/FeedbackModal/__tests__/FeedbackModal.test.tsx @@ -0,0 +1,36 @@ +import { FeedbackModal } from '..' +import { renderWithProviders } from '../../../__testing-utils__' +import { screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import { i18n } from '../../../i18n' +import { feedbackModalAtom } from '../../../resources/atoms' + +const initialValues: Array<[any, any]> = [[feedbackModalAtom, true]] + +const render = (): ReturnType => { + return renderWithProviders(, { + i18nInstance: i18n, + initialValues, + }) +} + +describe('FeedbackModal', () => { + it('should render Feedback modal', () => { + render() + screen.getByText('Send feedback to Opentrons') + screen.getByText('Share why the response was not helpful') + screen.getByText('Cancel') + screen.getByText('Send feedback') + }) + + // should move this test to the chat page + it.skip('should set the showFeedbackModel atom to be false when cancel button is clicked', () => { + render() + expect(feedbackModalAtom.init).toBe(true) + + const cancelButton = screen.getByText('Cancel') + cancelButton.click() + // check if the feedbackModalAtom is set to false + expect(feedbackModalAtom.read).toBe(false) + }) +}) diff --git a/opentrons-ai-client/src/molecules/FeedbackModal/index.tsx b/opentrons-ai-client/src/molecules/FeedbackModal/index.tsx new file mode 100644 index 00000000000..e65aa7a504c --- /dev/null +++ b/opentrons-ai-client/src/molecules/FeedbackModal/index.tsx @@ -0,0 +1,65 @@ +import { + Modal, + Flex, + SPACING, + ALIGN_FLEX_END, + SecondaryButton, + StyledText, + PrimaryButton, + InputField, +} from '@opentrons/components' +import { useAtom } from 'jotai' +import { useTranslation } from 'react-i18next' +import { feedbackModalAtom } from '../../resources/atoms' +import { useState } from 'react' + +export function FeedbackModal(): JSX.Element { + const { t } = useTranslation('protocol_generator') + + const [feedbackValue, setFeedbackValue] = useState('') + const [, setShowFeedbackModal] = useAtom(feedbackModalAtom) + + return ( + { + setShowFeedbackModal(false) + }} + footer={ + + { + setShowFeedbackModal(false) + }} + > + + {t(`cancel`)} + + + { + setShowFeedbackModal(false) + }} + > + + {t(`send_feedback`)} + + + + } + > + { + setFeedbackValue(event.target.value as string) + }} + > + + ) +} diff --git a/opentrons-ai-client/src/molecules/InputPrompt/index.tsx b/opentrons-ai-client/src/molecules/InputPrompt/index.tsx index 70ee01560f4..c87d24f1975 100644 --- a/opentrons-ai-client/src/molecules/InputPrompt/index.tsx +++ b/opentrons-ai-client/src/molecules/InputPrompt/index.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' import { useFormContext } from 'react-hook-form' import { useAtom } from 'jotai' +import { v4 as uuidv4 } from 'uuid' import { ALIGN_CENTER, @@ -42,13 +43,21 @@ export function InputPrompt(): JSX.Element { const [submitted, setSubmitted] = useState(false) const userPrompt = watch('userPrompt') ?? '' const { data, isLoading, callApi } = useApiCall() + const [requestId, setRequestId] = useState(uuidv4()) + + // This is to autofill the input field for when we navigate to the chat page from the existing/new protocol generator pages + useEffect(() => { + setValue('userPrompt', chatPromptAtomValue) + }, [chatPromptAtomValue, setValue]) useEffect(() => { setValue('userPrompt', chatPromptAtomValue) }, [chatPromptAtomValue, setValue]) const handleClick = async (): Promise => { + setRequestId(uuidv4()) const userInput: ChatData = { + requestId, role: 'user', reply: userPrompt, } @@ -100,6 +109,7 @@ export function InputPrompt(): JSX.Element { if (submitted && data != null && !isLoading) { const { role, reply } = data as ChatData const assistantResponse: ChatData = { + requestId, role, reply, } @@ -166,6 +176,7 @@ const LegacyStyledTextarea = styled.textarea` font-size: ${TYPOGRAPHY.fontSize20}; line-height: ${TYPOGRAPHY.lineHeight24}; padding: 1.2rem 0; + font-size: 1rem; ::placeholder { position: absolute; diff --git a/opentrons-ai-client/src/molecules/ModelDiagram/index.tsx b/opentrons-ai-client/src/molecules/ModelDiagram/index.tsx new file mode 100644 index 00000000000..e4211e7df98 --- /dev/null +++ b/opentrons-ai-client/src/molecules/ModelDiagram/index.tsx @@ -0,0 +1,80 @@ +import { css } from 'styled-components' +import { + MAGNETIC_MODULE_TYPE, + TEMPERATURE_MODULE_TYPE, + THERMOCYCLER_MODULE_TYPE, + MAGNETIC_MODULE_V1, + MAGNETIC_MODULE_V2, + TEMPERATURE_MODULE_V1, + TEMPERATURE_MODULE_V2, + THERMOCYCLER_MODULE_V1, + HEATERSHAKER_MODULE_TYPE, + HEATERSHAKER_MODULE_V1, + THERMOCYCLER_MODULE_V2, + MAGNETIC_BLOCK_TYPE, + MAGNETIC_BLOCK_V1, + ABSORBANCE_READER_TYPE, + ABSORBANCE_READER_V1, +} from '@opentrons/shared-data' + +import magdeck_gen1 from '../../assets/images/modules/magdeck_gen1.png' +import magdeck_gen2 from '../../assets/images/modules/magdeck_gen2.png' +import tempdeck_gen1 from '../../assets/images/modules/tempdeck_gen1.png' +import temp_deck_gen_2_transparent from '../../assets/images/modules/temp_deck_gen_2_transparent.png' +import thermocycler from '../../assets/images/modules/thermocycler.png' +import thermocycler_gen2 from '../../assets/images/modules/thermocycler_gen2.png' +import heater_shaker_module_transparent from '../../assets/images/modules/heater_shaker_module_transparent.png' +import mag_block from '../../assets/images/modules/MagneticBlock_GEN1_HERO.png' +import type { ModuleType, ModuleModel } from '@opentrons/shared-data' + +interface Props { + type: ModuleType + model: ModuleModel +} + +type ModuleImg = { + [type in ModuleType]: { + [model in ModuleModel]?: string + } +} + +const MODULE_IMG_BY_TYPE: ModuleImg = { + [MAGNETIC_MODULE_TYPE]: { + [MAGNETIC_MODULE_V1]: magdeck_gen1, + [MAGNETIC_MODULE_V2]: magdeck_gen2, + }, + [TEMPERATURE_MODULE_TYPE]: { + [TEMPERATURE_MODULE_V1]: tempdeck_gen1, + [TEMPERATURE_MODULE_V2]: temp_deck_gen_2_transparent, + }, + [THERMOCYCLER_MODULE_TYPE]: { + [THERMOCYCLER_MODULE_V1]: thermocycler, + [THERMOCYCLER_MODULE_V2]: thermocycler_gen2, + }, + [HEATERSHAKER_MODULE_TYPE]: { + [HEATERSHAKER_MODULE_V1]: heater_shaker_module_transparent, + }, + [MAGNETIC_BLOCK_TYPE]: { + [MAGNETIC_BLOCK_V1]: mag_block, + }, + [ABSORBANCE_READER_TYPE]: { + // TODO (AA): update absorbance reader image + [ABSORBANCE_READER_V1]: heater_shaker_module_transparent, + }, +} + +const IMAGE_MAX_WIDTH = '96px' +export function ModuleDiagram(props: Props): JSX.Element { + const model = MODULE_IMG_BY_TYPE[props.type][props.model] + return ( + {props.type} + ) +} diff --git a/opentrons-ai-client/src/molecules/ModuleListItemGroup/__tests__/ModuleListItemGroup.test.tsx b/opentrons-ai-client/src/molecules/ModuleListItemGroup/__tests__/ModuleListItemGroup.test.tsx new file mode 100644 index 00000000000..5c04e3a6b44 --- /dev/null +++ b/opentrons-ai-client/src/molecules/ModuleListItemGroup/__tests__/ModuleListItemGroup.test.tsx @@ -0,0 +1,93 @@ +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { ModuleListItemGroup } from '../index' +import { describe, it, expect } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { FormProvider, useForm } from 'react-hook-form' +import type { DisplayModules } from '../../../organisms/ModulesSection' + +const modulesMock: DisplayModules[] = [ + { + type: 'heaterShakerModuleType', + model: 'heaterShakerModuleV1', + name: 'Heater-Shaker Module GEN1', + }, + { + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + name: 'Temperature Module GEN2', + }, +] + +const TestFormProviderComponent = () => { + const methods = useForm({ + defaultValues: { + modules: modulesMock, + }, + }) + + return ( + + + + ) +} + +const render = (): ReturnType => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('ModuleListItemGroup', () => { + it('should render ModuleListItemGroup component', () => { + render() + + expect(screen.getAllByText('Adapter').length).toBe(2) + expect(screen.getAllByText('remove').length).toBe(2) + + screen.getByAltText('heaterShakerModuleType') + screen.getByText('Heater-Shaker Module GEN1') + + screen.getByAltText('temperatureModuleType') + screen.getByText('Temperature Module GEN2') + }) + + it('should remove the list item if remove is clicked', async () => { + render() + + const removeListItemButton = screen.getAllByText('remove')[0] + + fireEvent.click(removeListItemButton) + + expect( + screen.queryByText('Heater-Shaker Module GEN1') + ).not.toBeInTheDocument() + }) + + it('should render the dropdown if adapters are available', () => { + render() + + expect(screen.getAllByText('Choose an adapter').length).toBe(2) + }) + + it('should be able to select an adapter', () => { + render() + + const dropdownButton = screen.getAllByText('Choose an adapter')[1] + + fireEvent.click(dropdownButton) + + const adapterOption = screen.getByText( + 'Opentrons 24 Well Aluminum Block with Generic 2 mL Screwcap' + ) + + fireEvent.click(adapterOption) + + expect( + screen.getByText( + 'Opentrons 24 Well Aluminum Block with Generic 2 mL Screwcap' + ) + ).toBeInTheDocument() + }) +}) diff --git a/opentrons-ai-client/src/molecules/ModuleListItemGroup/index.tsx b/opentrons-ai-client/src/molecules/ModuleListItemGroup/index.tsx new file mode 100644 index 00000000000..878600fc97f --- /dev/null +++ b/opentrons-ai-client/src/molecules/ModuleListItemGroup/index.tsx @@ -0,0 +1,173 @@ +import { + Flex, + SPACING, + ALIGN_CENTER, + BORDERS, + COLORS, + ListItem, + ListItemCustomize, +} from '@opentrons/components' +import type { DropdownBorder } from '@opentrons/components' +import { + ABSORBANCE_READER_TYPE, + getAllDefinitions, + getModuleDisplayName, + HEATERSHAKER_MODULE_TYPE, + MAGNETIC_BLOCK_TYPE, + MAGNETIC_MODULE_TYPE, + TEMPERATURE_MODULE_TYPE, + THERMOCYCLER_MODULE_TYPE, +} from '@opentrons/shared-data' +import type { ModuleType } from '@opentrons/shared-data' +import { Controller, useFormContext } from 'react-hook-form' +import { ModuleDiagram } from '../ModelDiagram' +import { MODULES_FIELD_NAME } from '../../organisms/ModulesSection' +import type { DisplayModules } from '../../organisms/ModulesSection' +import { useTranslation } from 'react-i18next' +import { useMemo } from 'react' + +export const RECOMMENDED_LABWARE_BY_MODULE: { [K in ModuleType]: string[] } = { + [TEMPERATURE_MODULE_TYPE]: [ + 'opentrons_24_aluminumblock_generic_2ml_screwcap', + 'opentrons_96_well_aluminum_block', + 'opentrons_96_aluminumblock_generic_pcr_strip_200ul', + 'opentrons_24_aluminumblock_nest_1.5ml_screwcap', + 'opentrons_24_aluminumblock_nest_1.5ml_snapcap', + 'opentrons_24_aluminumblock_nest_2ml_screwcap', + 'opentrons_24_aluminumblock_nest_2ml_snapcap', + 'opentrons_24_aluminumblock_nest_0.5ml_screwcap', + 'opentrons_aluminum_flat_bottom_plate', + 'opentrons_96_deep_well_temp_mod_adapter', + ], + [MAGNETIC_MODULE_TYPE]: [ + 'nest_96_wellplate_100ul_pcr_full_skirt', + 'nest_96_wellplate_2ml_deep', + 'opentrons_96_wellplate_200ul_pcr_full_skirt', + ], + [THERMOCYCLER_MODULE_TYPE]: [ + 'nest_96_wellplate_100ul_pcr_full_skirt', + 'opentrons_96_wellplate_200ul_pcr_full_skirt', + ], + [HEATERSHAKER_MODULE_TYPE]: [ + 'opentrons_96_deep_well_adapter', + 'opentrons_96_flat_bottom_adapter', + 'opentrons_96_pcr_adapter', + 'opentrons_universal_flat_adapter', + ], + [MAGNETIC_BLOCK_TYPE]: [ + 'nest_96_wellplate_100ul_pcr_full_skirt', + 'nest_96_wellplate_2ml_deep', + 'opentrons_96_wellplate_200ul_pcr_full_skirt', + ], + [ABSORBANCE_READER_TYPE]: [ + 'opentrons_flex_lid_absorbance_plate_reader_module', + ], +} + +export function ModuleListItemGroup(): JSX.Element | null { + const { watch, setValue } = useFormContext() + const { t } = useTranslation('create_protocol') + const modulesWatch: DisplayModules[] = watch(MODULES_FIELD_NAME) ?? [] + + const allDefinitionsValues = useMemo( + () => Object.values(getAllDefinitions()), + [] + ) + + const getDefDisplayName = (value: string): string => { + return ( + allDefinitionsValues.find(def => def.parameters.loadName === value) + ?.metadata.displayName ?? value + ) + } + + return ( + <> + {modulesWatch?.map(module => { + const adapters = RECOMMENDED_LABWARE_BY_MODULE[module.type] + + return ( + { + const currentModule = field.value.find( + (m: DisplayModules) => m.type === module.type + ) + + return ( + + 0 + ? t('modules_adapter_label') + : undefined + } + linkText={t('modules_remove_label')} + dropdown={ + adapters != null && adapters.length > 0 + ? { + title: (null as unknown) as string, + currentOption: { + name: + getDefDisplayName( + currentModule?.adapter?.value as string + ) ?? 'Choose an adapter', + value: currentModule?.adapter?.value, + }, + onClick: (value: string) => { + field.onChange( + field.value.map((m: DisplayModules) => + m.type === module.type + ? { + ...m, + adapter: { + name: getDefDisplayName(value), + value, + }, + } + : m + ) + ) + }, + dropdownType: 'neutral' as DropdownBorder, + filterOptions: adapters?.map(adapter => ({ + name: getDefDisplayName(adapter), + value: adapter, + })), + } + : undefined + } + onClick={() => { + setValue( + MODULES_FIELD_NAME, + modulesWatch.filter(m => m.type !== module.type), + { shouldValidate: true } + ) + }} + header={getModuleDisplayName(module.model)} + leftHeaderItem={ + + + + } + /> + + ) + }} + /> + ) + })} + + ) +} diff --git a/opentrons-ai-client/src/molecules/PromptPreview/index.tsx b/opentrons-ai-client/src/molecules/PromptPreview/index.tsx index d74884ad1ae..ce7687907a8 100644 --- a/opentrons-ai-client/src/molecules/PromptPreview/index.tsx +++ b/opentrons-ai-client/src/molecules/PromptPreview/index.tsx @@ -24,6 +24,7 @@ interface PromptPreviewProps { const PromptPreviewContainer = styled(Flex)` flex-direction: ${DIRECTION_COLUMN}; width: 100%; + max-width: 516px; height: ${SIZE_AUTO}; padding-top: ${SPACING.spacing8}; background-color: ${COLORS.transparent}; @@ -78,7 +79,7 @@ export function PromptPreview({ key={`section-${index}`} title={section.title} items={section.items} - itemMaxWidth={index <= 2 ? '33.33%' : '100%'} + itemMaxWidth={index <= 1 ? '33.33%' : '100%'} /> ) )} diff --git a/opentrons-ai-client/src/organisms/MainContentContainer/index.tsx b/opentrons-ai-client/src/organisms/MainContentContainer/index.tsx deleted file mode 100644 index cc2ad54bc39..00000000000 --- a/opentrons-ai-client/src/organisms/MainContentContainer/index.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useRef, useEffect } from 'react' -import styled from 'styled-components' -import { useAtom } from 'jotai' - -import { - DIRECTION_COLUMN, - Flex, - OVERFLOW_AUTO, - SPACING, -} from '@opentrons/components' -import { PromptGuide } from '../../molecules/PromptGuide' -import { ChatDisplay } from '../../molecules/ChatDisplay' -import { ChatFooter } from '../../molecules/ChatFooter' -import { chatDataAtom } from '../../resources/atoms' - -export function MainContentContainer(): JSX.Element { - const [chatData] = useAtom(chatDataAtom) - const scrollRef = useRef(null) - - useEffect(() => { - if (scrollRef.current != null) - scrollRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'nearest', - }) - }, [chatData.length]) - - return ( - - - - - - - {chatData.length > 0 - ? chatData.map((chat, index) => ( - - )) - : null} - - - - - - - - ) -} - -const ChatDataContainer = styled(Flex)` - flex-direction: ${DIRECTION_COLUMN}; - width: 100%; -` diff --git a/opentrons-ai-client/src/organisms/ModulesSection/__tests__/ModulesSection.test.tsx b/opentrons-ai-client/src/organisms/ModulesSection/__tests__/ModulesSection.test.tsx new file mode 100644 index 00000000000..0a556238930 --- /dev/null +++ b/opentrons-ai-client/src/organisms/ModulesSection/__tests__/ModulesSection.test.tsx @@ -0,0 +1,96 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { FormProvider, useForm } from 'react-hook-form' +import { ModulesSection } from '..' + +const TestFormProviderComponent = () => { + const methods = useForm({ + defaultValues: {}, + }) + + return ( + + + + ) +} + +const render = (): ReturnType => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('ModulesSection', () => { + it('should render modules buttons, no modules added yet, and confirm button', async () => { + render() + + expect(screen.getAllByRole('button').length).toBe(5) + expect(screen.getByText('No modules added yet')).toBeInTheDocument() + expect(screen.getByText('Confirm')).toBeInTheDocument() + }) + + it('should render a list item with the selected module if user clicks the module button', () => { + render() + + const moduleButton = screen.getByText('Heater-Shaker Module GEN1') + fireEvent.click(moduleButton) + + expect(screen.getAllByText('Heater-Shaker Module GEN1').length).toBe(2) + expect(screen.queryByText('No modules added yet')).not.toBeInTheDocument() + }) + + it('should render multiple list items with the selected modules if user clicks multiple module buttons', () => { + render() + + const moduleButton1 = screen.getByText('Heater-Shaker Module GEN1') + fireEvent.click(moduleButton1) + + const moduleButton2 = screen.getByText('Temperature Module GEN2') + fireEvent.click(moduleButton2) + + expect(screen.getAllByText('Heater-Shaker Module GEN1').length).toBe(2) + expect(screen.getAllByText('Temperature Module GEN2').length).toBe(2) + }) + + it('should remove the module list item if user clicks the remove link', () => { + render() + + const moduleButton = screen.getByText('Heater-Shaker Module GEN1') + fireEvent.click(moduleButton) + + expect(screen.getAllByText('Heater-Shaker Module GEN1').length).toBe(2) + + const removeLink = screen.getByText('remove') + fireEvent.click(removeLink) + + expect(screen.getAllByText('Heater-Shaker Module GEN1').length).toBe(1) + }) + + it('should disable confirm button when all fields are not filled', async () => { + render() + + const confirmButton = screen.getByRole('button', { name: 'Confirm' }) + await waitFor(() => { + expect(confirmButton).not.toBeEnabled() + }) + }) + + it('should enable confirm button when all fields are filled', async () => { + render() + + const confirmButton = screen.getByRole('button', { name: 'Confirm' }) + await waitFor(() => { + expect(confirmButton).not.toBeEnabled() + }) + + const moduleButton = screen.getByText('Heater-Shaker Module GEN1') + fireEvent.click(moduleButton) + + await waitFor(() => { + expect(confirmButton).toBeEnabled() + }) + }) +}) diff --git a/opentrons-ai-client/src/organisms/ModulesSection/index.tsx b/opentrons-ai-client/src/organisms/ModulesSection/index.tsx new file mode 100644 index 00000000000..85f068bc226 --- /dev/null +++ b/opentrons-ai-client/src/organisms/ModulesSection/index.tsx @@ -0,0 +1,102 @@ +import { + DIRECTION_COLUMN, + DISPLAY_FLEX, + Flex, + InfoScreen, + JUSTIFY_FLEX_END, + LargeButton, + SPACING, +} from '@opentrons/components' +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' +import { useAtom } from 'jotai' +import { createProtocolAtom } from '../../resources/atoms' +import { MODULES_STEP } from '../ProtocolSectionsContainer' +import { ControlledEmptySelectorButtonGroup } from '../../molecules/ControlledEmptySelectorButtonGroup' +import { ModuleListItemGroup } from '../../molecules/ModuleListItemGroup' +import type { ModuleType, ModuleModel } from '@opentrons/shared-data' + +export interface DisplayModules { + type: ModuleType + model: ModuleModel + name: string + adapter?: { + name: string + value: string + } +} + +export const MODULES_FIELD_NAME = 'modules' + +export function ModulesSection(): JSX.Element | null { + const { t } = useTranslation('create_protocol') + const { + formState: { isValid }, + watch, + } = useFormContext() + const [{ currentStep }, setCreateProtocolAtom] = useAtom(createProtocolAtom) + + const modules: DisplayModules[] = [ + { + type: 'heaterShakerModuleType', + model: 'heaterShakerModuleV1', + name: t('heater_shaker_module_v1'), + }, + { + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + name: t('temperature_module_v2'), + }, + { + type: 'thermocyclerModuleType', + model: 'thermocyclerModuleV2', + name: t('thermocycler_module_v2'), + }, + { + type: 'magneticModuleType', + model: 'magneticModuleV1', + name: t('magnetic_module_v1'), + }, + ] + + function handleConfirmButtonClick(): void { + const step = currentStep > MODULES_STEP ? currentStep : MODULES_STEP + 1 + + setCreateProtocolAtom({ + currentStep: step, + focusStep: step, + }) + } + + const modulesWatch: DisplayModules[] = watch(MODULES_FIELD_NAME) ?? [] + + return ( + + + + {modulesWatch.length === 0 && ( + + )} + + + + + + + + ) +} + +const ButtonContainer = styled.div` + display: ${DISPLAY_FLEX}; + justify-content: ${JUSTIFY_FLEX_END}; +` diff --git a/opentrons-ai-client/src/organisms/ProtocolSectionsContainer/index.tsx b/opentrons-ai-client/src/organisms/ProtocolSectionsContainer/index.tsx index bd54a4201ad..4bbd370c00f 100644 --- a/opentrons-ai-client/src/organisms/ProtocolSectionsContainer/index.tsx +++ b/opentrons-ai-client/src/organisms/ProtocolSectionsContainer/index.tsx @@ -7,6 +7,7 @@ import { createProtocolAtom } from '../../resources/atoms' import { useAtom } from 'jotai' import { useFormContext } from 'react-hook-form' import { InstrumentsSection } from '../InstrumentsSection' +import { ModulesSection } from '../ModulesSection' export const APPLICATION_STEP = 0 export const INSTRUMENTS_STEP = 1 @@ -47,12 +48,12 @@ export function ProtocolSectionsContainer(): JSX.Element | null { { stepNumber: INSTRUMENTS_STEP, title: 'instruments_title', - Component: () => , + Component: InstrumentsSection, }, { stepNumber: MODULES_STEP, title: 'modules_title', - Component: () => Content, + Component: ModulesSection, }, { stepNumber: LABWARE_LIQUIDS_STEP, diff --git a/opentrons-ai-client/src/organisms/MainContentContainer/MainContainer.stories.tsx b/opentrons-ai-client/src/pages/Chat/Chat.stories.tsx similarity index 59% rename from opentrons-ai-client/src/organisms/MainContentContainer/MainContainer.stories.tsx rename to opentrons-ai-client/src/pages/Chat/Chat.stories.tsx index 4f8fe5739fd..3a4d0d674d4 100644 --- a/opentrons-ai-client/src/organisms/MainContentContainer/MainContainer.stories.tsx +++ b/opentrons-ai-client/src/pages/Chat/Chat.stories.tsx @@ -1,12 +1,12 @@ import { I18nextProvider } from 'react-i18next' import { i18n } from '../../i18n' -import { MainContentContainer as MainContentContainerComponent } from './index' +import { Chat as ChatComponent } from './index' import type { Meta, StoryObj } from '@storybook/react' -const meta: Meta = { +const meta: Meta = { title: 'AI/organisms/ChatContainer', - component: MainContentContainerComponent, + component: ChatComponent, decorators: [ Story => ( @@ -16,5 +16,5 @@ const meta: Meta = { ], } export default meta -type Story = StoryObj +type Story = StoryObj export const ChatContainer: Story = {} diff --git a/opentrons-ai-client/src/organisms/MainContentContainer/__tests__/MainContentContainer.test.tsx b/opentrons-ai-client/src/pages/Chat/__tests__/Chat.test.tsx similarity index 68% rename from opentrons-ai-client/src/organisms/MainContentContainer/__tests__/MainContentContainer.test.tsx rename to opentrons-ai-client/src/pages/Chat/__tests__/Chat.test.tsx index d3014a5895b..77874086534 100644 --- a/opentrons-ai-client/src/organisms/MainContentContainer/__tests__/MainContentContainer.test.tsx +++ b/opentrons-ai-client/src/pages/Chat/__tests__/Chat.test.tsx @@ -4,7 +4,7 @@ import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' import { PromptGuide } from '../../../molecules/PromptGuide' import { ChatFooter } from '../../../molecules/ChatFooter' -import { MainContentContainer } from '../index' +import { Chat } from '../index' vi.mock('../../../molecules/PromptGuide') vi.mock('../../../molecules/ChatFooter') @@ -12,20 +12,27 @@ vi.mock('../../../molecules/ChatFooter') window.HTMLElement.prototype.scrollIntoView = vi.fn() const render = (): ReturnType => { - return renderWithProviders(, { + return renderWithProviders(, { i18nInstance: i18n, }) } -describe('MainContentContainer', () => { +describe('Chat', () => { beforeEach(() => { vi.mocked(PromptGuide).mockReturnValue(
mock PromptGuide
) vi.mocked(ChatFooter).mockReturnValue(
mock ChatFooter
) }) - it('should render prompt guide and text', () => { + it('should render footer', () => { render() - screen.getByText('mock PromptGuide') screen.getByText('mock ChatFooter') }) + + it.skip('should not show the feedback modal when loading the page', () => { + render() + screen.getByText('Send feedback to Opentrons') + screen.getByText('Share why the response was not helpful') + screen.getByText('Cancel') + screen.getByText('Send feedback') + }) }) diff --git a/opentrons-ai-client/src/pages/Chat/index.tsx b/opentrons-ai-client/src/pages/Chat/index.tsx index 6d9492038b8..7bedeb8dffe 100644 --- a/opentrons-ai-client/src/pages/Chat/index.tsx +++ b/opentrons-ai-client/src/pages/Chat/index.tsx @@ -1,13 +1,17 @@ import { useForm, FormProvider } from 'react-hook-form' -import { - DIRECTION_COLUMN, - DIRECTION_ROW, - Flex, - JUSTIFY_CENTER, - POSITION_RELATIVE, -} from '@opentrons/components' +import { DIRECTION_COLUMN, Flex, SPACING } from '@opentrons/components' -import { MainContentContainer } from '../../organisms/MainContentContainer' +import { useAtom } from 'jotai' +import { useRef, useEffect } from 'react' +import { + chatDataAtom, + feedbackModalAtom, + scrollToBottomAtom, +} from '../../resources/atoms' +import { ChatDisplay } from '../../molecules/ChatDisplay' +import { ChatFooter } from '../../molecules/ChatFooter' +import styled from 'styled-components' +import { FeedbackModal } from '../../molecules/FeedbackModal' export interface InputType { userPrompt: string @@ -20,19 +24,54 @@ export function Chat(): JSX.Element | null { }, }) + const [chatData] = useAtom(chatDataAtom) + const scrollRef = useRef(null) + const [showFeedbackModal] = useAtom(feedbackModalAtom) + const [scrollToBottom] = useAtom(scrollToBottomAtom) + + useEffect(() => { + if (scrollRef.current != null) + scrollRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }) + }, [chatData.length, scrollToBottom]) + return ( - - - - {/* */} - + + + + + {chatData.length > 0 + ? chatData.map((chat, index) => ( + + )) + : null} + - - + + + {showFeedbackModal ? : null} + + ) } + +const ChatDataContainer = styled(Flex)` + flex-direction: ${DIRECTION_COLUMN}; + width: 100%; +` diff --git a/opentrons-ai-client/src/pages/CreateProtocol/__tests__/CreateProtocol.test.tsx b/opentrons-ai-client/src/pages/CreateProtocol/__tests__/CreateProtocol.test.tsx index d71130d6f1a..9182f1778ca 100644 --- a/opentrons-ai-client/src/pages/CreateProtocol/__tests__/CreateProtocol.test.tsx +++ b/opentrons-ai-client/src/pages/CreateProtocol/__tests__/CreateProtocol.test.tsx @@ -7,6 +7,7 @@ import { Provider } from 'jotai' import { fillApplicationSectionAndClickConfirm, fillInstrumentsSectionAndClickConfirm, + fillModulesSectionAndClickConfirm, } from '../../../resources/utils/createProtocolTestUtils' const render = (): ReturnType => { @@ -119,4 +120,19 @@ describe('CreateProtocol', () => { ) }) }) + + it('should display the Prompt preview correctly for Modules section', async () => { + render() + + await fillApplicationSectionAndClickConfirm() + await fillInstrumentsSectionAndClickConfirm() + await fillModulesSectionAndClickConfirm() + + const previewItems = screen.getAllByTestId('Tag_default') + + expect(previewItems).toHaveLength(7) + expect(previewItems[6]).toHaveTextContent( + 'Heater-Shaker Module GEN1 with Opentrons 96 Deep Well Heater-Shaker Adapter' + ) + }) }) diff --git a/opentrons-ai-client/src/pages/CreateProtocol/index.tsx b/opentrons-ai-client/src/pages/CreateProtocol/index.tsx index 22b3cbcc338..b3bbd83169e 100644 --- a/opentrons-ai-client/src/pages/CreateProtocol/index.tsx +++ b/opentrons-ai-client/src/pages/CreateProtocol/index.tsx @@ -12,6 +12,7 @@ import { createProtocolAtom, headerWithMeterAtom } from '../../resources/atoms' import { useAtom } from 'jotai' import { ProtocolSectionsContainer } from '../../organisms/ProtocolSectionsContainer' import { generatePromptPreviewData } from '../../resources/utils/createProtocolUtils' +import type { DisplayModules } from '../../organisms/ModulesSection' export interface CreateProtocolFormData { application: { @@ -26,6 +27,7 @@ export interface CreateProtocolFormData { rightPipette: string flexGripper: string } + modules: DisplayModules[] } const TOTAL_STEPS = 5 @@ -33,7 +35,7 @@ const TOTAL_STEPS = 5 export function CreateProtocol(): JSX.Element | null { const { t } = useTranslation('create_protocol') const [, setHeaderWithMeterAtom] = useAtom(headerWithMeterAtom) - const [{ currentStep }] = useAtom(createProtocolAtom) + const [{ currentStep }, setCreateProtocolAtom] = useAtom(createProtocolAtom) const methods = useForm({ defaultValues: { @@ -57,6 +59,21 @@ export function CreateProtocol(): JSX.Element | null { }) }, [currentStep]) + useEffect(() => { + return () => { + setHeaderWithMeterAtom({ + displayHeaderWithMeter: false, + progress: 0, + }) + + methods.reset() + setCreateProtocolAtom({ + currentStep: 0, + focusStep: 0, + }) + } + }, []) + return ( ([]) -/** ChatPromptAtom is for the prefilled userprompt when landing on the chat page */ +/** ChatPromptAtom is for the prefilled userprompt when navigating to the chat page from existing/new protocol pages */ export const chatPromptAtom = atom('') +/** Scroll to bottom of chat atom */ +export const scrollToBottomAtom = atom(false) + export const chatHistoryAtom = atom([]) +export const feedbackModalAtom = atom(false) + export const tokenAtom = atom(null) export const mixpanelAtom = atom({ diff --git a/opentrons-ai-client/src/resources/types.ts b/opentrons-ai-client/src/resources/types.ts index 410bdfd98a6..cd6be5dc1b7 100644 --- a/opentrons-ai-client/src/resources/types.ts +++ b/opentrons-ai-client/src/resources/types.ts @@ -8,6 +8,8 @@ export interface ChatData { reply: string /** for testing purpose will be removed and this is not used in the app */ fake?: boolean + /** uuid to map the chat prompt request to the response from the LLM */ + requestId: string } export interface Chat { diff --git a/opentrons-ai-client/src/resources/utils/createProtocolTestUtils.tsx b/opentrons-ai-client/src/resources/utils/createProtocolTestUtils.tsx index cef5ac3ee84..e612afa3dc4 100644 --- a/opentrons-ai-client/src/resources/utils/createProtocolTestUtils.tsx +++ b/opentrons-ai-client/src/resources/utils/createProtocolTestUtils.tsx @@ -33,3 +33,26 @@ export async function fillInstrumentsSectionAndClickConfirm(): Promise { }) fireEvent.click(confirmButton) } + +export async function fillModulesSectionAndClickConfirm(): Promise { + const firstModuleButton = screen.getByText('Heater-Shaker Module GEN1') + fireEvent.click(firstModuleButton) + + expect( + screen.getAllByText('Heater-Shaker Module GEN1')[1] + ).toBeInTheDocument() + + const adapterDropdown = screen.getByText('Choose an adapter') + fireEvent.click(adapterDropdown) + + const adapterOption = screen.getByText( + 'Opentrons 96 Deep Well Heater-Shaker Adapter' + ) + fireEvent.click(adapterOption) + + const confirmButton = screen.getByText('Confirm') + await waitFor(() => { + expect(confirmButton).toBeEnabled() + }) + fireEvent.click(confirmButton) +} diff --git a/opentrons-ai-client/src/resources/utils/createProtocolUtils.tsx b/opentrons-ai-client/src/resources/utils/createProtocolUtils.tsx index bb29912ae80..7e137fef854 100644 --- a/opentrons-ai-client/src/resources/utils/createProtocolUtils.tsx +++ b/opentrons-ai-client/src/resources/utils/createProtocolUtils.tsx @@ -59,6 +59,23 @@ export function generatePromptPreviewInstrumentItems( return items.filter(Boolean) } +export function generatePromptPreviewModulesItems( + watch: UseFormWatch, + t: any +): string[] { + const { modules } = watch() + + if (modules === undefined || modules?.length === 0) return [] + + const items = modules?.map(module => + module.adapter === undefined || module.adapter?.name === '' + ? module.name + : `${module.name} with ${module.adapter.name}` + ) + + return items.filter(Boolean) +} + export function generatePromptPreviewData( watch: UseFormWatch, t: any @@ -75,5 +92,9 @@ export function generatePromptPreviewData( title: t('instruments_title'), items: generatePromptPreviewInstrumentItems(watch, t), }, + { + title: t('modules_title'), + items: generatePromptPreviewModulesItems(watch, t), + }, ] } diff --git a/protocol-designer/src/assets/localization/en/create_new_protocol.json b/protocol-designer/src/assets/localization/en/create_new_protocol.json index 622464e6547..6b0cd5a6ade 100644 --- a/protocol-designer/src/assets/localization/en/create_new_protocol.json +++ b/protocol-designer/src/assets/localization/en/create_new_protocol.json @@ -13,11 +13,11 @@ "incompatible_tip_body": "Using a pipette with an incompatible tip rack may result reduce pipette accuracy and collisions. We strongly recommend that you do not pair a pipette with an incompatible tip rack.", "incompatible_tips": "Incompatible tips", "labware_name": "Labware name", - "left_right": "Left+Right", + "left_right": "Left + Right", "modules_added": "Modules added", "name": "Name", "need_gripper": "Do you want to move labware automatically with the gripper?", - "pip": "{{mount}} Pipette", + "pipette": "{{mount}} Mount", "pipette_gen": "Pipette generation", "pipette_tips": "Pipette tips", "pipette_type": "Pipette type", diff --git a/protocol-designer/src/assets/localization/en/form.json b/protocol-designer/src/assets/localization/en/form.json index c5a1099fbc1..5ed9f9d8331 100644 --- a/protocol-designer/src/assets/localization/en/form.json +++ b/protocol-designer/src/assets/localization/en/form.json @@ -176,6 +176,12 @@ "step_notes": { "label": "Step Notes" }, + "temperature": { + "caption": "Valid range is 4 – 95˚C", + "setTemperature": "Temperature", + "toggleOff": "Deactivate", + "toggleOn": "Set" + }, "thermocyclerAction": { "options": { "profile": "Program a Thermocycler profile", @@ -268,6 +274,7 @@ }, "mixRepetitions": "repetitions", "mixVolumeLabel": "mix volume", + "moduleState": "Module state", "multiDispenseOptionsLabel": "multi-dispense options", "section": { "dropTip": "drop tip", @@ -281,6 +288,7 @@ "columns_aspirate_wells": "Source columns", "columns_dispense_wells": "Destination columns", "columns_mix_wells": "Select columns", + "columns_wells": "Mix wells", "wells": "wells", "wells_aspirate_wells": "Select source wells", "wells_dispense_wells": "Select destination wells", diff --git a/protocol-designer/src/assets/localization/en/modal.json b/protocol-designer/src/assets/localization/en/modal.json index 8b891c0f405..2c065e2d6b6 100644 --- a/protocol-designer/src/assets/localization/en/modal.json +++ b/protocol-designer/src/assets/localization/en/modal.json @@ -55,7 +55,8 @@ "body1": "Welcome to Protocol Designer 8.2.0!", "body2": "We’re excited to release the new Opentrons Protocol Designer, now with a fresh redesign! Enjoy the same functionality with the added ability to:", "body3": "Add multiple Heater-Shaker Modules and Magnetic Blocks to the deck (Flex only).", - "body4": "All protocols now require Opentrons App version 8.0.0+ to run." + "body4": "All protocols now require Opentrons App version 8.2.0+ to run.", + "body5": "For more information, see the Protocol Designer Instruction Manual." } }, "labware_selection": { diff --git a/protocol-designer/src/assets/localization/en/protocol_overview.json b/protocol-designer/src/assets/localization/en/protocol_overview.json index e1975f4e4ef..b03097576cd 100644 --- a/protocol-designer/src/assets/localization/en/protocol_overview.json +++ b/protocol-designer/src/assets/localization/en/protocol_overview.json @@ -8,11 +8,12 @@ "edit_protocol": "Edit protocol", "edit": "Edit", "export_protocol": "Export protocol", - "extension": "Extension mount", + "extension": "Extension Mount", "gripper": "Opentrons Flex Gripper", "instruments": "Instruments", "labware": "Labware", - "left_pip": "Left pipette", + "left_mount": "Left Mount", + "left_right_mount": "Left + Right Mount", "liquid_defs": "Liquid Definitions", "liquids": "Liquids", "materials_list": "Materials list", @@ -28,7 +29,7 @@ "protocol_metadata": "Protocol Metadata", "protocol_steps": "Protocol Steps", "required_app_version": "Required app version", - "right_pip": "Right pipette", + "right_mount": "Right Mount", "robotType": "Robot type", "starting_deck": "Protocol Starting Deck", "steps": "{{count}} steps", diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json index 64a5cdffdcc..bdfbd8a2b36 100644 --- a/protocol-designer/src/assets/localization/en/shared.json +++ b/protocol-designer/src/assets/localization/en/shared.json @@ -44,7 +44,7 @@ "labware_name_conflict": "Duplicate labware name", "labware": "Labware", "learn_more": "Learn more about the recent changes in the {{version}} release.", - "left_right": "Left+Right", + "left_right": "Left + Right", "left": "Left", "liquid": "Liquid", "magneticmoduletype": "Magnetic Module", diff --git a/protocol-designer/src/molecules/DropdownStepFormField/index.tsx b/protocol-designer/src/molecules/DropdownStepFormField/index.tsx index 869d272ddde..39da4a74ca9 100644 --- a/protocol-designer/src/molecules/DropdownStepFormField/index.tsx +++ b/protocol-designer/src/molecules/DropdownStepFormField/index.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next' +import { useEffect } from 'react' import { COLORS, DIRECTION_COLUMN, @@ -36,9 +37,15 @@ export function DropdownStepFormField( const { t } = useTranslation('tooltip') const availableOptionId = options.find(opt => opt.value === value) + useEffect(() => { + if (options.length === 1) { + updateValue(options[0].value) + } + }, []) + return ( - {options.length > 1 ? ( + {options.length > 1 || options.length === 0 ? ( ) diff --git a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx index 3bb253dca9d..5c34b465647 100644 --- a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx +++ b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx @@ -46,7 +46,7 @@ export function ToggleExpandStepFormField( } = props const resetFieldValue = (): void => { - restProps.updateValue('null') + restProps.updateValue(null) } const onToggleUpdateValue = (): void => { diff --git a/protocol-designer/src/organisms/Alerts/FormAlerts.tsx b/protocol-designer/src/organisms/Alerts/FormAlerts.tsx index ad470253e69..f9d7166943d 100644 --- a/protocol-designer/src/organisms/Alerts/FormAlerts.tsx +++ b/protocol-designer/src/organisms/Alerts/FormAlerts.tsx @@ -22,6 +22,7 @@ import { } from '../../pages/Designer/ProtocolSteps/StepForm/utils' import { WarningContents } from './WarningContents' +import type { ReactNode } from 'react' import type { ProfileItem } from '@opentrons/step-generation' import type { StepFieldName } from '../../form-types' import type { ProfileFormError } from '../../steplist/formLevel/profileErrors' @@ -31,10 +32,16 @@ interface FormAlertsProps { showFormErrorsAndWarnings: boolean focusedField?: StepFieldName | null dirtyFields?: StepFieldName[] + page: number +} + +interface WarningType { + title: string + description: ReactNode | null } function FormAlertsComponent(props: FormAlertsProps): JSX.Element | null { - const { showFormErrorsAndWarnings, focusedField, dirtyFields } = props + const { showFormErrorsAndWarnings, focusedField, dirtyFields, page } = props const { t } = useTranslation('alert') const dispatch = useDispatch() @@ -70,6 +77,8 @@ function FormAlertsComponent(props: FormAlertsProps): JSX.Element | null { focusedField, dirtyFields: dirtyFields ?? [], errors: formLevelErrorsForUnsavedForm, + page, + showErrors: showFormErrorsAndWarnings, }) const profileItemsById: Record | null | undefined = @@ -123,21 +132,32 @@ function FormAlertsComponent(props: FormAlertsProps): JSX.Element | null { ) + const filteredFormErrorsForBanner = visibleFormErrors.reduce( + (acc, error) => { + return error.showAtForm ?? true + ? [ + ...acc, + { + title: error.title, + description: error.body ?? null, + }, + ] + : acc + }, + [] + ) + const formErrors = [ - ...visibleFormErrors.map(error => ({ - title: error.title, - description: error.body ?? null, - showAtForm: error.showAtForm ?? true, - })), + ...filteredFormErrorsForBanner, ...visibleDynamicFieldFormErrors.map(error => ({ title: error.title, - description: error.body || null, + description: error.body ?? null, })), ] const formWarnings = visibleFormWarnings.map(warning => ({ title: warning.title, - description: warning.body || null, + description: warning.body ?? null, dismissId: warning.type, })) diff --git a/protocol-designer/src/organisms/Alerts/KnowledgeLink.tsx b/protocol-designer/src/organisms/Alerts/KnowledgeLink.tsx deleted file mode 100644 index f29919ba6ce..00000000000 --- a/protocol-designer/src/organisms/Alerts/KnowledgeLink.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type * as React from 'react' -import { Link } from '@opentrons/components' -import { links } from './linkConstants' - -interface KnowledgeLinkProps { - to: keyof typeof links - children: React.ReactNode -} - -export function KnowledgeLink(props: KnowledgeLinkProps): JSX.Element { - const { to, children } = props - return ( - - {children} - - ) -} diff --git a/protocol-designer/src/organisms/Alerts/__tests__/FormAlerts.test.tsx b/protocol-designer/src/organisms/Alerts/__tests__/FormAlerts.test.tsx index 26c5cdb02ce..5c7428d6996 100644 --- a/protocol-designer/src/organisms/Alerts/__tests__/FormAlerts.test.tsx +++ b/protocol-designer/src/organisms/Alerts/__tests__/FormAlerts.test.tsx @@ -38,6 +38,7 @@ describe('FormAlerts', () => { focusedField: null, dirtyFields: [], showFormErrorsAndWarnings: false, + page: 0, } vi.mocked(getFormLevelErrorsForUnsavedForm).mockReturnValue([]) vi.mocked(getFormWarningsForSelectedStep).mockReturnValue([]) diff --git a/protocol-designer/src/organisms/Alerts/linkConstants.ts b/protocol-designer/src/organisms/Alerts/linkConstants.ts deleted file mode 100644 index 13b251961bd..00000000000 --- a/protocol-designer/src/organisms/Alerts/linkConstants.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const KNOWLEDGEBASE_ROOT_URL = - 'https://support.opentrons.com/s/protocol-designer' - -export const links = { - airGap: `https://support.opentrons.com/en/articles/4398106-air-gap`, - multiDispense: `https://support.opentrons.com/en/articles/4170341-paths`, - protocolSteps: `https://support.opentrons.com/s/protocol-designer?tabset-92ba3=2`, - customLabware: `https://support.opentrons.com/en/articles/3136504-creating-custom-labware-definitions`, - recommendedLabware: - 'https://support.opentrons.com/s/article/What-labware-can-I-use-with-my-modules', - pipetteGen1MultiModuleCollision: - 'https://support.opentrons.com/en/articles/4168741-module-placement', - betaReleases: `https://support.opentrons.com/en/articles/3854833-opentrons-beta-software-releases`, - magneticModuleGenerations: - 'http://support.opentrons.com/en/articles/1820112-magnetic-module', -} as const diff --git a/protocol-designer/src/organisms/AnnouncementModal/announcements.tsx b/protocol-designer/src/organisms/AnnouncementModal/announcements.tsx index b06dad7d704..edbc801351a 100644 --- a/protocol-designer/src/organisms/AnnouncementModal/announcements.tsx +++ b/protocol-designer/src/organisms/AnnouncementModal/announcements.tsx @@ -1,11 +1,12 @@ -import type * as React from 'react' import { Trans, useTranslation } from 'react-i18next' import { css } from 'styled-components' import { + COLORS, DIRECTION_COLUMN, Flex, JUSTIFY_CENTER, JUSTIFY_SPACE_AROUND, + Link as LinkComponent, SPACING, StyledText, } from '@opentrons/components' @@ -19,14 +20,16 @@ import thermocyclerGen2 from '../../assets/images/modules/thermocycler_gen2.png' import liquidEnhancements from '../../assets/images/announcements/liquid-enhancements.gif' import opentronsFlex from '../../assets/images/OpentronsFlex.png' import deckConfigutation from '../../assets/images/deck_configuration.png' +import { DOC_URL } from '../KnowledgeLink' +import type { ReactNode } from 'react' import styles from './AnnouncementModal.module.css' export interface Announcement { announcementKey: string - image: React.ReactNode | null + image: ReactNode | null heading: string - message: React.ReactNode + message: ReactNode } const batchEditStyles = css` @@ -323,7 +326,22 @@ export const useAnnouncements = (): Announcement[] => { }} - i18nKey={'announcements.redesign.body4'} + i18nKey="announcements.redesign.body4" + /> + + + + ), + }} + i18nKey="announcements.redesign.body5" />
diff --git a/protocol-designer/src/organisms/KnowledgeLink.tsx b/protocol-designer/src/organisms/KnowledgeLink.tsx new file mode 100644 index 00000000000..8398539db57 --- /dev/null +++ b/protocol-designer/src/organisms/KnowledgeLink.tsx @@ -0,0 +1,17 @@ +import { Link } from '@opentrons/components' +import type { ReactNode } from 'react' + +interface KnowledgeLinkProps { + children: ReactNode +} + +export const DOC_URL = 'https://docs.opentrons.com/protocol-designer/' + +export function KnowledgeLink(props: KnowledgeLinkProps): JSX.Element { + const { children } = props + return ( + + {children} + + ) +} diff --git a/protocol-designer/src/organisms/PipetteInfoItem/__tests__/PipetteInfoItem.test.tsx b/protocol-designer/src/organisms/PipetteInfoItem/__tests__/PipetteInfoItem.test.tsx index 53ab36986ea..ae60f21c4fb 100644 --- a/protocol-designer/src/organisms/PipetteInfoItem/__tests__/PipetteInfoItem.test.tsx +++ b/protocol-designer/src/organisms/PipetteInfoItem/__tests__/PipetteInfoItem.test.tsx @@ -34,7 +34,7 @@ describe('PipetteInfoItem', () => { it('renders pipette with edit and remove buttons', () => { render(props) screen.getByText('P1000 Single-Channel GEN1') - screen.getByText('Left Pipette') + screen.getByText('Left Mount') screen.getByText('mock display name') fireEvent.click(screen.getByText('Edit')) expect(props.editClick).toHaveBeenCalled() @@ -49,7 +49,7 @@ describe('PipetteInfoItem', () => { } render(props) screen.getByText('P1000 Single-Channel GEN1') - screen.getByText('Right Pipette') + screen.getByText('Right Mount') screen.getByText('mock display name') fireEvent.click(screen.getByText('Edit')) expect(props.editClick).toHaveBeenCalled() diff --git a/protocol-designer/src/organisms/PipetteInfoItem/index.tsx b/protocol-designer/src/organisms/PipetteInfoItem/index.tsx index 97ffe27f8cd..5b98a2c81a2 100644 --- a/protocol-designer/src/organisms/PipetteInfoItem/index.tsx +++ b/protocol-designer/src/organisms/PipetteInfoItem/index.tsx @@ -40,7 +40,7 @@ export function PipetteInfoItem(props: PipetteInfoItemProps): JSX.Element { {i18n.format( - t('pip', { + t('pipette', { mount: is96Channel ? t('left_right') : mount, }), 'titleCase' diff --git a/protocol-designer/src/organisms/index.ts b/protocol-designer/src/organisms/index.ts index 08e1745bccf..72d3ae1ff92 100644 --- a/protocol-designer/src/organisms/index.ts +++ b/protocol-designer/src/organisms/index.ts @@ -11,6 +11,7 @@ export * from './FileUploadMessagesModal/' export * from './GateModal' export * from './IncompatibleTipsModal' export * from './Kitchen' +export * from './KnowledgeLink' export * from './LabwareUploadModal' export * from './PipetteInfoItem' export * from './ProtocolMetadataNav' diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectFixtures.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectFixtures.tsx index eda7253ed62..023b588d7c0 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectFixtures.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectFixtures.tsx @@ -158,6 +158,7 @@ export function SelectFixtures(props: WizardTileProps): JSX.Element | null { return ( { setValue( diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectModules.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectModules.tsx index 575bca4ae8b..105fc2ecea5 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectModules.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectModules.tsx @@ -258,6 +258,7 @@ export function SelectModules(props: WizardTileProps): JSX.Element | null { return ( {t('pipette_type')} - + {PIPETTE_TYPES[robotType].map(type => { return type.value === '96' && (pipettesByMount.left.pipetteName != null || @@ -225,6 +225,7 @@ export function SelectPipettes(props: WizardTileProps): JSX.Element | null { {t('pipette_gen')} @@ -256,7 +257,7 @@ export function SelectPipettes(props: WizardTileProps): JSX.Element | null { {t('pipette_vol')} - + {PIPETTE_VOLUMES[robotType]?.map(volume => { if ( robotType === FLEX_ROBOT_TYPE && diff --git a/protocol-designer/src/pages/Designer/DeckSetup/MagnetModuleChangeContent.tsx b/protocol-designer/src/pages/Designer/DeckSetup/MagnetModuleChangeContent.tsx index 0a5e9c18471..3bf4e7de3a4 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/MagnetModuleChangeContent.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/MagnetModuleChangeContent.tsx @@ -2,10 +2,10 @@ import { useTranslation } from 'react-i18next' import { DIRECTION_COLUMN, Flex, - Link, SPACING, StyledText, } from '@opentrons/components' +import { KnowledgeLink } from '../../../organisms' export function MagnetModuleChangeContent(): JSX.Element { const { t } = useTranslation('starting_deck_state') @@ -26,13 +26,7 @@ export function MagnetModuleChangeContent(): JSX.Element { - {t('read_more_gen1_gen2')}{' '} - - {t('here')} - + {t('read_more_gen1_gen2')} {t('here')} diff --git a/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx b/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx index 71f07b973e3..2f3d0b4323d 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx @@ -7,6 +7,8 @@ import { import { selectors } from '../../../labware-ingred/selectors' import { getOnlyLatestDefs } from '../../../labware-defs' import { getCustomLabwareDefsByURI } from '../../../labware-defs/selectors' +import { getInitialDeckSetup } from '../../../step-forms/selectors' +import { LabwareOnDeck } from '../../../components/DeckSetup/LabwareOnDeck' import { ModuleLabel } from './ModuleLabel' import { LabwareLabel } from '../LabwareLabel' import { FixtureRender } from './FixtureRender' @@ -48,20 +50,44 @@ export const SelectedHoveredItems = ( } = selectedSlotInfo const customLabwareDefs = useSelector(getCustomLabwareDefsByURI) const defs = getOnlyLatestDefs() - + const deckSetup = useSelector(getInitialDeckSetup) + const { labware, modules } = deckSetup + const matchingSelectedLabwareOnDeck = Object.values(labware).find(labware => { + const moduleUnderLabware = Object.values(modules).find( + mod => mod.id === labware.slot + ) + const matchingSlot = + moduleUnderLabware != null ? moduleUnderLabware.slot : labware.slot + return ( + matchingSlot === selectedSlot.slot && + labware.labwareDefURI === selectedLabwareDefUri + ) + }) + const matchingSelectedNestedLabwareOnDeck = Object.values(labware).find( + lw => { + const adapterUnderLabware = Object.values(labware).find( + lab => lab.id === lw.slot + ) + if (adapterUnderLabware == null) { + return + } + const moduleUnderLabware = Object.values(modules).find( + mod => mod.id === adapterUnderLabware.slot + ) + const matchingSlot = + moduleUnderLabware != null + ? moduleUnderLabware.slot + : adapterUnderLabware.slot + return ( + lw.labwareDefURI === selectedNestedLabwareDefUri && + matchingSlot === selectedSlot.slot + ) + } + ) const hoveredLabwareDef = hoveredLabware != null ? defs[hoveredLabware] ?? customLabwareDefs[hoveredLabware] ?? null : null - const selectedLabwareDef = - selectedLabwareDefUri != null - ? defs[selectedLabwareDefUri] ?? customLabwareDefs[selectedLabwareDefUri] - : null - const selectedNestedLabwareDef = - selectedNestedLabwareDefUri != null - ? defs[selectedNestedLabwareDefUri] ?? - customLabwareDefs[selectedNestedLabwareDefUri] - : null const orientation = slotPosition != null @@ -83,9 +109,9 @@ export const SelectedHoveredItems = ( } labwareInfos.push(selectedLabwareLabel) } - if (selectedNestedLabwareDef != null && hoveredLabware == null) { + if (matchingSelectedNestedLabwareOnDeck != null && hoveredLabware == null) { const selectedNestedLabwareLabel = { - text: selectedNestedLabwareDef.metadata.displayName, + text: matchingSelectedNestedLabwareOnDeck.def.metadata.displayName, isSelected: true, isLast: hoveredLabware == null, } @@ -132,19 +158,23 @@ export const SelectedHoveredItems = ( orientation={orientation} > <> - {selectedLabwareDef != null && + {matchingSelectedLabwareOnDeck != null && selectedModuleModel != null && hoveredLabware == null ? ( - - - + ) : null} - {selectedNestedLabwareDef != null && + {matchingSelectedNestedLabwareOnDeck != null && selectedModuleModel != null && hoveredLabware == null ? ( - - - + ) : null} {hoveredLabwareDef != null && selectedModuleModel != null ? ( @@ -165,41 +195,47 @@ export const SelectedHoveredItems = ( ) : null} ) : null} - {selectedLabwareDef != null && + {matchingSelectedLabwareOnDeck != null && slotPosition != null && selectedModuleModel == null && hoveredLabware == null ? ( <> - - - + {selectedNestedLabwareDefUri == null ? ( ) : null} ) : null} - {selectedNestedLabwareDef != null && + {matchingSelectedNestedLabwareOnDeck != null && slotPosition != null && selectedModuleModel == null && hoveredLabware == null ? ( <> - - - - {selectedLabwareDef != null ? ( + + {matchingSelectedLabwareOnDeck != null ? ( { const actual = await importOriginal() return { ...actual, - LabwareRender: vi.fn(), Module: vi.fn(), } }) @@ -43,6 +48,20 @@ describe('SelectedHoveredItems', () => { hoveredFixture: null, slotPosition: [0, 0, 0], } + vi.mocked(getInitialDeckSetup).mockReturnValue({ + modules: {}, + additionalEquipmentOnDeck: {}, + pipettes: {}, + labware: { + labware: { + id: 'mockId', + def: fixture24Tuberack as LabwareDefinition2, + labwareDefURI: 'fixture/fixture_universal_flat_bottom_adapter/1', + slot: 'D3', + }, + }, + }) + vi.mocked(LabwareOnDeck).mockReturnValue(
mock LabwareOnDeck
) vi.mocked(selectors.getZoomedInSlotInfo).mockReturnValue({ selectedLabwareDefUri: null, selectedNestedLabwareDefUri: null, @@ -52,7 +71,6 @@ describe('SelectedHoveredItems', () => { }) vi.mocked(getCustomLabwareDefsByURI).mockReturnValue({}) vi.mocked(FixtureRender).mockReturnValue(
mock FixtureRender
) - vi.mocked(LabwareRender).mockReturnValue(
mock LabwareRender
) vi.mocked(Module).mockReturnValue(
mock Module
) }) it('renders a selected fixture by itself', () => { @@ -70,9 +88,9 @@ describe('SelectedHoveredItems', () => { }) render(props) screen.getByText('mock FixtureRender') - screen.getByText('mock LabwareRender') + screen.getByText('mock LabwareOnDeck') expect(screen.queryByText('mock Module')).not.toBeInTheDocument() - screen.getByText('Fixture Opentrons Universal Flat Heater-Shaker Adapter') + screen.getByText('Opentrons screwcap 2mL tuberack') }) it('renders a selected module', () => { vi.mocked(selectors.getZoomedInSlotInfo).mockReturnValue({ @@ -102,6 +120,25 @@ describe('SelectedHoveredItems', () => { screen.getByText('Fixture Opentrons Universal Flat Heater-Shaker Adapter') }) it('renders selected fixture and both labware and nested labware', () => { + vi.mocked(getInitialDeckSetup).mockReturnValue({ + modules: {}, + additionalEquipmentOnDeck: {}, + pipettes: {}, + labware: { + labware: { + id: 'mockId', + def: fixture24Tuberack as LabwareDefinition2, + labwareDefURI: 'fixture/fixture_universal_flat_bottom_adapter/1', + slot: 'D3', + }, + labware2: { + id: 'mockId2', + def: fixture24Tuberack as LabwareDefinition2, + labwareDefURI: 'fixture/fixture_universal_flat_bottom_adapter/1', + slot: 'mockId', + }, + }, + }) vi.mocked(selectors.getZoomedInSlotInfo).mockReturnValue({ selectedLabwareDefUri: 'fixture/fixture_universal_flat_bottom_adapter/1', selectedNestedLabwareDefUri: @@ -112,12 +149,10 @@ describe('SelectedHoveredItems', () => { }) render(props) screen.getByText('mock FixtureRender') - expect(screen.getAllByText('mock LabwareRender')).toHaveLength(2) - expect( - screen.getAllByText( - 'Fixture Opentrons Universal Flat Heater-Shaker Adapter' - ) - ).toHaveLength(2) + expect(screen.getAllByText('mock LabwareOnDeck')).toHaveLength(2) + expect(screen.getAllByText('Opentrons screwcap 2mL tuberack')).toHaveLength( + 2 + ) }) it('renders nothing when there is a hovered module but selected fixture', () => { props.hoveredModule = HEATERSHAKER_MODULE_V1 diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateField.tsx index db28d285a0d..2e264548d28 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateField.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateField.tsx @@ -100,6 +100,7 @@ export function FlowRateField(props: FlowRateFieldProps): JSX.Element { max: maxFlowRate, unit: t('application:units.microliterPerSec'), })} + placeholder={String(defaultFlowRate)} /> ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/LabwareField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/LabwareField.tsx index f4c18fd695e..daa290efc18 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/LabwareField.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/LabwareField.tsx @@ -17,7 +17,6 @@ export function LabwareField(props: FieldProps): JSX.Element { ? [...options, ...disposalOptions] : [...options] - console.log('name', name) return ( @@ -123,10 +123,12 @@ export const WellSelectionField = ( disabled={disabled ?? labwareId != null} readOnly name={name} - error={errorToShow} - value={primaryWellCount} + value={primaryWellCount ?? errorToShow} onClick={handleOpen} hasBackgroundError={hasFormError} + size="medium" + borderRadius={BORDERS.borderRadius8} + padding={SPACING.spacing12} /> {createPortal( diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx index cee9fdadb4f..2cc845fbe17 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import get from 'lodash/get' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' @@ -40,11 +40,20 @@ import { getVisibleFormErrors, getVisibleFormWarnings, capitalizeFirstLetter, + getIsErrorOnCurrentPage, } from './utils' import type { StepFieldName } from '../../../../steplist/fieldLevel' import type { FormData, StepType } from '../../../../form-types' -import type { FieldPropsByName, FocusHandlers, StepFormProps } from './types' -import { getFormLevelErrorsForUnsavedForm } from '../../../../step-forms/selectors' +import type { + FieldPropsByName, + FocusHandlers, + LiquidHandlingTab, + StepFormProps, +} from './types' +import { + getDynamicFieldFormErrorsForUnsavedForm, + getFormLevelErrorsForUnsavedForm, +} from '../../../../step-forms/selectors' type StepFormMap = { [K in StepType]?: React.ComponentType | null @@ -90,6 +99,8 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { 'protocol_steps', ]) const { makeSnackbar } = useKitchen() + const toolsComponentRef = useRef(null) + const formWarningsForSelectedStep = useSelector( getFormWarningsForSelectedStep ) @@ -99,12 +110,20 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { const formLevelErrorsForUnsavedForm = useSelector( getFormLevelErrorsForUnsavedForm ) + const dynamicFormLevelErrorsForUnsavedForm = useSelector( + getDynamicFieldFormErrorsForUnsavedForm + ).map(error => ({ + title: error.title, + body: error.body, + dependentFields: error.dependentProfileFields, + })) const timeline = useSelector(getRobotStateTimeline) const [toolboxStep, setToolboxStep] = useState(0) const [ showFormErrorsAndWarnings, setShowFormErrorsAndWarnings, ] = useState(false) + const [tab, setTab] = useState('aspirate') const visibleFormWarnings = getVisibleFormWarnings({ focusedField, dirtyFields: dirtyFields ?? [], @@ -113,7 +132,12 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { const visibleFormErrors = getVisibleFormErrors({ focusedField, dirtyFields: dirtyFields ?? [], - errors: formLevelErrorsForUnsavedForm, + errors: [ + ...formLevelErrorsForUnsavedForm, + ...dynamicFormLevelErrorsForUnsavedForm, + ], + page: toolboxStep, + showErrors: showFormErrorsAndWarnings, }) const [isRename, setIsRename] = useState(false) const icon = stepIconsByType[formData.stepType] @@ -123,6 +147,13 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { formData.stepType ) + const isAspirateError = formLevelErrorsForUnsavedForm.some( + error => error.tab === 'aspirate' && error.page === toolboxStep + ) + const isDispenseError = formLevelErrorsForUnsavedForm.some( + error => error.tab === 'dispense' && error.page === toolboxStep + ) + if (!ToolsComponent) { // early-exit if step form doesn't exist, this is a good check for when new steps // are added @@ -141,6 +172,19 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { visibleFormWarnings.length + timelineWarningsForSelectedStep.length const numErrors = timeline.errors?.length ?? 0 + const isErrorOnCurrentPage = getIsErrorOnCurrentPage({ + errors: formLevelErrorsForUnsavedForm, + page: toolboxStep, + }) + const handleScrollToTop = (): void => { + if (toolsComponentRef.current) { + toolsComponentRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }) + } + } + const handleSaveClick = (): void => { if (canSave) { handleSave() @@ -157,6 +201,27 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { ) } else { setShowFormErrorsAndWarnings(true) + if (tab === 'aspirate' && isDispenseError && !isAspirateError) { + setTab('dispense') + } + if (tab === 'dispense' && isAspirateError && !isDispenseError) { + setTab('aspirate') + } + handleScrollToTop() + } + } + + const handleContinue = (): void => { + if (isMultiStepToolbox && toolboxStep === 0) { + if (!isErrorOnCurrentPage) { + setToolboxStep(1) + setShowFormErrorsAndWarnings(false) + } else { + setShowFormErrorsAndWarnings(true) + handleScrollToTop() + } + } else { + handleSaveClick() } } @@ -202,21 +267,13 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { width="100%" onClick={() => { setToolboxStep(0) + setShowFormErrorsAndWarnings(false) }} > {i18n.format(t('shared:back'), 'capitalize')} ) : null} - { - setToolboxStep(1) - } - : handleSaveClick - } - width="100%" - > + {isMultiStepToolbox && toolboxStep === 0 ? i18n.format(t('shared:continue'), 'capitalize') : t('shared:save')} @@ -235,23 +292,28 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element {
} > - - +
+ + +
) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx index 6a0315bb3bf..2d16704fdb3 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react' import { useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import { @@ -6,7 +5,6 @@ import { COLORS, DIRECTION_COLUMN, Flex, - ListItem, SPACING, StyledText, } from '@opentrons/components' @@ -20,50 +18,19 @@ import { getFormErrorsMappedToField, getFormLevelError } from '../../utils' import type { StepFormProps } from '../../types' export function HeaterShakerTools(props: StepFormProps): JSX.Element { - const { - propsForFields, - formData, - showFormErrors = false, - focusedField = null, - visibleFormErrors, - } = props + const { propsForFields, formData, visibleFormErrors } = props const { t } = useTranslation(['application', 'form', 'protocol_steps']) const moduleLabwareOptions = useSelector(getHeaterShakerLabwareOptions) - useEffect(() => { - if (moduleLabwareOptions.length === 1) { - propsForFields.moduleId.updateValue(moduleLabwareOptions[0].value) - } - }, []) - const mappedErrorsToField = getFormErrorsMappedToField(visibleFormErrors) return ( - {moduleLabwareOptions.length > 1 ? ( - - ) : ( - - - {t('protocol_steps:module')} - - - - - {moduleLabwareOptions[0].name} - - - - - )} + 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 2468923d9c2..5d0d54cd5ea 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 @@ -32,11 +32,12 @@ import { } from '../../../../../../step-forms/selectors' import { getModulesOnDeckByType } from '../../../../../../ui/modules/utils' import { LINE_CLAMP_TEXT_STYLE } from '../../../../../../atoms' +import { getFormErrorsMappedToField, getFormLevelError } from '../../utils' import type { StepFormProps } from '../../types' export function MagnetTools(props: StepFormProps): JSX.Element { - const { propsForFields, formData } = props + const { propsForFields, formData, visibleFormErrors } = props const { t } = useTranslation(['application', 'form', 'protocol_steps']) const moduleLabwareOptions = useSelector(getMagneticLabwareOptions) const moduleEntities = useSelector(getModuleEntities) @@ -69,6 +70,9 @@ export function MagnetTools(props: StepFormProps): JSX.Element { }) : '' const engageHeightCaption = `${engageHeightMinMax} ${engageHeightDefault}` + + const mappedErrorsToField = getFormErrorsMappedToField(visibleFormErrors) + return ( diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx index a319afc572a..aae66a5f762 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx @@ -1,6 +1,5 @@ import { useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' -import { useState } from 'react' import { DIRECTION_COLUMN, Divider, @@ -37,16 +36,24 @@ import { } from '../../PipetteFields' import { getBlowoutLocationOptionsForForm, + getFormErrorsMappedToField, + getFormLevelError, getLabwareFieldForPositioningField, } from '../../utils' import type { StepFormProps } from '../../types' export function MixTools(props: StepFormProps): JSX.Element { - const { propsForFields, formData, toolboxStep, visibleFormErrors } = props + const { + propsForFields, + formData, + toolboxStep, + visibleFormErrors, + tab, + setTab, + } = props const pipettes = useSelector(getPipetteEntities) const enableReturnTip = useSelector(getEnableReturnTip) const labwares = useSelector(getLabwareEntities) - const [tab, setTab] = useState<'aspirate' | 'dispense'>('aspirate') const { t, i18n } = useTranslation(['application', 'form']) const aspirateTab = { text: i18n.format(t('aspirate'), 'capitalize'), @@ -63,6 +70,7 @@ export function MixTools(props: StepFormProps): JSX.Element { setTab('dispense') }, } + const is96Channel = propsForFields.pipette.value != null && pipettes[String(propsForFields.pipette.value)].name === 'p1000_96' @@ -71,6 +79,8 @@ export function MixTools(props: StepFormProps): JSX.Element { const userSelectedDropTipLocation = labwares[String(propsForFields.dropTip_location.value)] != null + const mappedErrorsToField = getFormErrorsMappedToField(visibleFormErrors) + return toolboxStep === 0 ? ( @@ -81,7 +91,10 @@ export function MixTools(props: StepFormProps): JSX.Element { pipetteId={propsForFields.pipette.value} /> - + - error.dependentFields.includes('labware') + error.dependentFields.includes('wells') ) ?? false } + errorToShow={getFormLevelError('wells', mappedErrorsToField)} /> - + ) : null} @@ -238,6 +260,10 @@ export function MixTools(props: StepFormProps): JSX.Element { options={getBlowoutLocationOptionsForForm({ stepType: formData.stepType, })} + errorToShow={getFormLevelError( + 'blowout_location', + mappedErrorsToField + )} /> ) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLabwareTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLabwareTools/index.tsx index fa71de6a2b4..8355ed6fa4d 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLabwareTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLabwareTools/index.tsx @@ -8,13 +8,14 @@ import { getAdditionalEquipment, getCurrentFormCanBeSaved, } from '../../../../../../step-forms/selectors' +import { getFormErrorsMappedToField, getFormLevelError } from '../../utils' import { MoveLabwareField } from './MoveLabwareField' import { LabwareLocationField } from './LabwareLocationField' import type { StepFormProps } from '../../types' export function MoveLabwareTools(props: StepFormProps): JSX.Element { - const { propsForFields } = props + const { propsForFields, visibleFormErrors } = props const { t, i18n } = useTranslation(['application', 'form', 'tooltip']) const robotType = useSelector(getRobotType) const canSave = useSelector(getCurrentFormCanBeSaved) @@ -23,32 +24,40 @@ export function MoveLabwareTools(props: StepFormProps): JSX.Element { equipment => equipment?.name === 'gripper' ) + const mappedErrorsToField = getFormErrorsMappedToField(visibleFormErrors) + return ( {robotType === FLEX_ROBOT_TYPE ? ( - + <> + + + ) : null} - + - ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx index 95291484386..19661741298 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx @@ -1,6 +1,5 @@ import { useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' -import { useState } from 'react' import { DIRECTION_COLUMN, Divider, @@ -40,6 +39,8 @@ import { } from '../../PipetteFields' import { getBlowoutLocationOptionsForForm, + getFormErrorsMappedToField, + getFormLevelError, getLabwareFieldForPositioningField, } from '../../utils' import type { StepFieldName } from '../../../../../../form-types' @@ -50,10 +51,17 @@ const makeAddFieldNamePrefix = (prefix: string) => ( ): StepFieldName => `${prefix}_${fieldName}` export function MoveLiquidTools(props: StepFormProps): JSX.Element { - const { toolboxStep, propsForFields, formData, visibleFormErrors } = props + const { + toolboxStep, + propsForFields, + formData, + visibleFormErrors, + setShowFormErrorsAndWarnings, + tab, + setTab, + } = props const { t, i18n } = useTranslation(['protocol_steps', 'form']) const { path } = formData - const [tab, setTab] = useState<'aspirate' | 'dispense'>('aspirate') const additionalEquipmentEntities = useSelector( getAdditionalEquipmentEntities ) @@ -93,6 +101,7 @@ export function MoveLiquidTools(props: StepFormProps): JSX.Element { isActive: tab === 'aspirate', onClick: () => { setTab('aspirate') + setShowFormErrorsAndWarnings?.(false) }, } const dispenseTab = { @@ -101,11 +110,14 @@ export function MoveLiquidTools(props: StepFormProps): JSX.Element { isActive: tab === 'dispense', onClick: () => { setTab('dispense') + setShowFormErrorsAndWarnings?.(false) }, } const hideWellOrderField = tab === 'dispense' && (isWasteChuteSelected || isTrashBinSelected) + const mappedErrorsToField = getFormErrorsMappedToField(visibleFormErrors) + return toolboxStep === 0 ? ( @@ -117,9 +129,15 @@ export function MoveLiquidTools(props: StepFormProps): JSX.Element { pipetteId={propsForFields.pipette.value} /> - + - + - + {isDisposalLocation ? null : ( )} @@ -290,6 +313,10 @@ export function MoveLiquidTools(props: StepFormProps): JSX.Element { title={t('protocol_steps:mix_volume')} {...propsForFields[`${tab}_mix_volume`]} units={t('application:units.microliter')} + errorToShow={getFormLevelError( + `${tab}_mix_volume`, + mappedErrorsToField + )} /> ) : null} @@ -324,6 +355,10 @@ export function MoveLiquidTools(props: StepFormProps): JSX.Element { title={t('protocol_steps:delay_duration')} {...propsForFields[`${tab}_delay_seconds`]} units={t('application:units.seconds')} + errorToShow={getFormLevelError( + `${tab}_delay_seconds`, + mappedErrorsToField + )} /> ) : null} 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 98175175218..07d80908d0b 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 @@ -37,8 +37,6 @@ export function PauseTools(props: StepFormProps): JSX.Element { const { propsForFields, visibleFormErrors, - focusedField, - showFormErrors, setShowFormErrorsAndWarnings, } = props @@ -84,6 +82,10 @@ export function PauseTools(props: StepFormProps): JSX.Element { const mappedErrorsToField = getFormErrorsMappedToField(visibleFormErrors) + const formLevelErrorsWithoutField = visibleFormErrors.filter( + error => error.dependentFields.length === 0 + ) + return ( <> @@ -133,105 +135,133 @@ export function PauseTools(props: StepFormProps): JSX.Element { largeDesktopBorderRadius disabled={!pauseUntilModuleEnabled} /> + {formLevelErrorsWithoutField.map(error => ( + + {error.title} + + ))} - - - {pauseAction === PAUSE_UNTIL_TIME ? ( + {pauseAction != null ? ( + <> + {' '} + - - - + {pauseAction === PAUSE_UNTIL_TIME ? ( + + + + + + ) : null} + {pauseAction === PAUSE_UNTIL_TEMP ? ( + <> + + + {i18n.format( + t( + 'form:step_edit_form.field.moduleActionLabware.label' + ), + 'capitalize' + )} + + { + propsForFields.moduleId.updateValue(value) + }} + currentOption={ + moduleOptions.find( + option => + option.value === propsForFields.moduleId.value + ) ?? { name: '', value: '' } + } + dropdownType="neutral" + width="100%" + error={getFormLevelError( + 'moduleId', + mappedErrorsToField + )} + /> + + + + + + ) : null} - ) : null} - {pauseAction === PAUSE_UNTIL_TEMP ? ( - <> - - - {i18n.format( - t('form:step_edit_form.field.moduleActionLabware.label'), - 'capitalize' - )} - - { - propsForFields.moduleId.updateValue(value) - }} - currentOption={ - moduleOptions.find( - option => option.value === propsForFields.moduleId.value - ) ?? { name: '', value: '' } - } - dropdownType="neutral" - width="100%" - /> - - + - - - - ) : null} - - - - {i18n.format( - t('form:step_edit_form.field.pauseMessage.label'), - 'capitalize' - )} - - ) => { - propsForFields.pauseMessage.updateValue(e.currentTarget.value) - }} - height="7rem" - /> - + {i18n.format( + t('form:step_edit_form.field.pauseMessage.label'), + 'capitalize' + )} + + ) => { + propsForFields.pauseMessage.updateValue( + e.currentTarget.value + ) + }} + height="7rem" + /> + + + ) : null}
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/TemperatureTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/TemperatureTools/index.tsx index a0e0ca76e27..c5d03a44afa 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/TemperatureTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/TemperatureTools/index.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import { @@ -6,112 +5,50 @@ import { COLORS, DIRECTION_COLUMN, Flex, - ListItem, - RadioButton, SPACING, - StyledText, } from '@opentrons/components' -import { - getTemperatureLabwareOptions, - getTemperatureModuleIds, -} from '../../../../../../ui/modules/selectors' +import { getTemperatureLabwareOptions } from '../../../../../../ui/modules/selectors' import { DropdownStepFormField, - InputStepFormField, + ToggleExpandStepFormField, } from '../../../../../../molecules' +import { getFormErrorsMappedToField, getFormLevelError } from '../../utils' + import type { StepFormProps } from '../../types' export function TemperatureTools(props: StepFormProps): JSX.Element { - const { propsForFields, formData } = props + const { propsForFields, formData, visibleFormErrors } = props const { t } = useTranslation(['application', 'form', 'protocol_steps']) const moduleLabwareOptions = useSelector(getTemperatureLabwareOptions) - const temperatureModuleIds = useSelector(getTemperatureModuleIds) - const { setTemperature, moduleId } = formData - React.useEffect(() => { - if (moduleLabwareOptions.length === 1) { - propsForFields.moduleId.updateValue(moduleLabwareOptions[0].value) - } - }, []) + const mappedErrorsToField = getFormErrorsMappedToField(visibleFormErrors) return ( - {moduleLabwareOptions.length > 1 ? ( - - ) : ( - - - {t('protocol_steps:module')} - - - - - {moduleLabwareOptions[0].name} - - - - - )} + - {temperatureModuleIds != null - ? temperatureModuleIds.map(id => - id === moduleId ? ( - - - ) => { - propsForFields.setTemperature.updateValue( - e.currentTarget.value - ) - }} - buttonLabel={t( - 'form:step_edit_form.field.setTemperature.options.true' - )} - buttonValue="true" - isSelected={propsForFields.setTemperature.value === 'true'} - /> - - {setTemperature === 'true' && ( - - )} - - ) => { - propsForFields.setTemperature.updateValue( - e.currentTarget.value - ) - }} - buttonLabel={t( - 'form:step_edit_form.field.setTemperature.options.false' - )} - buttonValue="false" - isSelected={propsForFields.setTemperature.value === 'false'} - /> - - - ) : null - ) - : null} + + + ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ProfileSettings.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ProfileSettings.tsx index 59d94469fd1..dc343ce693e 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ProfileSettings.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ProfileSettings.tsx @@ -18,12 +18,7 @@ interface ProfileSettingsProps { focusedField?: string | null } export function ProfileSettings(props: ProfileSettingsProps): JSX.Element { - const { - propsForFields, - showFormErrors, - visibleFormErrors, - focusedField, - } = props + const { propsForFields, visibleFormErrors } = props const mappedErrorsToField = getFormErrorsMappedToField(visibleFormErrors) @@ -43,12 +38,7 @@ export function ProfileSettings(props: ProfileSettingsProps): JSX.Element { units={t('units.microliter')} padding="0" showTooltip={false} - formLevelError={getFormLevelError( - showFormErrors, - 'profileVolume', - mappedErrorsToField, - focusedField - )} + formLevelError={getFormLevelError('profileVolume', mappedErrorsToField)} />
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 0b00c140f65..024c22c0e62 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 @@ -33,8 +33,6 @@ export function ThermocyclerState(props: ThermocyclerStateProps): JSX.Element { formData, isHold = false, visibleFormErrors, - showFormErrors = true, - focusedField, } = props const { i18n, t } = useTranslation(['application', 'form']) @@ -83,12 +81,7 @@ export function ThermocyclerState(props: ThermocyclerStateProps): JSX.Element { isSelected={formData[blockFieldActive] === true} onLabel={t('form:step_edit_form.field.heaterShaker.shaker.toggleOn')} offLabel={t('form:step_edit_form.field.heaterShaker.shaker.toggleOff')} - formLevelError={getFormLevelError( - showFormErrors, - blockTempField, - mappedErrorsToField, - focusedField - )} + formLevelError={getFormLevelError(blockTempField, mappedErrorsToField)} /> { }, }, showFormErrors: false, + tab: 'aspirate', + setTab: vi.fn(), } vi.mocked(getMagneticLabwareOptions).mockReturnValue([ { name: 'mock labware in mock module in slot abc', value: 'mockValue' }, diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/TemperatureTools.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/TemperatureTools.test.tsx index 498e6b2e1db..904377f66f4 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/TemperatureTools.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/TemperatureTools.test.tsx @@ -72,6 +72,8 @@ describe('TemperatureTools', () => { }, }, showFormErrors: false, + tab: 'aspirate', + setTab: vi.fn(), } vi.mocked(getTemperatureModuleIds).mockReturnValue(['mockId']) @@ -85,9 +87,7 @@ describe('TemperatureTools', () => { it('renders a temperature module form with 1 module', () => { render(props) - screen.getByText('Module') + screen.getByText('Module state') screen.getByText('mock module') - screen.getByText('Deactivate module') - screen.getByText('Change to temperature') }) }) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/__tests__/utils.test.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/__tests__/utils.test.ts index 6007eae6d0c..1f79fe88440 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/__tests__/utils.test.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/__tests__/utils.test.ts @@ -122,26 +122,16 @@ describe('getFormErrorsMappedToField', () => { }) describe('getFormLevelError', () => { - it('shows form-level error at field when field is not focused and showAtField is true', () => { - const result = getFormLevelError(true, 'field1', MAPPED_ERRORS) + it('shows form-level error at field when showAtField is true', () => { + const result = getFormLevelError('field1', MAPPED_ERRORS) expect(result).toEqual('form level error title') }) - it('shows no form-level error at field when field is focused and showAtField is true', () => { - const result = getFormLevelError(true, 'field1', MAPPED_ERRORS, 'field1') - expect(result).toBeNull() - }) - - it('shows no form-level error at field when field is not focused and showAtField is false', () => { - const result = getFormLevelError( - true, - 'field1', - { - ...MAPPED_ERRORS, - field1: { ...MAPPED_ERRORS.field1, showAtField: false }, - }, - 'field2' - ) + it('shows no form-level error at field when showAtField is false', () => { + const result = getFormLevelError('field1', { + ...MAPPED_ERRORS, + field1: { ...MAPPED_ERRORS.field1, showAtField: false }, + }) expect(result).toBeNull() }) }) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts index f0bd6970e73..c2d50cfbcb5 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts @@ -20,6 +20,7 @@ export interface FieldProps { export type FieldPropsByName = Record // Shared props across all step forms +export type LiquidHandlingTab = 'aspirate' | 'dispense' export interface StepFormProps { formData: FormData focusHandlers: FocusHandlers @@ -29,4 +30,6 @@ export interface StepFormProps { showFormErrors: boolean focusedField?: string | null setShowFormErrorsAndWarnings?: React.Dispatch> + tab: LiquidHandlingTab + setTab: React.Dispatch> } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts index 563308f1238..3821d0ba49d 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts @@ -104,20 +104,33 @@ export const getDirtyFields = ( // exclude form "metadata" (not really fields) return without(dirtyFields, 'stepType', 'id') } + +export const getIsErrorOnCurrentPage = (args: { + errors: StepFormErrors + page: number +}): boolean => { + const { errors, page = 0 } = args + return errors.some(error => error.page == null || error.page === page) +} + export const getVisibleFormErrors = (args: { focusedField?: string | null dirtyFields: string[] errors: StepFormErrors + showErrors?: boolean + page: number }): StepFormErrors => { - const { focusedField, dirtyFields, errors } = args + const { focusedField, errors, page = 0, showErrors } = args + return errors.filter(error => { const dependentFieldsAreNotFocused = !error.dependentFields.includes( // @ts-expect-error(sa, 2021-6-22): focusedField might be undefined focusedField ) - const dependentFieldsAreDirty = - difference(error.dependentFields, dirtyFields).length === 0 - return dependentFieldsAreNotFocused && dependentFieldsAreDirty + + const isPageImplicated = error.page != null ? page === error.page : true + + return isPageImplicated && dependentFieldsAreNotFocused && showErrors }) } export const getVisibleFormWarnings = (args: { @@ -353,14 +366,10 @@ export const getFormErrorsMappedToField = ( } export const getFormLevelError = ( - showFormErrors: boolean, fieldName: string, - mappedErrorsToField: ErrorMappedToField, - focusedField?: string | null + mappedErrorsToField: ErrorMappedToField ): string | null => { - return showFormErrors && - focusedField !== fieldName && - mappedErrorsToField[fieldName] && + return mappedErrorsToField[fieldName] && mappedErrorsToField[fieldName].showAtField ? mappedErrorsToField[fieldName].title : null diff --git a/protocol-designer/src/pages/ProtocolOverview/InstrumentsInfo.tsx b/protocol-designer/src/pages/ProtocolOverview/InstrumentsInfo.tsx index 810fc098db0..63ce567a805 100644 --- a/protocol-designer/src/pages/ProtocolOverview/InstrumentsInfo.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/InstrumentsInfo.tsx @@ -40,14 +40,17 @@ export function InstrumentsInfo({ equipment => equipment?.name === 'gripper' ) + const has96Channel = leftPipette?.name === 'p1000_96' && rightPipette == null + const pipetteInfo = (pipette?: PipetteOnDeck): JSX.Element => { const pipetteName = pipette != null ? getPipetteSpecsV2(pipette.name as PipetteName)?.displayName : t('na') - const tipsInfo = pipette?.tiprackLabwareDef - ? pipette.tiprackLabwareDef.map(labware => labware.metadata.displayName) - : t('na') + const tipsInfo = + pipette?.tiprackLabwareDef != null + ? pipette.tiprackLabwareDef.map(labware => labware.metadata.displayName) + : t('na') if (pipetteName === t('na') || tipsInfo === t('na')) { return ( @@ -124,29 +127,31 @@ export function InstrumentsInfo({ desktopStyle="bodyDefaultRegular" color={COLORS.grey60} > - {t('left_pip')} + {has96Channel ? t('left_right_mount') : t('left_mount')}
} content={pipetteInfo(leftPipette)} /> - - - - {t('right_pip')} - -
- } - content={pipetteInfo(rightPipette)} - /> - + {!has96Channel ? ( + + + + {t('right_mount')} + + + } + content={pipetteInfo(rightPipette)} + /> + + ) : null} {robotType === FLEX_ROBOT_TYPE ? ( { screen.getByText('Instruments') screen.getByText('Robot type') screen.getAllByText('Opentrons Flex') - screen.getByText('Left pipette') - screen.getByText('Right pipette') - screen.getByText('Extension mount') + screen.getByText('Left Mount') + screen.getByText('Right Mount') + screen.getByText('Extension Mount') expect(screen.getAllByText('N/A').length).toBe(3) }) @@ -103,4 +122,13 @@ describe('InstrumentsInfo', () => { fireEvent.click(screen.getByText('Edit')) expect(mockSetShowEditInstrumentsModal).toHaveBeenCalled() }) + + it('should render left + right mount when 96 channels is selected', () => { + props = { + ...props, + pipettesOnDeck: mock96Pipette, + } + render(props) + screen.getByText('Left + Right Mount') + }) }) diff --git a/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolMetadata.test.tsx b/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolMetadata.test.tsx index 405bd946279..8df6988b1c4 100644 --- a/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolMetadata.test.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolMetadata.test.tsx @@ -45,7 +45,7 @@ describe('ProtocolMetadata', () => { screen.getByText('Protocol Metadata') screen.getByText('Edit') screen.getByText('Required app version') - screen.getByText('8.0.0 or higher') + screen.getByText('8.2.0 or higher') }) it('should render protocol metadata', () => { diff --git a/protocol-designer/src/step-forms/selectors/index.ts b/protocol-designer/src/step-forms/selectors/index.ts index 70b4ff44ce2..75c47774fca 100644 --- a/protocol-designer/src/step-forms/selectors/index.ts +++ b/protocol-designer/src/step-forms/selectors/index.ts @@ -651,13 +651,20 @@ export const getHydratedUnsavedForm: Selector< export const getDynamicFieldFormErrorsForUnsavedForm: Selector< BaseState, ProfileFormError[] -> = createSelector(getHydratedUnsavedForm, hydratedForm => { - if (!hydratedForm) return [] +> = createSelector( + getHydratedUnsavedForm, + getInvariantContext, + (hydratedForm, invariantContext) => { + if (!hydratedForm) return [] - const errors = _dynamicFieldFormErrors(hydratedForm) + const errors = [ + ..._dynamicFieldFormErrors(hydratedForm), + ..._dynamicMoveLabwareFieldFormErrors(hydratedForm, invariantContext), + ] - return errors -}) + return errors + } +) export const getFormLevelErrorsForUnsavedForm: Selector< BaseState, StepFormErrors diff --git a/protocol-designer/src/steplist/fieldLevel/index.ts b/protocol-designer/src/steplist/fieldLevel/index.ts index 5440cda40e7..4d240fdd649 100644 --- a/protocol-designer/src/steplist/fieldLevel/index.ts +++ b/protocol-designer/src/steplist/fieldLevel/index.ts @@ -307,9 +307,6 @@ const stepFieldHelperMap: Record = { maskValue: composeMaskers(maskToFloat, trimDecimals(1)), castValue: Number, }, - setTemperature: { - getErrors: composeErrors(requiredField), - }, targetTemperature: { getErrors: composeErrors( minFieldValue(MIN_TEMP_MODULE_TEMP), diff --git a/protocol-designer/src/steplist/formLevel/errors.ts b/protocol-designer/src/steplist/formLevel/errors.ts index 5a2d71c1ff6..add7662903a 100644 --- a/protocol-designer/src/steplist/formLevel/errors.ts +++ b/protocol-designer/src/steplist/formLevel/errors.ts @@ -55,6 +55,8 @@ export interface FormError { dependentFields: StepFieldName[] showAtField?: boolean showAtForm?: boolean + page?: number + tab?: 'aspirate' | 'dispense' } const INCOMPATIBLE_ASPIRATE_LABWARE: FormError = { title: 'Selected aspirate labware is incompatible with pipette', @@ -100,8 +102,10 @@ const MAGNET_ACTION_TYPE_REQUIRED: FormError = { dependentFields: ['magnetAction'], } const ENGAGE_HEIGHT_REQUIRED: FormError = { - title: 'Engage height is required', + title: 'Engage height required', dependentFields: ['magnetAction', 'engageHeight'], + showAtForm: false, + showAtField: true, } const ENGAGE_HEIGHT_MIN_EXCEEDED: FormError = { title: 'Specified distance is below module minimum', @@ -117,46 +121,52 @@ const MODULE_ID_REQUIRED: FormError = { dependentFields: ['moduleId'], } const TARGET_TEMPERATURE_REQUIRED: FormError = { - title: 'Temperature is required', + title: 'Temperature required', dependentFields: ['setTemperature', 'targetTemperature'], showAtForm: false, showAtField: true, } const PROFILE_VOLUME_REQUIRED: FormError = { - title: 'Volume is required', + title: 'Well volume required', dependentFields: ['thermocyclerFormType', 'profileVolume'], showAtForm: false, showAtField: true, + page: 1, } const PROFILE_LID_TEMPERATURE_REQUIRED: FormError = { - title: 'Temperature is required', + title: 'Temperature required', dependentFields: ['thermocyclerFormType', 'profileTargetLidTemp'], showAtForm: false, showAtField: true, + page: 1, } const LID_TEMPERATURE_REQUIRED: FormError = { - title: 'Temperature is required', + title: 'Temperature required', dependentFields: ['lidIsActive', 'lidTargetTemp'], showAtForm: false, showAtField: true, + page: 1, } const BLOCK_TEMPERATURE_REQUIRED: FormError = { - title: 'Temperature is required', + title: 'Temperature required', dependentFields: ['blockIsActive', 'blockTargetTemp'], showAtForm: false, showAtField: true, + page: 1, } const BLOCK_TEMPERATURE_HOLD_REQUIRED: FormError = { - title: 'Temperature is required', + title: 'Temperature required', dependentFields: ['blockIsActiveHold', 'blockTargetTempHold'], showAtForm: false, showAtField: true, + page: 1, } const LID_TEMPERATURE_HOLD_REQUIRED: FormError = { - title: 'Temperature is required', + title: 'Temperature required', dependentFields: ['lidIsActiveHold', 'lidTargetTempHold'], showAtForm: false, showAtField: true, + page: 1, } const SHAKE_SPEED_REQUIRED: FormError = { title: 'Speed required', @@ -170,6 +180,18 @@ const SHAKE_TIME_REQUIRED: FormError = { showAtForm: false, showAtField: true, } +const PAUSE_ACTION_REQUIRED: FormError = { + title: 'Pause type required', + dependentFields: [], + showAtForm: false, + showAtField: true, +} +const PAUSE_MODULE_REQUIRED: FormError = { + title: 'Select a module', + dependentFields: ['moduleId', 'pauseAction'], + showAtForm: false, + showAtField: true, +} const PAUSE_TEMP_REQUIRED: FormError = { title: 'Pause temperature required', dependentFields: ['pauseTemperature', 'pauseAction'], @@ -192,7 +214,7 @@ const HS_TEMPERATURE_REQUIRED: FormError = { showAtField: true, } const LABWARE_TO_MOVE_REQUIRED: FormError = { - title: 'Labware to move required', + title: 'Labware required', dependentFields: ['labware'], showAtForm: false, showAtField: true, @@ -203,6 +225,134 @@ const NEW_LABWARE_LOCATION_REQUIRED: FormError = { showAtForm: false, showAtField: true, } +const ASPIRATE_WELLS_REQUIRED: FormError = { + title: 'Choose wells', + dependentFields: ['aspirate_wells'], + showAtForm: false, + showAtField: true, + page: 0, +} +const DISPENSE_WELLS_REQUIRED: FormError = { + title: 'Choose wells', + dependentFields: ['dispense_wells'], + showAtForm: false, + showAtField: true, + page: 0, +} +const MIX_WELLS_REQUIRED: FormError = { + title: 'Choose wells', + dependentFields: ['wells'], + showAtForm: false, + showAtField: true, + page: 0, +} +const VOLUME_REQUIRED: FormError = { + title: 'Volume required', + dependentFields: ['volume'], + showAtForm: false, + showAtField: true, + page: 0, +} +const TIMES_REQUIRED: FormError = { + title: 'Repetitions required', + dependentFields: ['times'], + showAtForm: false, + showAtField: true, + page: 0, +} +const ASPIRATE_LABWARE_REQUIRED: FormError = { + title: 'Labware required', + dependentFields: ['aspirate_labware'], + showAtForm: false, + showAtField: true, + page: 0, +} +const DISPENSE_LABWARE_REQUIRED: FormError = { + title: 'Labware required', + dependentFields: ['dispense_labware'], + showAtForm: false, + showAtField: true, + page: 0, +} +const MIX_LABWARE_REQUIRED: FormError = { + title: 'Labware required', + dependentFields: ['labware'], + showAtForm: false, + showAtField: true, + page: 0, +} +const ASPIRATE_MIX_TIMES_REQUIRED: FormError = { + title: 'Repititions required', + dependentFields: ['aspirate_mix_times'], + showAtForm: false, + showAtField: true, + page: 1, + tab: 'aspirate', +} +const ASPIRATE_MIX_VOLUME_REQUIRED: FormError = { + title: 'Volume required', + dependentFields: ['aspirate_mix_checkbox', 'aspirate_mix_volume'], + showAtForm: false, + showAtField: true, + page: 1, + tab: 'aspirate', +} +const ASPIRATE_DELAY_DURATION_REQUIRED: FormError = { + title: 'Duration required', + dependentFields: ['aspirate_delay_checkbox', 'aspirate_delay_seconds'], + showAtForm: false, + showAtField: true, + page: 1, + tab: 'aspirate', +} +const ASPIRATE_AIRGAP_VOLUME_REQUIRED: FormError = { + title: 'Volume required', + dependentFields: ['aspirate_airGap_checkbox', 'aspirate_airGap_volume'], + showAtForm: false, + showAtField: true, + page: 1, + tab: 'aspirate', +} +const DISPENSE_MIX_TIMES_REQUIRED: FormError = { + title: 'Repititions required', + dependentFields: ['dispense_mix_checkbox', 'dispense_mix_times'], + showAtForm: false, + showAtField: true, + page: 1, + tab: 'dispense', +} +const DISPENSE_MIX_VOLUME_REQUIRED: FormError = { + title: 'Volume required', + dependentFields: ['dispense_mix_checkbox', 'dispense_mix_volume'], + showAtForm: false, + showAtField: true, + page: 1, + tab: 'dispense', +} +const DISPENSE_DELAY_DURATION_REQUIRED: FormError = { + title: 'Duration required', + dependentFields: ['dispense_delay_checkbox', 'dispense_delay_seconds'], + showAtForm: false, + showAtField: true, + page: 1, + tab: 'dispense', +} +const DISPENSE_AIRGAP_VOLUME_REQUIRED: FormError = { + title: 'Volume required', + dependentFields: ['dispense_airGap_checkbox', 'dispense_airGap_volume'], + showAtForm: false, + showAtField: true, + page: 1, + tab: 'dispense', +} +const BLOWOUT_LOCATION_REQUIRED: FormError = { + title: 'Volume required', + dependentFields: ['blowout_checkbox', 'blowout_location'], + showAtForm: false, + showAtField: true, + page: 1, + tab: 'dispense', +} export interface HydratedFormData { [key: string]: any @@ -357,7 +507,7 @@ export const targetTemperatureRequired = ( fields: HydratedFormData ): FormError | null => { const { setTemperature, targetTemperature } = fields - return setTemperature === 'true' && !targetTemperature + return setTemperature && !targetTemperature ? TARGET_TEMPERATURE_REQUIRED : null } @@ -429,6 +579,12 @@ export const temperatureRequired = ( ? HS_TEMPERATURE_REQUIRED : null } +export const pauseActionRequired = ( + fields: HydratedFormData +): FormError | null => { + const { pauseAction } = fields + return pauseAction == null ? PAUSE_ACTION_REQUIRED : null +} export const pauseTimeRequired = ( fields: HydratedFormData ): FormError | null => { @@ -437,6 +593,14 @@ export const pauseTimeRequired = ( ? PAUSE_TIME_REQUIRED : null } +export const pauseModuleRequired = ( + fields: HydratedFormData +): FormError | null => { + const { moduleId, pauseAction } = fields + return pauseAction === PAUSE_UNTIL_TEMP && moduleId == null + ? PAUSE_MODULE_REQUIRED + : null +} export const pauseTemperatureRequired = ( fields: HydratedFormData ): FormError | null => { @@ -455,8 +619,10 @@ export const newLabwareLocationRequired = ( fields: HydratedFormData ): FormError | null => { const { newLocation } = fields - console.log(fields) - return newLocation == null ? NEW_LABWARE_LOCATION_REQUIRED : null + return newLocation == null || + Object.values(newLocation as Object).every(val => val == null) + ? NEW_LABWARE_LOCATION_REQUIRED + : null } export const engageHeightRangeExceeded = ( fields: HydratedFormData @@ -485,6 +651,131 @@ export const engageHeightRangeExceeded = ( return null } +export const aspirateWellsRequired = ( + fields: HydratedFormData +): FormError | null => { + const { aspirate_wells } = fields + return aspirate_wells == null || aspirate_wells.length === 0 + ? ASPIRATE_WELLS_REQUIRED + : null +} +export const dispenseWellsRequired = ( + fields: HydratedFormData +): FormError | null => { + const { dispense_wells, dispense_labware } = fields + return (dispense_wells == null || dispense_wells.length === 0) && + !( + dispense_labware != null && + (dispense_labware.name === 'wasteChute' || + dispense_labware.name === 'trashBin') + ) + ? DISPENSE_WELLS_REQUIRED + : null +} +export const mixWellsRequired = ( + fields: HydratedFormData +): FormError | null => { + const { wells } = fields + return wells == null || wells.length === 0 ? MIX_WELLS_REQUIRED : null +} +export const volumeRequired = (fields: HydratedFormData): FormError | null => { + const { volume } = fields + return !volume ? VOLUME_REQUIRED : null +} +export const timesRequired = (fields: HydratedFormData): FormError | null => { + const { times } = fields + return !times ? TIMES_REQUIRED : null +} +export const aspirateLabwareRequired = ( + fields: HydratedFormData +): FormError | null => { + const { aspirate_labware } = fields + return aspirate_labware == null ? ASPIRATE_LABWARE_REQUIRED : null +} +export const dispenseLabwareRequired = ( + fields: HydratedFormData +): FormError | null => { + const { dispense_labware } = fields + return dispense_labware == null ? DISPENSE_LABWARE_REQUIRED : null +} +export const mixLabwareRequired = ( + fields: HydratedFormData +): FormError | null => { + const { labware } = fields + return labware == null ? MIX_LABWARE_REQUIRED : null +} +export const aspirateMixTimesRequired = ( + fields: HydratedFormData +): FormError | null => { + const { aspirate_mix_checkbox, aspirate_mix_times } = fields + return aspirate_mix_checkbox && !aspirate_mix_times + ? ASPIRATE_MIX_TIMES_REQUIRED + : null +} +export const aspirateMixVolumeRequired = ( + fields: HydratedFormData +): FormError | null => { + const { aspirate_mix_checkbox, aspirate_mix_volume } = fields + return aspirate_mix_checkbox && !aspirate_mix_volume + ? ASPIRATE_MIX_VOLUME_REQUIRED + : null +} +export const aspirateDelayDurationRequired = ( + fields: HydratedFormData +): FormError | null => { + const { aspirate_delay_seconds, aspirate_delay_checkbox } = fields + return aspirate_delay_checkbox && !aspirate_delay_seconds + ? ASPIRATE_DELAY_DURATION_REQUIRED + : null +} +export const aspirateAirGapVolumeRequired = ( + fields: HydratedFormData +): FormError | null => { + const { aspirate_airGap_checkbox, aspirate_airGap_volume } = fields + return aspirate_airGap_checkbox && !aspirate_airGap_volume + ? ASPIRATE_AIRGAP_VOLUME_REQUIRED + : null +} +export const dispenseMixTimesRequired = ( + fields: HydratedFormData +): FormError | null => { + const { dispense_mix_checkbox, dispense_mix_times } = fields + return dispense_mix_checkbox && !dispense_mix_times + ? DISPENSE_MIX_TIMES_REQUIRED + : null +} +export const dispenseMixVolumeRequired = ( + fields: HydratedFormData +): FormError | null => { + const { dispense_mix_checkbox, dispense_mix_volume } = fields + return dispense_mix_checkbox && !dispense_mix_volume + ? DISPENSE_MIX_VOLUME_REQUIRED + : null +} +export const dispenseDelayDurationRequired = ( + fields: HydratedFormData +): FormError | null => { + const { dispense_delay_seconds, dispense_delay_checkbox } = fields + return dispense_delay_checkbox && !dispense_delay_seconds + ? DISPENSE_DELAY_DURATION_REQUIRED + : null +} +export const dispenseAirGapVolumeRequired = ( + fields: HydratedFormData +): FormError | null => { + const { dispense_airGap_checkbox, dispense_airGap_volume } = fields + return dispense_airGap_checkbox && !dispense_airGap_volume + ? DISPENSE_AIRGAP_VOLUME_REQUIRED + : null +} +export const blowoutLocationRequired = ( + fields: HydratedFormData +): FormError | null => { + const { blowout_checkbox, blowout_location } = fields + return blowout_checkbox && !blowout_location + ? BLOWOUT_LOCATION_REQUIRED + : null +} /******************* ** Helpers ** diff --git a/protocol-designer/src/steplist/formLevel/index.ts b/protocol-designer/src/steplist/formLevel/index.ts index dd7b9fea36c..937d8edee64 100644 --- a/protocol-designer/src/steplist/formLevel/index.ts +++ b/protocol-designer/src/steplist/formLevel/index.ts @@ -21,6 +21,27 @@ import { shakeTimeRequired, pauseTimeRequired, pauseTemperatureRequired, + newLabwareLocationRequired, + labwareToMoveRequired, + pauseModuleRequired, + aspirateLabwareRequired, + dispenseLabwareRequired, + aspirateMixVolumeRequired, + aspirateMixTimesRequired, + aspirateDelayDurationRequired, + aspirateAirGapVolumeRequired, + dispenseMixTimesRequired, + dispenseDelayDurationRequired, + dispenseAirGapVolumeRequired, + dispenseMixVolumeRequired, + blowoutLocationRequired, + aspirateWellsRequired, + dispenseWellsRequired, + mixWellsRequired, + mixLabwareRequired, + volumeRequired, + timesRequired, + pauseActionRequired, } from './errors' import { @@ -63,20 +84,52 @@ const stepFormHelperMap: Partial> = { ), }, mix: { - getErrors: composeErrors(incompatibleLabware, volumeTooHigh), + getErrors: composeErrors( + incompatibleLabware, + volumeTooHigh, + mixWellsRequired, + mixLabwareRequired, + volumeRequired, + timesRequired, + aspirateDelayDurationRequired, + dispenseDelayDurationRequired, + blowoutLocationRequired + ), getWarnings: composeWarnings( belowPipetteMinimumVolume, mixTipPositionInTube ), }, pause: { - getErrors: composeErrors(pauseTimeRequired, pauseTemperatureRequired), + getErrors: composeErrors( + pauseActionRequired, + pauseTimeRequired, + pauseTemperatureRequired, + pauseModuleRequired + ), + }, + moveLabware: { + getErrors: composeErrors(labwareToMoveRequired, newLabwareLocationRequired), }, moveLiquid: { getErrors: composeErrors( incompatibleAspirateLabware, incompatibleDispenseLabware, - wellRatioMoveLiquid + wellRatioMoveLiquid, + volumeRequired, + aspirateLabwareRequired, + dispenseLabwareRequired, + aspirateMixTimesRequired, + aspirateMixVolumeRequired, + aspirateDelayDurationRequired, + aspirateAirGapVolumeRequired, + dispenseMixTimesRequired, + dispenseMixVolumeRequired, + dispenseDelayDurationRequired, + dispenseAirGapVolumeRequired, + blowoutLocationRequired, + aspirateWellsRequired, + dispenseWellsRequired ), getWarnings: composeWarnings( belowPipetteMinimumVolume, diff --git a/protocol-designer/src/steplist/formLevel/moveLabwareFormErrors.ts b/protocol-designer/src/steplist/formLevel/moveLabwareFormErrors.ts index b9ee871772d..3362a0a2bff 100644 --- a/protocol-designer/src/steplist/formLevel/moveLabwareFormErrors.ts +++ b/protocol-designer/src/steplist/formLevel/moveLabwareFormErrors.ts @@ -32,7 +32,7 @@ const getMoveLabwareError = ( invariantContext.moduleEntities[newLocation.moduleId].type const modAllowList = COMPATIBLE_LABWARE_ALLOWLIST_BY_MODULE_TYPE[moduleType] errorString = !modAllowList.includes(loadName) - ? 'labware incompatible with this module' + ? 'Labware incompatible with this module' : null } else if ('labwareId' in newLocation) { const adapterValueDefUri = @@ -41,7 +41,7 @@ const getMoveLabwareError = ( const adapterAllowList = COMPATIBLE_LABWARE_ALLOWLIST_FOR_ADAPTER[adapterValueDefUri] errorString = !adapterAllowList?.includes(selectedLabwareDefUri) - ? 'labware incompatible with this adapter' + ? 'Labware incompatible with this adapter' : null } return errorString @@ -68,7 +68,7 @@ export const getMoveLabwareFormErrors = ( ? ([ { title: errorString, - dependentProfileFields: [], + dependentProfileFields: ['newLocation'], }, ] as ProfileFormError[]) : [] diff --git a/protocol-designer/src/steplist/formLevel/warnings.tsx b/protocol-designer/src/steplist/formLevel/warnings.tsx index ef9e5a62603..d40bf9b20af 100644 --- a/protocol-designer/src/steplist/formLevel/warnings.tsx +++ b/protocol-designer/src/steplist/formLevel/warnings.tsx @@ -1,5 +1,4 @@ import { getWellTotalVolume } from '@opentrons/shared-data' -import { KnowledgeBaseLink } from '../../components/KnowledgeBaseLink' import type { FormError } from './errors' import type { LabwareDefinition2 } from '@opentrons/shared-data' @@ -29,13 +28,8 @@ const belowMinAirGapVolumeWarning = (min: number): FormWarning => ({ const belowPipetteMinVolumeWarning = (min: number): FormWarning => ({ type: 'BELOW_PIPETTE_MINIMUM_VOLUME', title: `Disposal volume is below recommended minimum (${min} uL)`, - body: ( - <> - { - 'For accuracy in multi-dispense Transfers we recommend you use a disposal volume of at least the pipette`s minimum. Read more ' - } - - ), + body: + 'For accuracy in multi-dispense Transfers we recommend you use a disposal volume of at least the pipette`s minimum.', dependentFields: ['pipette', 'volume'], }) @@ -48,14 +42,8 @@ const overMaxWellVolumeWarning = (): FormWarning => ({ const belowMinDisposalVolumeWarning = (min: number): FormWarning => ({ type: 'BELOW_MIN_DISPOSAL_VOLUME', title: `Disposal volume is below recommended minimum (${min} uL)`, - body: ( - <> - { - 'For accuracy in multi-dispense Transfers we recommend you use a disposal volume of at least the pipette`s minimum. Read more ' - } - {'here'}. - - ), + body: + 'For accuracy in multi-dispense Transfers we recommend you use a disposal volume of at least the pipette`s minimum.', dependentFields: ['disposalVolume_volume', 'pipette'], })