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() !== '' && (
+
+
+
+ )
+ )}
+
+
+ )
+}