From 908ad7ca397e2949de0628d4d81bcae30a1dc496 Mon Sep 17 00:00:00 2001 From: fbelginetw <167361860+fbelginetw@users.noreply.github.com> Date: Mon, 21 Oct 2024 15:59:54 -0400 Subject: [PATCH] feat(opentrons-ai-client): Prompt Preview (#16508) # Overview This PR adds the Prompt Review component that will be consumed by the Create Protocol flow in the new AI Client. ![image](https://github.com/user-attachments/assets/afdf6be4-b675-495c-9f4f-2fdc79a23eaf) ## Test Plan and Hands on Testing - Basic scenarios manually tested - Unit tests ## Changelog - Add Opentrons AI Client Prompt Review component ## Review requests - Validate if this approach for creating components fits the project structure and best practices ## Risk assessment - No risk --- .../localization/en/protocol_generator.json | 4 +- .../PromptPreview/PromptPreview.stories.tsx | 84 ++++++++++++++ .../__tests__/PromptPreview.test.tsx | 109 ++++++++++++++++++ .../src/molecules/PromptPreview/index.tsx | 86 ++++++++++++++ .../__tests__/PromptPreviewSection.test.tsx | 60 ++++++++++ .../molecules/PromptPreviewSection/index.tsx | 74 ++++++++++++ 6 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 opentrons-ai-client/src/molecules/PromptPreview/PromptPreview.stories.tsx create mode 100644 opentrons-ai-client/src/molecules/PromptPreview/__tests__/PromptPreview.test.tsx create mode 100644 opentrons-ai-client/src/molecules/PromptPreview/index.tsx create mode 100644 opentrons-ai-client/src/molecules/PromptPreviewSection/__tests__/PromptPreviewSection.test.tsx create mode 100644 opentrons-ai-client/src/molecules/PromptPreviewSection/index.tsx 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 f44eff34e73..cc5c727c1e8 100644 --- a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json +++ b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json @@ -34,5 +34,7 @@ "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?", - "you": "You" + "you": "You", + "prompt_preview_submit_button": "Submit prompt", + "prompt_preview_placeholder_message": "As you complete the sections on the left, your prompt will be built here. When all requirements are met you will be able to generate the protocol." } diff --git a/opentrons-ai-client/src/molecules/PromptPreview/PromptPreview.stories.tsx b/opentrons-ai-client/src/molecules/PromptPreview/PromptPreview.stories.tsx new file mode 100644 index 00000000000..79e7b822dcc --- /dev/null +++ b/opentrons-ai-client/src/molecules/PromptPreview/PromptPreview.stories.tsx @@ -0,0 +1,84 @@ +import { I18nextProvider } from 'react-i18next' +import { COLORS, Flex, SPACING } from '@opentrons/components' +import { i18n } from '../../i18n' +import type { Meta, StoryObj } from '@storybook/react' +import { PromptPreview } from '.' + +const meta: Meta = { + title: 'AI/molecules/PromptPreview', + component: PromptPreview, + decorators: [ + Story => ( + + + + + + ), + ], +} +export default meta +type Story = StoryObj + +export const PromptPreviewExample: Story = { + args: { + isSubmitButtonEnabled: false, + handleSubmit: () => { + alert('Submit button clicked') + }, + promptPreviewData: [ + { + title: 'Application', + items: [ + 'Cherrypicking', + 'I have a Chlorine Reagent Set (Total), Ultra Low Range', + ], + }, + { + title: 'Instruments', + items: [ + 'Opentrons Flex', + 'Flex 1-Channel 50 uL', + 'Flex 8-Channel 1000 uL', + ], + }, + { + title: 'Modules', + items: [ + 'Thermocycler GEN2', + 'Heater-Shaker with Universal Flat Adaptor', + ], + }, + { + title: 'Labware and Liquids', + items: [ + 'Opentrons 96 Well Plate', + 'Thermocycler GEN2', + 'Opentrons 96 Deep Well Plate', + 'Liquid 1: In commodo lectus nec erat commodo blandit. Etiam leo dui, porttitor vel imperdiet sed, tristique nec nisl. Maecenas pulvinar sapien quis sodales imperdiet.', + 'Liquid 2: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + ], + }, + { + title: 'Steps', + items: [ + 'Fill the first column of a Elisa plate with 100 uL of Liquid 1', + 'Fill the second column of a Elisa plate with 100 uL of Liquid 2', + ], + }, + ], + }, +} + +export const PromptPreviewPlaceholderMessage: Story = { + args: { + isSubmitButtonEnabled: false, + handleSubmit: () => { + alert('Submit button clicked') + }, + }, +} diff --git a/opentrons-ai-client/src/molecules/PromptPreview/__tests__/PromptPreview.test.tsx b/opentrons-ai-client/src/molecules/PromptPreview/__tests__/PromptPreview.test.tsx new file mode 100644 index 00000000000..ab7d69543ba --- /dev/null +++ b/opentrons-ai-client/src/molecules/PromptPreview/__tests__/PromptPreview.test.tsx @@ -0,0 +1,109 @@ +import { screen } from '@testing-library/react' +import { describe, it, vi, beforeEach, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { PromptPreview } from '..' + +const PROMPT_PREVIEW_PLACEHOLDER_MESSAGE = + 'As you complete the sections on the left, your prompt will be built here. When all requirements are met you will be able to generate the protocol.' + +const mockHandleClick = vi.fn() + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('PromptPreview', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + isSubmitButtonEnabled: false, + handleSubmit: () => { + mockHandleClick() + }, + promptPreviewData: [ + { + title: 'Test Section 1', + items: ['item1', 'item2'], + }, + { + title: 'Test Section 2', + items: ['item3', 'item4'], + }, + ], + } + }) + + it('should render the PromptPreview component', () => { + render(props) + + expect(screen.getByText('Prompt')).toBeInTheDocument() + }) + + it('should render the submit button', () => { + render(props) + + expect(screen.getByText('Submit prompt')).toBeInTheDocument() + }) + + it('should render the placeholder message when all sections are empty', () => { + props.promptPreviewData = [ + { + title: 'Test Section 1', + items: [], + }, + { + title: 'Test Section 2', + items: [], + }, + ] + render(props) + + expect( + screen.getByText(PROMPT_PREVIEW_PLACEHOLDER_MESSAGE) + ).toBeInTheDocument() + }) + + it('should not render the placeholder message when at least one section has items', () => { + render(props) + + expect( + screen.queryByText(PROMPT_PREVIEW_PLACEHOLDER_MESSAGE) + ).not.toBeInTheDocument() + }) + + it('should render the sections with items', () => { + render(props) + + expect(screen.getByText('Test Section 1')).toBeInTheDocument() + expect(screen.getByText('Test Section 2')).toBeInTheDocument() + }) + + it('should display submit button disabled when isSubmitButtonEnabled is false', () => { + render(props) + + expect(screen.getByRole('button', { name: 'Submit prompt' })).toBeDisabled() + }) + + it('should display submit button enabled when isSubmitButtonEnabled is true', () => { + props.isSubmitButtonEnabled = true + render(props) + + expect( + screen.getByRole('button', { name: 'Submit prompt' }) + ).not.toBeDisabled() + }) + + it('should call handleSubmit when the submit button is clicked', () => { + props.isSubmitButtonEnabled = true + render(props) + + const submitButton = screen.getByRole('button', { name: 'Submit prompt' }) + submitButton.click() + + expect(mockHandleClick).toHaveBeenCalled() + }) +}) diff --git a/opentrons-ai-client/src/molecules/PromptPreview/index.tsx b/opentrons-ai-client/src/molecules/PromptPreview/index.tsx new file mode 100644 index 00000000000..b789cfbb4c7 --- /dev/null +++ b/opentrons-ai-client/src/molecules/PromptPreview/index.tsx @@ -0,0 +1,86 @@ +import styled from 'styled-components' +import { + Flex, + StyledText, + LargeButton, + COLORS, + JUSTIFY_SPACE_BETWEEN, + DIRECTION_COLUMN, + SIZE_AUTO, + DIRECTION_ROW, + ALIGN_CENTER, + SPACING, +} from '@opentrons/components' +import { PromptPreviewSection } from '../PromptPreviewSection' +import type { PromptPreviewSectionProps } from '../PromptPreviewSection' +import { useTranslation } from 'react-i18next' + +interface PromptPreviewProps { + isSubmitButtonEnabled?: boolean + handleSubmit: () => void + promptPreviewData: PromptPreviewSectionProps[] +} + +const PromptPreviewContainer = styled(Flex)` + flex-direction: ${DIRECTION_COLUMN}; + width: 100%; + height: ${SIZE_AUTO}; + padding-top: ${SPACING.spacing8}; + background-color: ${COLORS.transparent}; +` + +const PromptPreviewHeading = styled(Flex)` + flex-direction: ${DIRECTION_ROW}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + align-items: ${ALIGN_CENTER}; + margin-bottom: ${SPACING.spacing16}; +` + +const PromptPreviewPlaceholderMessage = styled(StyledText)` + padding: 82px 73px; + color: ${COLORS.grey60}; + text-align: ${ALIGN_CENTER}; +` + +export function PromptPreview({ + isSubmitButtonEnabled = false, + handleSubmit, + promptPreviewData = [], +}: PromptPreviewProps): JSX.Element { + const { t } = useTranslation('protocol_generator') + + const areAllSectionsEmpty = (): boolean => { + return promptPreviewData.every(section => section.items.length === 0) + } + + return ( + + + Prompt + + + + {areAllSectionsEmpty() && ( + + {t('prompt_preview_placeholder_message')} + + )} + + {Object.values(promptPreviewData).map( + (section, index) => + section.items.length > 0 && ( + + ) + )} + + ) +} diff --git a/opentrons-ai-client/src/molecules/PromptPreviewSection/__tests__/PromptPreviewSection.test.tsx b/opentrons-ai-client/src/molecules/PromptPreviewSection/__tests__/PromptPreviewSection.test.tsx new file mode 100644 index 00000000000..e194bae5a8e --- /dev/null +++ b/opentrons-ai-client/src/molecules/PromptPreviewSection/__tests__/PromptPreviewSection.test.tsx @@ -0,0 +1,60 @@ +import type * as React from 'react' +import { screen } from '@testing-library/react' +import { describe, it, beforeEach, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' + +import { PromptPreviewSection } from '../index' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('PromptPreviewSection', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + title: 'Test Section', + items: ['test item 1', 'test item 2'], + } + }) + + it('should render the PromptPreviewSection component', () => { + render(props) + + expect(screen.getByText('Test Section')).toBeInTheDocument() + }) + + it('should render the section title', () => { + render(props) + + expect(screen.getByText('Test Section')).toBeInTheDocument() + }) + + it('should render the items', () => { + render(props) + + expect(screen.getByText('test item 1')).toBeInTheDocument() + expect(screen.getByText('test item 2')).toBeInTheDocument() + }) + + it("should not render the item tag if it's an empty string", () => { + props.items = ['test item 1', ''] + render(props) + + const items = screen.getAllByTestId('Tag_default') + expect(items).toHaveLength(1) + }) + + it('should render the item with the correct max item width', () => { + props.items = ['test item 1 long text long text long text long text'] + props.itemMaxWidth = '23%' + render(props) + + const item = screen.getByTestId('item-tag-wrapper-0') + expect(item).toHaveStyle({ maxWidth: '23%' }) + }) +}) diff --git a/opentrons-ai-client/src/molecules/PromptPreviewSection/index.tsx b/opentrons-ai-client/src/molecules/PromptPreviewSection/index.tsx new file mode 100644 index 00000000000..c781e0308d7 --- /dev/null +++ b/opentrons-ai-client/src/molecules/PromptPreviewSection/index.tsx @@ -0,0 +1,74 @@ +import styled from 'styled-components' +import { + Flex, + StyledText, + Tag, + DIRECTION_COLUMN, + WRAP, + SPACING, +} from '@opentrons/components' + +export interface PromptPreviewSectionProps { + title: string + items: string[] + itemMaxWidth?: string +} + +const PromptPreviewSectionContainer = styled(Flex)` + flex-direction: ${DIRECTION_COLUMN}; + margin-top: ${SPACING.spacing32}; +` + +const SectionHeading = styled(StyledText)` + margin-bottom: ${SPACING.spacing8}; +` + +const TagsContainer = styled(Flex)` + grid-gap: ${SPACING.spacing4}; + flex-wrap: ${WRAP}; + justify-content: flex-start; + width: 100%; +` + +const TagItemWrapper = styled.div<{ itemMaxWidth: string }>` + display: flex; + width: auto; + white-space: nowrap; + overflow: hidden; + max-width: ${props => props.itemMaxWidth}; + + & > div { + overflow: hidden; + + > p { + overflow: hidden; + text-overflow: ellipsis; + } + } +` + +export function PromptPreviewSection({ + title, + items, + itemMaxWidth = '35%', +}: PromptPreviewSectionProps): JSX.Element { + return ( + + {title} + + {items.map( + (item: string, index: number) => + item.trim() !== '' && ( + + + + ) + )} + + + ) +}