diff --git a/opentrons-ai-client/src/OpentronsAIRoutes.tsx b/opentrons-ai-client/src/OpentronsAIRoutes.tsx index b790b262ebe..32d09f351cf 100644 --- a/opentrons-ai-client/src/OpentronsAIRoutes.tsx +++ b/opentrons-ai-client/src/OpentronsAIRoutes.tsx @@ -1,10 +1,18 @@ import { Route, Navigate, Routes } from 'react-router-dom' import { Landing } from './pages/Landing' +import { UpdateProtocol } from './organisms/UpdateProtocol' import type { RouteProps } from './resources/types' +import { Chat } from './pages/Chat' import { CreateProtocol } from './pages/CreateProtocol' const opentronsAIRoutes: RouteProps[] = [ + { + Component: Chat, + name: 'Chat', + navLinkTo: '/chat', + path: '/chat', + }, { Component: CreateProtocol, name: 'Create A New Protocol', @@ -12,7 +20,7 @@ const opentronsAIRoutes: RouteProps[] = [ path: '/new-protocol', }, { - Component: Landing, + Component: UpdateProtocol, name: 'Update An Existing Protocol', navLinkTo: '/update-protocol', path: '/update-protocol', 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 0f38061ff47..e3e23b4f0d3 100644 --- a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json +++ b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json @@ -5,8 +5,11 @@ "commands": "Commands: List the protocol's steps, specifying quantities in microliters (uL) and giving exact source and destination locations.", "copyright": "Copyright © 2024 Opentrons", "copy_code": "Copy code", + "choose_file": "Choose file", "disclaimer": "OpentronsAI can make mistakes. Review your protocol before running it on an Opentrons robot.", + "drag_and_drop": "Drag and drop or browse your files", "example": "For example prompts, click the buttons in the left panel.", + "file_length_error": "The length of the file contents is 0. Please upload a file with content.", "exit": "Exit", "exit_confirmation_title": "Are you sure you want to exit?", "exit_confirmation_body": "Exiting now will discard your progress.", @@ -26,6 +29,10 @@ "login": "Login", "logout": "Logout", "make_sure_your_prompt": "Write a prompt in a natural language for OpentronsAI to generate a protocol using the Opentrons Python Protocol API v2. The better the prompt, the better the quality of the protocol produced by OpentronsAI.", + "modify_intro": "Modify the following Python code using the Opentrons Python Protocol API v2. Ensure that the new labware and pipettes are compatible with the Flex robot. Ensure that you perform the correct Type of Update use the Details of Changes.\n\n", + "modify_python_code": "Original Python Code:\n", + "modify_type_of_update": "Type of update:\n- ", + "modify_details_of_change": "Details of Changes:\n- ", "modules_and_adapters": "Modules and adapters: Specify the modules and labware adapters required by your protocol.", "notes": "A few important things to note:", "opentrons": "Opentrons", @@ -35,6 +42,9 @@ "pcr": "PCR", "pipettes": "Pipettes: Specify your pipettes, including the volume, number of channels, and whether they’re mounted on the left or right.", "privacy_policy": "By continuing, you agree to the Opentrons Privacy Policy and End user license agreement", + "protocol_file": "Protocol file", + "provide_details_of_changes": "Provide details of changes you want to make", + "python_file_type_error": "Python file type required", "reagent_transfer_flex": "Reagent Transfer (Flex)", "reagent_transfer": "Reagent Transfer", "reload_page": "To start over and create a new protocol, simply reload the page.", @@ -44,8 +54,11 @@ "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.", + "submit_prompt": "Submit prompt", "try_example_prompts": "Stuck? Try these example prompts to get started.", + "type_of_update": "Type of update", "type_your_prompt": "Type your prompt...", + "update_existing_protocol": "Update an existing protocol", "well_allocations": "Well allocations: Describe where liquids should go in labware.", "what_if_you": "What if you don’t provide all of those pieces of information? OpentronsAI asks you to provide it!", "what_typeof_protocol": "What type of protocol do you need?", diff --git a/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx b/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx index 4eaa840dbcb..dd39d415acc 100644 --- a/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx +++ b/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx @@ -18,6 +18,7 @@ import { SPACING, LegacyStyledText, TYPOGRAPHY, + OVERFLOW_AUTO, } from '@opentrons/components' import type { ChatData } from '../../resources/types' @@ -63,6 +64,7 @@ export function ChatDisplay({ chat, chatId }: ChatDisplayProps): JSX.Element { data-testid={`ChatDisplay_from_${isUser ? 'user' : 'backend'}`} borderRadius={BORDERS.borderRadius12} width="100%" + overflowY={OVERFLOW_AUTO} flexDirection={DIRECTION_COLUMN} gridGap={SPACING.spacing16} position={POSITION_RELATIVE} diff --git a/opentrons-ai-client/src/molecules/FileUpload/index.tsx b/opentrons-ai-client/src/molecules/FileUpload/index.tsx new file mode 100644 index 00000000000..551c3d0bd05 --- /dev/null +++ b/opentrons-ai-client/src/molecules/FileUpload/index.tsx @@ -0,0 +1,74 @@ +import { css } from 'styled-components' + +import { + ALIGN_CENTER, + BORDERS, + Btn, + COLORS, + DIRECTION_COLUMN, + Flex, + Icon, + JUSTIFY_SPACE_BETWEEN, + SPACING, + LegacyStyledText, + truncateString, +} from '@opentrons/components' + +const FILE_UPLOAD_STYLE = css` + +&:hover > svg { + background: ${COLORS.black90}${COLORS.opacity20HexCode}; +} +&:active > svg { + background: ${COLORS.black90}${COLORS.opacity20HexCode}}; +} +` + +const FILE_UPLOAD_FOCUS_VISIBLE = css` + &:focus-visible { + border-radius: ${BORDERS.borderRadius4}; + box-shadow: 0 0 0 ${SPACING.spacing2} ${COLORS.blue50}; + } +` + +interface FileUploadProps { + file: File + fileError: string | null + handleClick: () => unknown +} + +export function FileUpload({ + file, + fileError, + handleClick, +}: FileUploadProps): JSX.Element { + return ( + + + + + {truncateString(file.name, 34, 19)} + + + + + {fileError != null ? ( + + {fileError} + + ) : null} + + ) +} diff --git a/opentrons-ai-client/src/molecules/InputPrompt/index.tsx b/opentrons-ai-client/src/molecules/InputPrompt/index.tsx index d4c4cdf5f8d..70ee01560f4 100644 --- a/opentrons-ai-client/src/molecules/InputPrompt/index.tsx +++ b/opentrons-ai-client/src/molecules/InputPrompt/index.tsx @@ -15,7 +15,12 @@ import { TYPOGRAPHY, } from '@opentrons/components' import { SendButton } from '../../atoms/SendButton' -import { chatDataAtom, chatHistoryAtom, tokenAtom } from '../../resources/atoms' +import { + chatDataAtom, + chatHistoryAtom, + chatPromptAtom, + tokenAtom, +} from '../../resources/atoms' import { useApiCall } from '../../resources/hooks' import { calcTextAreaHeight } from '../../resources/utils/utils' import { @@ -29,7 +34,8 @@ import type { ChatData } from '../../resources/types' export function InputPrompt(): JSX.Element { const { t } = useTranslation('protocol_generator') - const { register, watch, reset } = useFormContext() + const { register, watch, reset, setValue } = useFormContext() + const [chatPromptAtomValue] = useAtom(chatPromptAtom) const [, setChatData] = useAtom(chatDataAtom) const [chatHistory, setChatHistory] = useAtom(chatHistoryAtom) const [token] = useAtom(tokenAtom) @@ -37,6 +43,10 @@ export function InputPrompt(): JSX.Element { const userPrompt = watch('userPrompt') ?? '' const { data, isLoading, callApi } = useApiCall() + useEffect(() => { + setValue('userPrompt', chatPromptAtomValue) + }, [chatPromptAtomValue, setValue]) + const handleClick = async (): Promise => { const userInput: ChatData = { role: 'user', diff --git a/opentrons-ai-client/src/molecules/UploadInput/index.tsx b/opentrons-ai-client/src/molecules/UploadInput/index.tsx new file mode 100644 index 00000000000..77dc5a2616d --- /dev/null +++ b/opentrons-ai-client/src/molecules/UploadInput/index.tsx @@ -0,0 +1,168 @@ +import * as React from 'react' +import styled, { css } from 'styled-components' +import { useTranslation } from 'react-i18next' +import { + ALIGN_CENTER, + BORDERS, + COLORS, + CURSOR_POINTER, + DIRECTION_COLUMN, + DISPLAY_FLEX, + Flex, + Icon, + JUSTIFY_CENTER, + LegacyStyledText, + POSITION_FIXED, + PrimaryButton, + SPACING, + TYPOGRAPHY, +} from '@opentrons/components' + +const StyledLabel = styled.label` + display: ${DISPLAY_FLEX}; + cursor: ${CURSOR_POINTER}; + flex-direction: ${DIRECTION_COLUMN}; + align-items: ${ALIGN_CENTER}; + width: 100%; + padding: ${SPACING.spacing32}; + border: 2px dashed ${COLORS.grey30}; + border-radius: ${BORDERS.borderRadius4}; + text-align: ${TYPOGRAPHY.textAlignCenter}; + background-color: ${COLORS.white}; + + &:hover { + border: 2px dashed ${COLORS.blue50}; + } +` +const DRAG_OVER_STYLES = css` + border: 2px dashed ${COLORS.blue50}; +` + +const StyledInput = styled.input` + position: ${POSITION_FIXED}; + clip: rect(1px 1px 1px 1px); +` + +export interface UploadInputProps { + /** Callback function that is called when a file is uploaded. */ + onUpload: (file: File) => unknown + /** Optional callback function that is called when the upload button is clicked. */ + onClick?: () => void + /** Optional text for the upload button. If undefined, the button displays Upload */ + uploadButtonText?: string + /** Optional text or JSX element that is displayed above the upload button. */ + uploadText?: string | JSX.Element + /** Optional text or JSX element that is displayed in the drag and drop area. */ + dragAndDropText?: string | JSX.Element +} + +export function UploadInput(props: UploadInputProps): JSX.Element | null { + const { + dragAndDropText, + onClick, + onUpload, + uploadButtonText, + uploadText, + } = props + const { t } = useTranslation('protocol_info') + + const fileInput = React.useRef(null) + const [isFileOverDropZone, setIsFileOverDropZone] = React.useState( + false + ) + const [isHover, setIsHover] = React.useState(false) + const handleDrop: React.DragEventHandler = e => { + e.preventDefault() + e.stopPropagation() + Array.from(e.dataTransfer.files).forEach(f => onUpload(f)) + setIsFileOverDropZone(false) + } + const handleDragEnter: React.DragEventHandler = e => { + e.preventDefault() + e.stopPropagation() + } + const handleDragLeave: React.DragEventHandler = e => { + e.preventDefault() + e.stopPropagation() + setIsFileOverDropZone(false) + setIsHover(false) + } + const handleDragOver: React.DragEventHandler = e => { + e.preventDefault() + e.stopPropagation() + setIsFileOverDropZone(true) + setIsHover(true) + } + + const handleClick: React.MouseEventHandler = _event => { + onClick != null ? onClick() : fileInput.current?.click() + } + + const onChange: React.ChangeEventHandler = event => { + ;[...(event.target.files ?? [])].forEach(f => onUpload(f)) + if ('value' in event.currentTarget) event.currentTarget.value = '' + } + + return ( + + {uploadText != null ? ( + <> + {typeof uploadText === 'string' ? ( + + {uploadText} + + ) : ( + <>{uploadText} + )} + + ) : null} + + {uploadButtonText ?? t('upload')} + + + { + setIsHover(true) + }} + onMouseLeave={() => { + setIsHover(false) + }} + css={isFileOverDropZone ? DRAG_OVER_STYLES : undefined} + > + + {dragAndDropText} + + + + ) +} diff --git a/opentrons-ai-client/src/organisms/MainContentContainer/__tests__/MainContentContainer.test.tsx b/opentrons-ai-client/src/organisms/MainContentContainer/__tests__/MainContentContainer.test.tsx index 4598eddc49e..d3014a5895b 100644 --- a/opentrons-ai-client/src/organisms/MainContentContainer/__tests__/MainContentContainer.test.tsx +++ b/opentrons-ai-client/src/organisms/MainContentContainer/__tests__/MainContentContainer.test.tsx @@ -25,7 +25,6 @@ describe('MainContentContainer', () => { it('should render prompt guide and text', () => { render() - screen.getByText('OpentronsAI') screen.getByText('mock PromptGuide') screen.getByText('mock ChatFooter') }) diff --git a/opentrons-ai-client/src/organisms/MainContentContainer/index.tsx b/opentrons-ai-client/src/organisms/MainContentContainer/index.tsx index b5b495a691e..cc2ad54bc39 100644 --- a/opentrons-ai-client/src/organisms/MainContentContainer/index.tsx +++ b/opentrons-ai-client/src/organisms/MainContentContainer/index.tsx @@ -1,15 +1,12 @@ import { useRef, useEffect } from 'react' -import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { useAtom } from 'jotai' import { - COLORS, DIRECTION_COLUMN, Flex, OVERFLOW_AUTO, SPACING, - LegacyStyledText, } from '@opentrons/components' import { PromptGuide } from '../../molecules/PromptGuide' import { ChatDisplay } from '../../molecules/ChatDisplay' @@ -17,7 +14,6 @@ import { ChatFooter } from '../../molecules/ChatFooter' import { chatDataAtom } from '../../resources/atoms' export function MainContentContainer(): JSX.Element { - const { t } = useTranslation('protocol_generator') const [chatData] = useAtom(chatDataAtom) const scrollRef = useRef(null) @@ -32,14 +28,11 @@ export function MainContentContainer(): JSX.Element { return ( - {/* Prompt Guide remain as a reference for users. */} - {t('opentronsai')} @@ -74,6 +65,5 @@ export function MainContentContainer(): JSX.Element { const ChatDataContainer = styled(Flex)` flex-direction: ${DIRECTION_COLUMN}; - grid-gap: ${SPACING.spacing40}; width: 100%; ` diff --git a/opentrons-ai-client/src/organisms/UpdateProtocol/__tests__/UpdateProtocol.test.tsx b/opentrons-ai-client/src/organisms/UpdateProtocol/__tests__/UpdateProtocol.test.tsx new file mode 100644 index 00000000000..04c3ad3b167 --- /dev/null +++ b/opentrons-ai-client/src/organisms/UpdateProtocol/__tests__/UpdateProtocol.test.tsx @@ -0,0 +1,125 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { describe, it, vi, beforeEach, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import type { NavigateFunction } from 'react-router-dom' + +import { UpdateProtocol } from '../index' +import { i18n } from '../../../i18n' + +// global.Blob = BlobPolyfill as any +global.Blob = require('node:buffer').Blob + +const mockNavigate = vi.fn() +const mockUseTrackEvent = vi.fn() +const mockUseChatData = vi.fn() + +vi.mock('../../../resources/hooks/useTrackEvent', () => ({ + useTrackEvent: () => mockUseTrackEvent, +})) + +File.prototype.text = vi.fn().mockResolvedValue('test file content') + +vi.mock('../../../resources/chatDataAtom', () => ({ + chatDataAtom: () => mockUseChatData, +})) + +vi.mock('react-router-dom', async importOriginal => { + const reactRouterDom = await importOriginal() + return { + ...reactRouterDom, + useNavigate: () => mockNavigate, + } +}) + +const render = () => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('Update Protocol', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render', () => { + render() + expect(screen.getByText('Update an existing protocol')).toBeInTheDocument() + expect(screen.getByText('Choose file')).toBeInTheDocument() + expect(screen.getByText('Protocol file')).toBeInTheDocument() + expect(screen.getByText('Choose file')).toBeInTheDocument() + expect(screen.getByText('Type of update')).toBeInTheDocument() + expect(screen.getByText('Select an option')).toBeInTheDocument() + expect( + screen.getByText('Provide details of changes you want to make') + ).toBeInTheDocument() + }) + + it('should update the file value when the file is uploaded', async () => { + render() + + const blobParts: BlobPart[] = [ + 'x = 1\n', + 'x = 2\n', + 'x = 3\n', + 'x = 4\n', + 'print("x is 1.")\n', + ] + + const file = new File(blobParts, 'test-file.py', { type: 'text/python' }) + + fireEvent.drop(screen.getByTestId('file_drop_zone'), { + dataTransfer: { + files: [file], + }, + }) + + await waitFor(() => { + expect(screen.getByText('test-file.py')).toBeInTheDocument() + }) + }) + + it('should not proceed when you click the submit prompt when the progress percentage is not 1.0', () => { + render() + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it.skip('should call navigate to the chat page when the submit prompt button is clicked when progress is 1.0', async () => { + render() + + // upload file + const blobParts: BlobPart[] = [ + 'x = 1\n', + 'x = 2\n', + 'x = 3\n', + 'x = 4\n', + 'print("x is 1.")\n', + ] + const file = new File(blobParts, 'test-file.py', { type: 'text/python' }) + fireEvent.drop(screen.getByTestId('file_drop_zone'), { + dataTransfer: { + files: [file], + }, + }) + + // input description + const describeInput = screen.getByRole('textbox') + fireEvent.change(describeInput, { target: { value: 'Test description' } }) + + expect(screen.getByDisplayValue('Test description')).toBeInTheDocument() + + // select update type + const applicationDropdown = screen.getByText('Select an option') + fireEvent.click(applicationDropdown) + + const basicOtherOption = screen.getByText('Other') + fireEvent.click(basicOtherOption) + + const submitPromptButton = screen.getByText('Submit prompt') + await waitFor(() => { + expect(submitPromptButton).toBeEnabled() + submitPromptButton.click() + }) + expect(mockNavigate).toHaveBeenCalledWith('/chat') + }) +}) diff --git a/opentrons-ai-client/src/organisms/UpdateProtocol/index.tsx b/opentrons-ai-client/src/organisms/UpdateProtocol/index.tsx new file mode 100644 index 00000000000..f0f8f4c7e12 --- /dev/null +++ b/opentrons-ai-client/src/organisms/UpdateProtocol/index.tsx @@ -0,0 +1,287 @@ +import styled from 'styled-components' +import { + COLORS, + DIRECTION_COLUMN, + DIRECTION_ROW, + Flex, + InputField, + JUSTIFY_CENTER, + JUSTIFY_END, + LargeButton, + StyledText, + Link as LinkComponent, + DropdownMenu, +} from '@opentrons/components' +import type { DropdownOption } from '@opentrons/components' +import { UploadInput } from '../../molecules/UploadInput' +import { useEffect, useState } from 'react' +import type { ChangeEvent } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { FileUpload } from '../../molecules/FileUpload' +import { useNavigate } from 'react-router-dom' +import { chatPromptAtom, headerWithMeterAtom } from '../../resources/atoms' +import { CSSTransition } from 'react-transition-group' +import { useAtom } from 'jotai' + +const updateOptions: DropdownOption[] = [ + { + name: 'Adapt Python protocol from OT-2 to Flex', + value: 'adapt_python_protocol', + }, + { name: 'Change labware', value: 'change_labware' }, + { name: 'Change pipettes', value: 'change_pipettes' }, + { name: 'Other', value: 'other' }, +] + +const FadeWrapper = styled.div` + &.fade-enter { + opacity: 0; + } + &.fade-enter-active { + opacity: 1; + transition: opacity 1000ms; + } + &.fade-exit { + height: 100%; + opacity: 1; + } + &.fade-exit-active { + opacity: 0; + height: 0%; + transition: opacity 1000ms; + } +` + +const Container = styled(Flex)` + width: 100%; + flex-direction: ${DIRECTION_COLUMN}; + align-items: ${JUSTIFY_CENTER}; +` + +const Spacer = styled(Flex)` + height: 16px; +` + +const ContentBox = styled(Flex)` + background-color: white; + border-radius: 16px; + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_CENTER}; + padding: 32px 24px; + width: 60%; +` + +const HeadingText = styled(StyledText).attrs({ + desktopStyle: 'headingSmallBold', +})`` + +const BodyText = styled(StyledText).attrs({ + color: COLORS.grey60, + desktopStyle: 'bodyDefaultRegular', + paddingBottom: '8px', + paddingTop: '16px', +})`` + +const isValidProtocolFileName = (protocolFileName: string): boolean => { + return protocolFileName.endsWith('.py') +} + +export function UpdateProtocol(): JSX.Element { + const navigate = useNavigate() + const { t }: { t: (key: string) => string } = useTranslation( + 'protocol_generator' + ) + const [, setChatPrompt] = useAtom(chatPromptAtom) + const [headerState, setHeaderWithMeterAtom] = useAtom(headerWithMeterAtom) + const [updateType, setUpdateType] = useState(null) + const [detailsValue, setDetailsValue] = useState('') + const [fileValue, setFile] = useState(null) + const [pythonText, setPythonTextValue] = useState('') + const [errorText, setErrorText] = useState(null) + + useEffect(() => { + let progress = 0.0 + if (updateType !== null) { + progress += 0.33 + } + + if (detailsValue !== '') { + progress += 0.33 + } + + if (pythonText !== '' && fileValue !== null && errorText === null) { + progress += 0.34 + } + + setHeaderWithMeterAtom({ + displayHeaderWithMeter: true, + progress, + }) + }, [ + updateType, + detailsValue, + pythonText, + errorText, + fileValue, + setHeaderWithMeterAtom, + ]) + + const handleInputChange = (event: ChangeEvent): void => { + setDetailsValue(event.target.value) + } + + const handleFileUpload = async ( + file: File & { name: string } + ): Promise => { + if (isValidProtocolFileName(file.name)) { + const text = await file.text().catch(error => { + console.error('Error reading file:', error) + setErrorText(t('python_file_read_error')) + }) + + if (typeof text === 'string' && text !== '') { + setErrorText(null) + console.log('File read successfully:\n', text) + setPythonTextValue(text) + } else { + setErrorText(t('file_length_error')) + } + + setFile(file) + } else { + setErrorText(t('python_file_type_error')) + setFile(file) + } + } + + function processDataAndNavigateToChat(): void { + const introText = t('modify_intro') + const originalCodeText = + t('modify_python_code') + `\`\`\`python\n` + pythonText + `\n\`\`\`\n\n` + const updateTypeText = + t('modify_type_of_update') + updateType?.value + `\n\n` + const detailsText = t('modify_details_of_change') + detailsValue + '\n' + + const chatPrompt = `${introText}${originalCodeText}${updateTypeText}${detailsText}` + + console.log(chatPrompt) + + setChatPrompt(chatData => chatPrompt) + navigate('/chat') + } + + return ( + + + + {t('update_existing_protocol')} + {t('protocol_file')} + + + + {fileValue !== null ? ( + + + + ) : null} + + + + + + + + ), + }} + /> + + } + onUpload={async function (file: File) { + try { + await handleFileUpload(file) + } catch (error) { + // todo perhaps make this a toast? + console.error('Error uploading file:', error) + } + }} + /> + + + + + + { + const selectedOption = updateOptions.find(v => v.value === value) + if (selectedOption != null) { + setUpdateType(selectedOption) + } + }} + /> + + {t('provide_details_of_changes')} + + + + + + + ) +} diff --git a/opentrons-ai-client/src/pages/Chat/index.tsx b/opentrons-ai-client/src/pages/Chat/index.tsx new file mode 100644 index 00000000000..6d9492038b8 --- /dev/null +++ b/opentrons-ai-client/src/pages/Chat/index.tsx @@ -0,0 +1,38 @@ +import { useForm, FormProvider } from 'react-hook-form' +import { + DIRECTION_COLUMN, + DIRECTION_ROW, + Flex, + JUSTIFY_CENTER, + POSITION_RELATIVE, +} from '@opentrons/components' + +import { MainContentContainer } from '../../organisms/MainContentContainer' + +export interface InputType { + userPrompt: string +} + +export function Chat(): JSX.Element | null { + const methods = useForm({ + defaultValues: { + userPrompt: '', + }, + }) + + return ( + + + + {/* */} + + + + + ) +} diff --git a/opentrons-ai-client/src/resources/atoms.ts b/opentrons-ai-client/src/resources/atoms.ts index 229e9fbe739..b92f3e848f5 100644 --- a/opentrons-ai-client/src/resources/atoms.ts +++ b/opentrons-ai-client/src/resources/atoms.ts @@ -11,6 +11,9 @@ import type { /** ChatDataAtom is for chat data (user prompt and response from OpenAI API) */ export const chatDataAtom = atom([]) +/** ChatPromptAtom is for the prefilled userprompt when landing on the chat page */ +export const chatPromptAtom = atom('') + export const chatHistoryAtom = atom([]) export const tokenAtom = atom(null) 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 1b9d1f72898..622464e6547 100644 --- a/protocol-designer/src/assets/localization/en/create_new_protocol.json +++ b/protocol-designer/src/assets/localization/en/create_new_protocol.json @@ -32,6 +32,8 @@ "show_default_tips": "Show default tips", "show_tips": "Show incompatible tips", "slots_limit_reached": "Slots limit reached", + "staging_area_has_labware": "This staging area slot has labware", + "staging_area_will_delete_labware": "The staging area slot that you are about to delete has labware placed on it. If you make these changes to your protocol starting deck, the labware will be deleted as well.", "stagingArea": "Staging area", "swap_pipettes": "Swap pipettes", "tell_us": "Tell us about your protocol", diff --git a/protocol-designer/src/organisms/ConfirmDeleteStagingAreaModal/__tests__/ConfirmDeleteStagingAreaModal.test.tsx b/protocol-designer/src/organisms/ConfirmDeleteStagingAreaModal/__tests__/ConfirmDeleteStagingAreaModal.test.tsx new file mode 100644 index 00000000000..9f6e2991e73 --- /dev/null +++ b/protocol-designer/src/organisms/ConfirmDeleteStagingAreaModal/__tests__/ConfirmDeleteStagingAreaModal.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, beforeEach, vi, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../assets/localization' +import { ConfirmDeleteStagingAreaModal } from '..' +import type { ComponentProps } from 'react' + +const render = ( + props: ComponentProps +) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('ConfirmDeleteStagingAreaModal', () => { + let props: ComponentProps + + beforeEach(() => { + props = { + onClose: vi.fn(), + onConfirm: vi.fn(), + } + }) + it('renders the text and buttons work as expected', () => { + render(props) + screen.getByText('This staging area slot has labware') + screen.getByText( + 'The staging area slot that you are about to delete has labware placed on it. If you make these changes to your protocol starting deck, the labware will be deleted as well.' + ) + fireEvent.click(screen.getByText('Cancel')) + expect(props.onClose).toHaveBeenCalled() + fireEvent.click(screen.getByText('Continue')) + expect(props.onConfirm).toHaveBeenCalled() + }) +}) diff --git a/protocol-designer/src/organisms/ConfirmDeleteStagingAreaModal/index.tsx b/protocol-designer/src/organisms/ConfirmDeleteStagingAreaModal/index.tsx new file mode 100644 index 00000000000..c2d0c81f0ea --- /dev/null +++ b/protocol-designer/src/organisms/ConfirmDeleteStagingAreaModal/index.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from 'react-i18next' +import { createPortal } from 'react-dom' +import { + Flex, + JUSTIFY_END, + Modal, + PrimaryButton, + SecondaryButton, + SPACING, + StyledText, +} from '@opentrons/components' +import { getTopPortalEl } from '../../components/portals/TopPortal' +import { HandleEnter } from '../../atoms/HandleEnter' + +interface ConfirmDeleteStagingAreaModalProps { + onClose: () => void + onConfirm: () => void +} +export function ConfirmDeleteStagingAreaModal( + props: ConfirmDeleteStagingAreaModalProps +): JSX.Element { + const { onClose, onConfirm } = props + const { t, i18n } = useTranslation(['create_new_protocol', 'shared']) + + return createPortal( + + + { + onClose() + }} + > + {t('shared:cancel')} + + + {i18n.format(t('shared:continue'), 'capitalize')} + + + } + > + + {t('staging_area_will_delete_labware')} + + + , + getTopPortalEl() + ) +} diff --git a/protocol-designer/src/organisms/index.ts b/protocol-designer/src/organisms/index.ts index 3d6fee9b662..08e1745bccf 100644 --- a/protocol-designer/src/organisms/index.ts +++ b/protocol-designer/src/organisms/index.ts @@ -2,6 +2,7 @@ export * from './Alerts' export * from './AnnouncementModal' export * from './AssignLiquidsModal' export * from './BlockingHintModal' +export * from './ConfirmDeleteStagingAreaModal' export * from './DefineLiquidsModal' export * from './EditInstrumentsModal' export * from './EditNickNameModal' diff --git a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupContainer.tsx b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupContainer.tsx index ff914a0ff37..2e45768cf4d 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupContainer.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupContainer.tsx @@ -67,7 +67,7 @@ const OT2_STANDARD_DECK_VIEW_LAYER_BLOCK_LIST: string[] = [ 'fixedTrash', ] export const lightFill = COLORS.grey35 -const darkFill = COLORS.grey60 +export const darkFill = COLORS.grey60 export function DeckSetupContainer(props: DeckSetupTabType): JSX.Element { const { tab } = props diff --git a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupDetails.tsx b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupDetails.tsx index 0b23fa3315e..ed6150223c0 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupDetails.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupDetails.tsx @@ -1,5 +1,5 @@ -import * as React from 'react' import values from 'lodash/values' +import { Fragment, useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Module } from '@opentrons/components' @@ -28,7 +28,9 @@ import { DeckItemHover } from './DeckItemHover' import { SlotOverflowMenu } from './SlotOverflowMenu' import { HoveredItems } from './HoveredItems' import { SelectedHoveredItems } from './SelectedHoveredItems' +import { getAdjacentLabware } from './utils' +import type { ComponentProps, Dispatch, SetStateAction } from 'react' import type { ModuleTemporalProperties } from '@opentrons/step-generation' import type { AddressableArea, @@ -55,7 +57,7 @@ interface DeckSetupDetailsProps extends DeckSetupTabType { hoveredFixture: Fixture | null hoveredLabware: string | null hoveredModule: ModuleModel | null - setHover: React.Dispatch> + setHover: Dispatch> showGen1MultichannelCollisionWarnings: boolean stagingAreaCutoutIds: CutoutId[] selectedZoomInSlot?: DeckSlotId @@ -83,9 +85,7 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { ) const selectedSlotInfo = useSelector(selectors.getZoomedInSlotInfo) const { selectedSlot } = selectedSlotInfo - const [menuListId, setShowMenuListForId] = React.useState( - null - ) + const [menuListId, setShowMenuListForId] = useState(null) const dispatch = useDispatch() const { @@ -100,7 +100,7 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { deckDef, }) // initiate the slot's info - React.useEffect(() => { + useEffect(() => { dispatch( editSlotInfo({ createdNestedLabwareForSlot, @@ -132,6 +132,15 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { ? getSlotsWithCollisions(deckDef, allModules) : [] + const adjacentLabware = + preSelectedFixture != null && selectedSlot.cutout != null + ? getAdjacentLabware( + preSelectedFixture, + selectedSlot.cutout, + activeDeckSetup.labware + ) + : null + return ( <> {/* all modules */} @@ -146,7 +155,7 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { const moduleDef = getModuleDef2(moduleOnDeck.model) const getModuleInnerProps = ( moduleState: ModuleTemporalProperties['moduleState'] - ): React.ComponentProps['innerProps'] => { + ): ComponentProps['innerProps'] => { if (moduleState.type === THERMOCYCLER_MODULE_TYPE) { let lidMotorState = 'unknown' if (tab === 'startingDeck' || moduleState.lidOpen) { @@ -186,7 +195,7 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { zDimension: labwareLoadedOnModule?.def.dimensions.zDimension ?? 0, } return moduleOnDeck.slot !== selectedSlot.slot ? ( - + ) : null} - + ) : null })} @@ -276,7 +285,7 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { }) .map(addressableArea => { return ( - + - + ) })} {/* all labware on deck NOT those in modules */} @@ -299,10 +308,10 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { if ( labware.slot === 'offDeck' || allModules.some(m => m.id === labware.slot) || - allLabware.some(lab => lab.id === labware.slot) + allLabware.some(lab => lab.id === labware.slot) || + labware.id === adjacentLabware?.id ) return null - const slotPosition = getPositionFromSlotId(labware.slot, deckDef) const slotBoundingBox = getAddressableAreaFromSlotId( labware.slot, @@ -313,7 +322,7 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { return null } return labware.slot !== selectedSlot.slot ? ( - + - + ) : null })} @@ -376,7 +385,7 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { ? slotForOnTheDeck : allModules.find(module => module.id === slotForOnTheDeck)?.slot return ( - + - + ) })} diff --git a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx index 6c000ad0428..e73ab455dc7 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx @@ -20,6 +20,7 @@ import { MAGNETIC_MODULE_TYPE, MAGNETIC_MODULE_V1, MAGNETIC_MODULE_V2, + MODULE_MODELS, OT2_ROBOT_TYPE, } from '@opentrons/shared-data' @@ -46,6 +47,7 @@ import { selectors } from '../../../labware-ingred/selectors' import { useKitchen } from '../../../organisms/Kitchen/hooks' import { getDismissedHints } from '../../../tutorial/selectors' import { createContainerAboveModule } from '../../../step-forms/actions/thunks' +import { ConfirmDeleteStagingAreaModal } from '../../../organisms' import { FIXTURES, MOAM_MODELS } from './constants' import { getSlotInformation } from '../utils' import { getModuleModelsBySlot, getDeckErrors } from './utils' @@ -71,6 +73,9 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { const { makeSnackbar } = useKitchen() const selectedSlotInfo = useSelector(selectors.getZoomedInSlotInfo) const robotType = useSelector(getRobotType) + const [showDeleteLabwareModal, setShowDeleteLabwareModal] = useState< + ModuleModel | 'clear' | null + >(null) const isDismissedModuleHint = useSelector(getDismissedHints).includes( 'change_magnet_module_model' ) @@ -154,6 +159,7 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { createdModuleForSlot, createdLabwareForSlot, createFixtureForSlots, + matchingLabwareFor4thColumn, } = getSlotInformation({ deckSetup, slot }) let fixtures: Fixture[] = [] @@ -218,6 +224,10 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { if (createdNestedLabwareForSlot != null) { dispatch(deleteContainer({ labwareId: createdNestedLabwareForSlot.id })) } + // clear labware on staging area 4th column slot + if (matchingLabwareFor4thColumn != null) { + dispatch(deleteContainer({ labwareId: matchingLabwareFor4thColumn.id })) + } } handleResetToolbox() setSelectedHardware(null) @@ -278,6 +288,26 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { } return ( <> + {showDeleteLabwareModal != null ? ( + { + setShowDeleteLabwareModal(null) + }} + onConfirm={() => { + if (showDeleteLabwareModal === 'clear') { + handleClear() + handleResetToolbox() + } else if (MODULE_MODELS.includes(showDeleteLabwareModal)) { + setSelectedHardware(showDeleteLabwareModal) + dispatch(selectFixture({ fixture: null })) + dispatch(selectModule({ moduleModel: showDeleteLabwareModal })) + dispatch(selectLabware({ labwareDefUri: null })) + dispatch(selectNestedLabware({ nestedLabwareDefUri: null })) + } + setShowDeleteLabwareModal(null) + }} + /> + ) : null} {changeModuleWarning} } onCloseClick={() => { - handleClear() - handleResetToolbox() + if (matchingLabwareFor4thColumn != null) { + setShowDeleteLabwareModal('clear') + } else { + handleClear() + handleResetToolbox() + } }} onConfirmClick={() => { handleConfirm() @@ -407,6 +441,12 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { !isDismissedModuleHint ) { displayModuleWarning(true) + } else if ( + selectedFixture === 'stagingArea' || + (selectedFixture === 'wasteChuteAndStagingArea' && + matchingLabwareFor4thColumn != null) + ) { + setShowDeleteLabwareModal(model) } else { setSelectedHardware(model) dispatch(selectFixture({ fixture: null })) diff --git a/protocol-designer/src/pages/Designer/DeckSetup/FixtureRender.tsx b/protocol-designer/src/pages/Designer/DeckSetup/FixtureRender.tsx index 92803de701f..cf4d1129486 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/FixtureRender.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/FixtureRender.tsx @@ -1,4 +1,5 @@ import { Fragment } from 'react' +import { useSelector } from 'react-redux' import { COLORS, FlexTrash, @@ -7,7 +8,11 @@ import { WasteChuteFixture, WasteChuteStagingAreaFixture, } from '@opentrons/components' -import { lightFill } from './DeckSetupContainer' +import { getPositionFromSlotId } from '@opentrons/shared-data' +import { getInitialDeckSetup } from '../../../step-forms/selectors' +import { LabwareOnDeck as LabwareOnDeckComponent } from '../../../components/DeckSetup/LabwareOnDeck' +import { lightFill, darkFill } from './DeckSetupContainer' +import { getAdjacentLabware } from './utils' import type { TrashCutoutId, StagingAreaLocation } from '@opentrons/components' import type { CutoutId, @@ -25,16 +30,34 @@ interface FixtureRenderProps { } export const FixtureRender = (props: FixtureRenderProps): JSX.Element => { const { fixture, cutout, deckDef, robotType } = props + const deckSetup = useSelector(getInitialDeckSetup) + const { labware } = deckSetup + const adjacentLabware = getAdjacentLabware(fixture, cutout, labware) + + const renderLabwareOnDeck = (): JSX.Element | null => { + if (!adjacentLabware) return null + const slotPosition = getPositionFromSlotId(adjacentLabware.slot, deckDef) + return ( + + ) + } switch (fixture) { case 'stagingArea': { return ( - + + + {renderLabwareOnDeck()} + ) } case 'trashBin': { @@ -67,12 +90,14 @@ export const FixtureRender = (props: FixtureRenderProps): JSX.Element => { } case 'wasteChuteAndStagingArea': { return ( - + + + {renderLabwareOnDeck()} + ) } } diff --git a/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx b/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx index 258f5fe07d6..71f07b973e3 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/SelectedHoveredItems.tsx @@ -1,5 +1,4 @@ import { useSelector } from 'react-redux' -import { FixtureRender } from './FixtureRender' import { LabwareRender, Module } from '@opentrons/components' import { getModuleDef2, @@ -10,6 +9,7 @@ import { getOnlyLatestDefs } from '../../../labware-defs' import { getCustomLabwareDefsByURI } from '../../../labware-defs/selectors' import { ModuleLabel } from './ModuleLabel' import { LabwareLabel } from '../LabwareLabel' +import { FixtureRender } from './FixtureRender' import type { CoordinateTuple, DeckDefinition, diff --git a/protocol-designer/src/pages/Designer/DeckSetup/SlotOverflowMenu.tsx b/protocol-designer/src/pages/Designer/DeckSetup/SlotOverflowMenu.tsx index 974a6b96552..b29d3b90dfa 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/SlotOverflowMenu.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/SlotOverflowMenu.tsx @@ -1,5 +1,5 @@ -import * as React from 'react' import { useTranslation } from 'react-i18next' +import { useState } from 'react' import styled from 'styled-components' import { useDispatch, useSelector } from 'react-redux' import { useNavigate } from 'react-router-dom' @@ -20,15 +20,25 @@ import { import { getDeckSetupForActiveItem } from '../../../top-selectors/labware-locations' import { deleteModule } from '../../../step-forms/actions' -import { EditNickNameModal } from '../../../organisms' +import { + ConfirmDeleteStagingAreaModal, + EditNickNameModal, +} from '../../../organisms' import { deleteDeckFixture } from '../../../step-forms/actions/additionalItems' import { deleteContainer, duplicateLabware, openIngredientSelector, } from '../../../labware-ingred/actions' +import { getStagingAreaAddressableAreas } from '../../../utils' import { selectors as labwareIngredSelectors } from '../../../labware-ingred/selectors' -import type { CoordinateTuple, DeckSlotId } from '@opentrons/shared-data' +import type { MouseEvent, SetStateAction } from 'react' +import type { + CoordinateTuple, + CutoutId, + DeckSlotId, +} from '@opentrons/shared-data' +import type { LabwareOnDeck } from '../../../step-forms' import type { ThunkDispatch } from '../../../types' const ROBOT_BOTTOM_HALF_SLOTS = [ @@ -55,7 +65,7 @@ const TOP_SLOT_Y_POSITION_2_BUTTONS = 35 interface SlotOverflowMenuProps { // can be off-deck id or deck slot location: DeckSlotId | string - setShowMenuList: (value: React.SetStateAction) => void + setShowMenuList: (value: SetStateAction) => void addEquipment: (slotId: string) => void menuListSlotPosition?: CoordinateTuple } @@ -71,14 +81,14 @@ export function SlotOverflowMenu( const { t } = useTranslation('starting_deck_state') const navigate = useNavigate() const dispatch = useDispatch>() - const [showNickNameModal, setShowNickNameModal] = React.useState( + const [showDeleteLabwareModal, setShowDeleteLabwareModal] = useState( false ) + const [showNickNameModal, setShowNickNameModal] = useState(false) const overflowWrapperRef = useOnClickOutside({ onClickOutside: () => { - if (!showNickNameModal) { - setShowMenuList(false) - } + if (showNickNameModal || showDeleteLabwareModal) return + setShowMenuList(false) }, }) const deckSetup = useSelector(getDeckSetupForActiveItem) @@ -111,6 +121,20 @@ export function SlotOverflowMenu( const fixturesOnSlot = Object.values(additionalEquipmentOnDeck).filter( ae => ae.location?.split('cutout')[1] === location ) + const stagingAreaCutout = fixturesOnSlot.find( + fixture => fixture.name === 'stagingArea' + )?.location + + let matchingLabware: LabwareOnDeck | null = null + if (stagingAreaCutout != null) { + const stagingAreaAddressableAreaName = getStagingAreaAddressableAreas([ + stagingAreaCutout, + ] as CutoutId[]) + matchingLabware = + Object.values(deckSetupLabware).find( + lw => lw.slot === stagingAreaAddressableAreaName[0] + ) ?? null + } const hasNoItems = moduleOnSlot == null && labwareOnSlot == null && fixturesOnSlot.length === 0 @@ -132,7 +156,12 @@ export function SlotOverflowMenu( if (nestedLabwareOnSlot != null) { dispatch(deleteContainer({ labwareId: nestedLabwareOnSlot.id })) } + // clear labware on staging area 4th column slot + if (matchingLabware != null) { + dispatch(deleteContainer({ labwareId: matchingLabware.id })) + } } + const showDuplicateBtn = (labwareOnSlot != null && !isLabwareAnAdapter && @@ -179,6 +208,19 @@ export function SlotOverflowMenu( }} /> ) : null} + {showDeleteLabwareModal ? ( + { + setShowDeleteLabwareModal(false) + setShowMenuList(false) + }} + onConfirm={() => { + handleClear() + setShowDeleteLabwareModal(false) + setShowMenuList(false) + }} + /> + ) : null} { + onClick={(e: MouseEvent) => { e.preventDefault() e.stopPropagation() }} @@ -206,7 +248,7 @@ export function SlotOverflowMenu( {showEditAndLiquidsBtns ? ( <> { + onClick={(e: MouseEvent) => { setShowNickNameModal(true) e.preventDefault() e.stopPropagation() @@ -254,9 +296,15 @@ export function SlotOverflowMenu( ) : null} { - handleClear() - setShowMenuList(false) + onClick={(e: MouseEvent) => { + if (matchingLabware != null) { + setShowDeleteLabwareModal(true) + e.preventDefault() + e.stopPropagation() + } else { + handleClear() + setShowMenuList(false) + } }} > diff --git a/protocol-designer/src/pages/Designer/DeckSetup/utils.ts b/protocol-designer/src/pages/Designer/DeckSetup/utils.ts index 51a661eeed1..b231da91072 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/utils.ts +++ b/protocol-designer/src/pages/Designer/DeckSetup/utils.ts @@ -13,6 +13,7 @@ import { } from '@opentrons/shared-data' import { getOnlyLatestDefs } from '../../../labware-defs' +import { getStagingAreaAddressableAreas } from '../../../utils' import { FLEX_MODULE_MODELS, OT2_MODULE_MODELS, @@ -29,7 +30,12 @@ import type { ModuleModel, RobotType, } from '@opentrons/shared-data' -import type { InitialDeckSetup } from '../../../step-forms' +import type { + AllTemporalPropertiesForTimelineFrame, + InitialDeckSetup, + LabwareOnDeck, +} from '../../../step-forms' +import type { Fixture } from './constants' const OT2_TC_SLOTS = ['7', '8', '10', '11'] const FLEX_TC_SLOTS = ['A1', 'B1'] @@ -255,3 +261,22 @@ export function animateZoom(props: AnimateZoomProps): void { } requestAnimationFrame(animate) } + +export const getAdjacentLabware = ( + fixture: Fixture, + cutout: CutoutId, + labware: AllTemporalPropertiesForTimelineFrame['labware'] +): LabwareOnDeck | null => { + let adjacentLabware: LabwareOnDeck | null = null + if (fixture === 'stagingArea' || fixture === 'wasteChuteAndStagingArea') { + const stagingAreaAddressableAreaName = getStagingAreaAddressableAreas([ + cutout, + ]) + + adjacentLabware = + Object.values(labware).find( + lw => lw.slot === stagingAreaAddressableAreaName[0] + ) ?? null + } + return adjacentLabware +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/CommentTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/CommentTools/index.tsx index 72688f43146..a6956cd342d 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/CommentTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/CommentTools/index.tsx @@ -1,3 +1,50 @@ -export function CommentTools(): JSX.Element { - return
TODO: wire this up
+import { useTranslation } from 'react-i18next' +import styled from 'styled-components' +import { + BORDERS, + COLORS, + DIRECTION_COLUMN, + Flex, + SPACING, + StyledText, + TYPOGRAPHY, +} from '@opentrons/components' +import type { ChangeEvent } from 'react' +import type { StepFormProps } from '../../types' + +export function CommentTools(props: StepFormProps): JSX.Element { + const { t, i18n } = useTranslation('form') + const { propsForFields } = props + + return ( + + + {i18n.format(t('step_edit_form.field.comment.label'), 'capitalize')} + + ) => { + propsForFields.message.updateValue(e.currentTarget.value) + }} + /> + + ) } + +// TODO: use TextArea component when we make it +const StyledTextArea = styled.textarea` + width: 100%; + height: 7rem; + box-sizing: border-box; + border: 1px solid ${COLORS.grey50}; + border-radius: ${BORDERS.borderRadius4}; + padding: ${SPACING.spacing8}; + font-size: ${TYPOGRAPHY.fontSizeH4}; + line-height: ${TYPOGRAPHY.lineHeight16}; + font-weight: ${TYPOGRAPHY.fontWeightRegular}; + resize: none; +` diff --git a/protocol-designer/src/pages/Designer/__tests__/utils.test.ts b/protocol-designer/src/pages/Designer/__tests__/utils.test.ts index b1faa3f0d67..6be91d71b2a 100644 --- a/protocol-designer/src/pages/Designer/__tests__/utils.test.ts +++ b/protocol-designer/src/pages/Designer/__tests__/utils.test.ts @@ -117,6 +117,7 @@ describe('getSlotInformation', () => { expect( getSlotInformation({ deckSetup: mockOt2DeckSetup, slot: '1' }) ).toEqual({ + matchingLabwareFor4thColumn: null, createdModuleForSlot: mockHS, createdLabwareForSlot: mockLabOnDeck1, createdNestedLabwareForSlot: mockLabOnDeck2, @@ -128,6 +129,7 @@ describe('getSlotInformation', () => { expect( getSlotInformation({ deckSetup: mockOt2DeckSetup, slot: '2' }) ).toEqual({ + matchingLabwareFor4thColumn: null, createdLabwareForSlot: mockLabOnDeck3, createFixtureForSlots: [], slotPosition: null, @@ -142,12 +144,17 @@ describe('getSlotInformation', () => { } expect( getSlotInformation({ deckSetup: mockDeckSetup, slot: 'A1' }) - ).toEqual({ slotPosition: null, createFixtureForSlots: [] }) + ).toEqual({ + matchingLabwareFor4thColumn: null, + slotPosition: null, + createFixtureForSlots: [], + }) }) it('renders a trashbin for a Flex on slot A3', () => { expect( getSlotInformation({ deckSetup: mockFlex2DeckSetup, slot: 'A3' }) ).toEqual({ + matchingLabwareFor4thColumn: null, slotPosition: null, createFixtureForSlots: [mockTrash], preSelectedFixture: 'trashBin', @@ -157,6 +164,7 @@ describe('getSlotInformation', () => { expect( getSlotInformation({ deckSetup: mockFlex2DeckSetup, slot: 'D1' }) ).toEqual({ + matchingLabwareFor4thColumn: null, slotPosition: null, createdModuleForSlot: mockHSFlex, createdLabwareForSlot: mockLabOnDeck1, @@ -168,6 +176,7 @@ describe('getSlotInformation', () => { expect( getSlotInformation({ deckSetup: mockFlex2DeckSetup, slot: 'D3' }) ).toEqual({ + matchingLabwareFor4thColumn: mockLabOnStagingArea, slotPosition: null, createFixtureForSlots: [mockWasteChute, mockStagingArea], preSelectedFixture: 'wasteChuteAndStagingArea', @@ -177,6 +186,7 @@ describe('getSlotInformation', () => { expect( getSlotInformation({ deckSetup: mockFlex2DeckSetup, slot: 'D4' }) ).toEqual({ + matchingLabwareFor4thColumn: null, slotPosition: null, createdLabwareForSlot: mockLabOnStagingArea, createFixtureForSlots: [mockWasteChute, mockStagingArea], diff --git a/protocol-designer/src/pages/Designer/utils.ts b/protocol-designer/src/pages/Designer/utils.ts index c940e12c8d5..3110f9d519d 100644 --- a/protocol-designer/src/pages/Designer/utils.ts +++ b/protocol-designer/src/pages/Designer/utils.ts @@ -1,9 +1,14 @@ import { getPositionFromSlotId } from '@opentrons/shared-data' +import { getStagingAreaAddressableAreas } from '../../utils' import type { AdditionalEquipmentName, DeckSlot, } from '@opentrons/step-generation' -import type { CoordinateTuple, DeckDefinition } from '@opentrons/shared-data' +import type { + CoordinateTuple, + CutoutId, + DeckDefinition, +} from '@opentrons/shared-data' import type { AllTemporalPropertiesForTimelineFrame, LabwareOnDeck, @@ -18,6 +23,7 @@ interface AdditionalEquipment { } interface SlotInformation { + matchingLabwareFor4thColumn: LabwareOnDeck | null slotPosition: CoordinateTuple | null createdModuleForSlot?: ModuleOnDeck createdLabwareForSlot?: LabwareOnDeck @@ -66,6 +72,24 @@ export const getSlotInformation = ( } ) + const fixturesOnSlot = Object.values(additionalEquipmentOnDeck).filter( + ae => ae.location?.split('cutout')[1] === slot + ) + const stagingAreaCutout = fixturesOnSlot.find( + fixture => fixture.name === 'stagingArea' + )?.location + + let matchingLabware: LabwareOnDeck | null = null + if (stagingAreaCutout != null) { + const stagingAreaAddressableAreaName = getStagingAreaAddressableAreas([ + stagingAreaCutout, + ] as CutoutId[]) + matchingLabware = + Object.values(deckSetupLabware).find( + lw => lw.slot === stagingAreaAddressableAreaName[0] + ) ?? null + } + const preSelectedFixture = createFixtureForSlots != null && createFixtureForSlots.length === 2 ? ('wasteChuteAndStagingArea' as Fixture) @@ -78,5 +102,6 @@ export const getSlotInformation = ( createFixtureForSlots, preSelectedFixture, slotPosition: slotPosition, + matchingLabwareFor4thColumn: matchingLabware, } }