From cebe51a706d852b98a5f6a8a6c10910e91d7902b Mon Sep 17 00:00:00 2001 From: fbelginetw <167361860+fbelginetw@users.noreply.github.com> Date: Mon, 28 Oct 2024 09:50:13 -0400 Subject: [PATCH] feat: opentrons ai client Create New Protocol - Application Section (#16578) # Overview This PR adds the first part of the Create New Protocol flow for the Opentrons AI Client It also refactors the renderWithProviders test util to use Jotai provider ![image](https://github.com/user-attachments/assets/6b478303-be53-4790-9d21-90952024096d) ## Test Plan and Hands on Testing - On the landing page click Create a new protocol button, you will be redirected to the new page - You can select a Scientific application and describe it. You can also select other and a new input will be displayed. - The Prompt Preview component is updated with the data entered. - ## Changelog - Add Create New Protocol page - Add first section Application functionality - Header is now sticky - Updated renderWithProviders to use Jotai provider ## Review requests - Verify new page. ## Risk assessment - low --- opentrons-ai-client/src/OpentronsAI.test.tsx | 40 ++++--- opentrons-ai-client/src/OpentronsAI.tsx | 44 +++++-- opentrons-ai-client/src/OpentronsAIRoutes.tsx | 4 +- .../__testing-utils__/renderWithProviders.tsx | 56 ++++++--- opentrons-ai-client/src/analytics/mixpanel.ts | 7 +- .../src/analytics/selectors.ts | 4 +- .../localization/en/create_protocol.json | 17 +++ .../src/assets/localization/en/index.ts | 2 + .../atoms/ControlledDropdownMenu/index.tsx | 55 +++++++++ .../src/atoms/ControlledInputField/index.tsx | 37 ++++++ .../molecules/Accordion/Accordion.stories.tsx | 1 + .../src/molecules/Accordion/index.tsx | 110 ++++++++---------- .../src/molecules/PromptPreview/index.tsx | 1 + .../__tests__/ApplicationSection.test.tsx | 81 +++++++++++++ .../organisms/ApplicationSection/index.tsx | 98 ++++++++++++++++ .../ProtocolSectionsContainer.test.tsx | 101 ++++++++++++++++ .../ProtocolSectionsContainer/index.tsx | 87 ++++++++++++++ .../__tests__/CreateProtocol.test.tsx | 84 +++++++++++++ .../src/pages/CreateProtocol/index.tsx | 101 ++++++++++++++++ .../src/pages/Landing/index.tsx | 4 - opentrons-ai-client/src/resources/atoms.ts | 21 +++- .../hooks/__tests__/useTrackEvent.test.tsx | 35 +++--- opentrons-ai-client/src/resources/types.ts | 27 +++++ .../utils/createProtocolTestUtils.tsx | 19 +++ .../src/resources/utils/testUtils.tsx | 29 ----- 25 files changed, 896 insertions(+), 169 deletions(-) create mode 100644 opentrons-ai-client/src/assets/localization/en/create_protocol.json create mode 100644 opentrons-ai-client/src/atoms/ControlledDropdownMenu/index.tsx create mode 100644 opentrons-ai-client/src/atoms/ControlledInputField/index.tsx create mode 100644 opentrons-ai-client/src/organisms/ApplicationSection/__tests__/ApplicationSection.test.tsx create mode 100644 opentrons-ai-client/src/organisms/ApplicationSection/index.tsx create mode 100644 opentrons-ai-client/src/organisms/ProtocolSectionsContainer/__tests__/ProtocolSectionsContainer.test.tsx create mode 100644 opentrons-ai-client/src/organisms/ProtocolSectionsContainer/index.tsx create mode 100644 opentrons-ai-client/src/pages/CreateProtocol/__tests__/CreateProtocol.test.tsx create mode 100644 opentrons-ai-client/src/pages/CreateProtocol/index.tsx create mode 100644 opentrons-ai-client/src/resources/utils/createProtocolTestUtils.tsx delete mode 100644 opentrons-ai-client/src/resources/utils/testUtils.tsx diff --git a/opentrons-ai-client/src/OpentronsAI.test.tsx b/opentrons-ai-client/src/OpentronsAI.test.tsx index 68d604edf07..ba069ca3081 100644 --- a/opentrons-ai-client/src/OpentronsAI.test.tsx +++ b/opentrons-ai-client/src/OpentronsAI.test.tsx @@ -1,21 +1,22 @@ import { screen } from '@testing-library/react' import { describe, it, vi, beforeEach } from 'vitest' import * as auth0 from '@auth0/auth0-react' - import { renderWithProviders } from './__testing-utils__' import { i18n } from './i18n' import { Loading } from './molecules/Loading' - import { OpentronsAI } from './OpentronsAI' import { Landing } from './pages/Landing' import { useGetAccessToken } from './resources/hooks' import { Header } from './molecules/Header' import { Footer } from './molecules/Footer' +import { HeaderWithMeter } from './molecules/HeaderWithMeter' +import { headerWithMeterAtom } from './resources/atoms' vi.mock('@auth0/auth0-react') vi.mock('./pages/Landing') vi.mock('./molecules/Header') +vi.mock('./molecules/HeaderWithMeter') vi.mock('./molecules/Footer') vi.mock('./molecules/Loading') vi.mock('./resources/hooks/useGetAccessToken') @@ -27,9 +28,14 @@ vi.mock('./resources/hooks/useTrackEvent', () => ({ useTrackEvent: () => mockUseTrackEvent, })) +const initialValues: Array<[any, any]> = [ + [headerWithMeterAtom, { displayHeaderWithMeter: false, progress: 0 }], +] + const render = (): ReturnType => { return renderWithProviders(, { i18nInstance: i18n, + initialValues, }) } @@ -41,7 +47,14 @@ describe('OpentronsAI', () => { vi.mocked(Landing).mockReturnValue(
mock Landing page
) vi.mocked(Loading).mockReturnValue(
mock Loading
) vi.mocked(Header).mockReturnValue(
mock Header component
) + vi.mocked(HeaderWithMeter).mockReturnValue( +
mock Header With Meter component
+ ) vi.mocked(Footer).mockReturnValue(
mock Footer component
) + ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ + isAuthenticated: true, + isLoading: false, + }) }) it('should render loading screen when isLoading is true', () => { @@ -54,28 +67,25 @@ describe('OpentronsAI', () => { }) it('should render text', () => { - ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ - isAuthenticated: true, - isLoading: false, - }) render() screen.getByText('mock Landing page') }) - it('should render Header component', () => { - ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ - isAuthenticated: true, - isLoading: false, - }) + it('should render the default Header component if displayHeaderWithMeter is false', () => { render() + screen.getByText('mock Header component') }) + it('should render Header with meter component if displayHeaderWithMeter is true', () => { + initialValues[0][1].displayHeaderWithMeter = true + + render() + + screen.getByText('mock Header With Meter component') + }) + it('should render Footer component', () => { - ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ - isAuthenticated: true, - isLoading: false, - }) render() screen.getByText('mock Footer component') }) diff --git a/opentrons-ai-client/src/OpentronsAI.tsx b/opentrons-ai-client/src/OpentronsAI.tsx index 621c2453e50..1f91faf0eed 100644 --- a/opentrons-ai-client/src/OpentronsAI.tsx +++ b/opentrons-ai-client/src/OpentronsAI.tsx @@ -11,23 +11,24 @@ import { useAuth0 } from '@auth0/auth0-react' import { useAtom } from 'jotai' import { useEffect } from 'react' import { Loading } from './molecules/Loading' -import { mixpanelAtom, tokenAtom } from './resources/atoms' +import { headerWithMeterAtom, mixpanelAtom, tokenAtom } from './resources/atoms' import { useGetAccessToken } from './resources/hooks' import { initializeMixpanel } from './analytics/mixpanel' import { useTrackEvent } from './resources/hooks/useTrackEvent' import { Header } from './molecules/Header' import { CLIENT_MAX_WIDTH } from './resources/constants' import { Footer } from './molecules/Footer' +import { HeaderWithMeter } from './molecules/HeaderWithMeter' +import styled from 'styled-components' export function OpentronsAI(): JSX.Element | null { const { isAuthenticated, isLoading, loginWithRedirect } = useAuth0() const [, setToken] = useAtom(tokenAtom) - const [mixpanel] = useAtom(mixpanelAtom) + const [{ displayHeaderWithMeter, progress }] = useAtom(headerWithMeterAtom) + const [mixpanelState, setMixpanelState] = useAtom(mixpanelAtom) const { getAccessToken } = useGetAccessToken() const trackEvent = useTrackEvent() - initializeMixpanel(mixpanel) - const fetchAccessToken = async (): Promise => { try { const accessToken = await getAccessToken() @@ -37,6 +38,11 @@ export function OpentronsAI(): JSX.Element | null { } } + if (mixpanelState?.isInitialized === false) { + setMixpanelState({ ...mixpanelState, isInitialized: true }) + initializeMixpanel(mixpanelState) + } + useEffect(() => { if (!isAuthenticated && !isLoading) { void loginWithRedirect() @@ -61,30 +67,44 @@ export function OpentronsAI(): JSX.Element | null { } return ( -
+ + {displayHeaderWithMeter ? ( + + ) : ( +
+ )} + + -
- -
-
+ ) } + +const StickyHeader = styled.div` + position: sticky; + top: 0; + z-index: 100; +` diff --git a/opentrons-ai-client/src/OpentronsAIRoutes.tsx b/opentrons-ai-client/src/OpentronsAIRoutes.tsx index 630429c2aa1..b790b262ebe 100644 --- a/opentrons-ai-client/src/OpentronsAIRoutes.tsx +++ b/opentrons-ai-client/src/OpentronsAIRoutes.tsx @@ -2,11 +2,11 @@ import { Route, Navigate, Routes } from 'react-router-dom' import { Landing } from './pages/Landing' import type { RouteProps } from './resources/types' +import { CreateProtocol } from './pages/CreateProtocol' const opentronsAIRoutes: RouteProps[] = [ - // replace Landing with the correct component { - Component: Landing, + Component: CreateProtocol, name: 'Create A New Protocol', navLinkTo: '/new-protocol', path: '/new-protocol', diff --git a/opentrons-ai-client/src/__testing-utils__/renderWithProviders.tsx b/opentrons-ai-client/src/__testing-utils__/renderWithProviders.tsx index f551c875d9c..4f442fa42b9 100644 --- a/opentrons-ai-client/src/__testing-utils__/renderWithProviders.tsx +++ b/opentrons-ai-client/src/__testing-utils__/renderWithProviders.tsx @@ -3,31 +3,49 @@ import type * as React from 'react' import { QueryClient, QueryClientProvider } from 'react-query' import { I18nextProvider } from 'react-i18next' -import { Provider } from 'react-redux' -import { vi } from 'vitest' import { render } from '@testing-library/react' -import { createStore } from 'redux' -import type { PreloadedState, Store } from 'redux' import type { RenderOptions, RenderResult } from '@testing-library/react' +import { useHydrateAtoms } from 'jotai/utils' +import { Provider } from 'jotai' -export interface RenderWithProvidersOptions extends RenderOptions { - initialState?: State +interface HydrateAtomsProps { + initialValues: Array<[any, any]> + children: React.ReactNode +} + +interface TestProviderProps { + initialValues: Array<[any, any]> + children: React.ReactNode +} + +const HydrateAtoms = ({ + initialValues, + children, +}: HydrateAtomsProps): React.ReactNode => { + useHydrateAtoms(initialValues) + return children +} + +export const TestProvider = ({ + initialValues, + children, +}: TestProviderProps): React.ReactNode => ( + + {children} + +) + +export interface RenderWithProvidersOptions extends RenderOptions { + initialValues?: Array<[any, any]> i18nInstance: React.ComponentProps['i18n'] } -export function renderWithProviders( +export function renderWithProviders( Component: React.ReactElement, - options?: RenderWithProvidersOptions -): [RenderResult, Store] { - const { initialState = {}, i18nInstance = null } = options ?? {} - - const store: Store = createStore( - vi.fn(), - initialState as PreloadedState - ) - store.dispatch = vi.fn() - store.getState = vi.fn(() => initialState) as () => State + options?: RenderWithProvidersOptions +): RenderResult { + const { i18nInstance = null, initialValues = [] } = options ?? {} const queryClient = new QueryClient() @@ -36,7 +54,7 @@ export function renderWithProviders( > = ({ children }) => { const BaseWrapper = ( - {children} + {children} ) if (i18nInstance != null) { @@ -48,5 +66,5 @@ export function renderWithProviders( } } - return [render(Component, { wrapper: ProviderWrapper }), store] + return render(Component, { wrapper: ProviderWrapper, ...options }) } diff --git a/opentrons-ai-client/src/analytics/mixpanel.ts b/opentrons-ai-client/src/analytics/mixpanel.ts index eb81b72e6e3..6d617dae876 100644 --- a/opentrons-ai-client/src/analytics/mixpanel.ts +++ b/opentrons-ai-client/src/analytics/mixpanel.ts @@ -1,8 +1,6 @@ import mixpanel from 'mixpanel-browser' import { getHasOptedIn } from './selectors' - -export const getIsProduction = (): boolean => - global.location.host === 'designer.opentrons.com' // UPDATE THIS TO CORRECT URL +import type { Mixpanel } from '../resources/types' export type AnalyticsEvent = | { @@ -20,7 +18,7 @@ const MIXPANEL_OPTS = { opt_out_tracking_by_default: true, } -export function initializeMixpanel(state: any): void { +export function initializeMixpanel(state: Mixpanel): void { const optedIn = getHasOptedIn(state) ?? false if (MIXPANEL_ID != null) { console.debug('Initializing Mixpanel', { optedIn }) @@ -53,7 +51,6 @@ export function setMixpanelTracking(optedIn: boolean): void { // Register "super properties" which are included with all events mixpanel.register({ appVersion: 'test', // TODO update this? - // NOTE(IL, 2020): Since PD may be in the same Mixpanel project as other OT web apps, this 'appName' property is intended to distinguish it appName: 'opentronsAIClient', }) } else { diff --git a/opentrons-ai-client/src/analytics/selectors.ts b/opentrons-ai-client/src/analytics/selectors.ts index b55165f3049..19baf7c8ec2 100644 --- a/opentrons-ai-client/src/analytics/selectors.ts +++ b/opentrons-ai-client/src/analytics/selectors.ts @@ -1,2 +1,4 @@ -export const getHasOptedIn = (state: any): boolean | null => +import type { Mixpanel } from '../resources/types' + +export const getHasOptedIn = (state: Mixpanel): boolean | null => state.analytics.hasOptedIn diff --git a/opentrons-ai-client/src/assets/localization/en/create_protocol.json b/opentrons-ai-client/src/assets/localization/en/create_protocol.json new file mode 100644 index 00000000000..5bf2d5d6e23 --- /dev/null +++ b/opentrons-ai-client/src/assets/localization/en/create_protocol.json @@ -0,0 +1,17 @@ +{ + "application_title": "Application", + "application_scientific_dropdown_title": "What's your scientific application?", + "application_scientific_dropdown_placeholder": "Select an option", + "basic_aliquoting": "Basic aliquoting", + "pcr": "PCR", + "other": "Other", + "application_other_title": "Other application", + "application_other_caption": "Example: “cherrypicking” or “serial dilution”", + "application_describe_title": "Describe what you are trying to do", + "application_describe_caption": "Example: “The protocol performs automated liquid handling for Pierce BCA Protein Assay Kit to determine protein concentrations in various sample types, such as cell lysates and eluates of purification process.", + "section_confirm_button": "Confirm", + "instruments_title": "Instruments", + "modules_title": "Modules", + "labware_liquids_title": "Labware & Liquids", + "steps_title": "Steps" +} diff --git a/opentrons-ai-client/src/assets/localization/en/index.ts b/opentrons-ai-client/src/assets/localization/en/index.ts index b5aa26621dd..078684c8548 100644 --- a/opentrons-ai-client/src/assets/localization/en/index.ts +++ b/opentrons-ai-client/src/assets/localization/en/index.ts @@ -1,7 +1,9 @@ import shared from './shared.json' import protocol_generator from './protocol_generator.json' +import create_protocol from './create_protocol.json' export const en = { shared, protocol_generator, + create_protocol, } diff --git a/opentrons-ai-client/src/atoms/ControlledDropdownMenu/index.tsx b/opentrons-ai-client/src/atoms/ControlledDropdownMenu/index.tsx new file mode 100644 index 00000000000..5f2463081a6 --- /dev/null +++ b/opentrons-ai-client/src/atoms/ControlledDropdownMenu/index.tsx @@ -0,0 +1,55 @@ +import { DropdownMenu } from '@opentrons/components' +import type { DropdownBorder, DropdownOption } from '@opentrons/components' +import { Controller } from 'react-hook-form' + +interface ControlledDropdownMenuProps { + id?: string + name: string + rules?: any + options: DropdownOption[] + width?: string + dropdownType?: DropdownBorder | undefined + title?: string + defaultOption?: string + placeholder?: string +} + +export function ControlledDropdownMenu({ + name, + rules, + options, + width, + dropdownType, + title, + defaultOption, + placeholder = '', +}: ControlledDropdownMenuProps): JSX.Element { + return ( + { + const fieldValueName = options.find( + option => option.value === field.value + )?.name + + return ( + { + field.onChange(e) + }} + /> + ) + }} + /> + ) +} diff --git a/opentrons-ai-client/src/atoms/ControlledInputField/index.tsx b/opentrons-ai-client/src/atoms/ControlledInputField/index.tsx new file mode 100644 index 00000000000..064fee48573 --- /dev/null +++ b/opentrons-ai-client/src/atoms/ControlledInputField/index.tsx @@ -0,0 +1,37 @@ +import { InputField } from '@opentrons/components' +import { Controller } from 'react-hook-form' + +interface ControlledInputFieldProps { + id?: string + name: string + rules?: any + title?: string + caption?: string +} + +export function ControlledInputField({ + id, + name, + rules, + title, + caption, +}: ControlledInputFieldProps): JSX.Element { + return ( + ( + + )} + /> + ) +} diff --git a/opentrons-ai-client/src/molecules/Accordion/Accordion.stories.tsx b/opentrons-ai-client/src/molecules/Accordion/Accordion.stories.tsx index 388267061b0..a96f5e56cf1 100644 --- a/opentrons-ai-client/src/molecules/Accordion/Accordion.stories.tsx +++ b/opentrons-ai-client/src/molecules/Accordion/Accordion.stories.tsx @@ -36,6 +36,7 @@ type Story = StoryObj export const AccordionCollapsed: Story = { args: { id: 'accordion', + isOpen: false, handleClick: () => { alert('Accordion clicked') }, diff --git a/opentrons-ai-client/src/molecules/Accordion/index.tsx b/opentrons-ai-client/src/molecules/Accordion/index.tsx index 885f6af1745..4bb611d618d 100644 --- a/opentrons-ai-client/src/molecules/Accordion/index.tsx +++ b/opentrons-ai-client/src/molecules/Accordion/index.tsx @@ -1,7 +1,6 @@ -import { useRef, useState, useEffect } from 'react' +import { useRef } from 'react' import styled from 'styled-components' import { - Flex, Icon, StyledText, COLORS, @@ -14,17 +13,16 @@ import { CURSOR_POINTER, TEXT_ALIGN_LEFT, DISPLAY_FLEX, - OVERFLOW_HIDDEN, CURSOR_DEFAULT, } from '@opentrons/components' interface AccordionProps { id?: string handleClick: () => void - heading: string isOpen?: boolean isCompleted?: boolean disabled?: boolean + heading?: string children: React.ReactNode } @@ -33,54 +31,6 @@ const BUTTON = 'button' const CONTENT = 'content' const OT_CHECK = 'ot-check' -const AccordionContainer = styled(Flex)<{ - isOpen: boolean - disabled: boolean -}>` - flex-direction: ${DIRECTION_COLUMN}; - width: 100%; - height: ${SIZE_AUTO}; - padding: ${SPACING.spacing24} ${SPACING.spacing32}; - border-radius: ${BORDERS.borderRadius16}; - background-color: ${COLORS.white}; - cursor: ${props => - props.isOpen || props.disabled ? `${CURSOR_DEFAULT}` : `${CURSOR_POINTER}`}; -` - -const AccordionButton = styled.button<{ isOpen: boolean; disabled: boolean }>` - display: ${DISPLAY_FLEX}; - justify-content: ${JUSTIFY_SPACE_BETWEEN}; - align-items: ${ALIGN_CENTER}; - width: 100%; - background: none; - border: none; - cursor: ${props => - props.isOpen || props.disabled ? `${CURSOR_DEFAULT}` : `${CURSOR_POINTER}`}; - text-align: ${TEXT_ALIGN_LEFT}; - - &:focus-visible { - outline: 2px solid ${COLORS.blue50}; - } -` - -const HeadingText = styled(StyledText)` - flex: 1; - margin-right: ${SPACING.spacing8}; -` - -const AccordionContent = styled.div<{ - id: string - isOpen: boolean - contentHeight: number -}>` - transition: height 0.3s ease, margin-top 0.3s ease, visibility 0.3s ease; - overflow: ${OVERFLOW_HIDDEN}; - height: ${props => (props.isOpen ? `${props.contentHeight}px` : '0')}; - margin-top: ${props => (props.isOpen ? `${SPACING.spacing16}` : '0')}; - pointer-events: ${props => (props.isOpen ? 'auto' : 'none')}; - visibility: ${props => (props.isOpen ? 'unset' : 'hidden')}; -` - export function Accordion({ id = ACCORDION, handleClick, @@ -91,16 +41,8 @@ export function Accordion({ children, }: AccordionProps): JSX.Element { const contentRef = useRef(null) - const [contentHeight, setContentHeight] = useState(0) - - useEffect(() => { - if (contentRef.current != null) { - setContentHeight(contentRef.current.scrollHeight) - } - }, [isOpen]) const handleContainerClick = (e: React.MouseEvent): void => { - // Prevent the click event from propagating to the button if ( (e.target as HTMLElement).tagName !== 'BUTTON' && !disabled && @@ -111,7 +53,6 @@ export function Accordion({ } const handleButtonClick = (e: React.MouseEvent): void => { - // Stop the event from propagating to the container if (!isOpen && !disabled) { e.stopPropagation() handleClick() @@ -148,7 +89,6 @@ export function Accordion({ role="region" aria-labelledby={`${id}-${BUTTON}`} isOpen={isOpen} - contentHeight={contentHeight} ref={contentRef} > {children} @@ -156,3 +96,49 @@ export function Accordion({ ) } + +const AccordionContainer = styled.div<{ + isOpen: boolean + disabled: boolean +}>` + display: ${DISPLAY_FLEX}; + flex-direction: ${DIRECTION_COLUMN}; + width: 100%; + height: ${SIZE_AUTO}; + padding: ${SPACING.spacing24} ${SPACING.spacing32}; + border-radius: ${BORDERS.borderRadius16}; + background-color: ${COLORS.white}; + cursor: ${props => + props.isOpen || props.disabled ? `${CURSOR_DEFAULT}` : `${CURSOR_POINTER}`}; +` + +const AccordionButton = styled.button<{ isOpen: boolean; disabled: boolean }>` + display: ${DISPLAY_FLEX}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + align-items: ${ALIGN_CENTER}; + width: 100%; + background: none; + border: none; + cursor: ${props => + props.isOpen || props.disabled ? `${CURSOR_DEFAULT}` : `${CURSOR_POINTER}`}; + text-align: ${TEXT_ALIGN_LEFT}; + + &:focus-visible { + outline: 2px solid ${COLORS.blue50}; + } +` + +const HeadingText = styled(StyledText)` + flex: 1; + margin-right: ${SPACING.spacing8}; +` + +const AccordionContent = styled.div<{ + id: string + isOpen: boolean +}>` + max-height: ${props => (props.isOpen ? `auto` : '0')}; + margin-top: ${props => (props.isOpen ? `${SPACING.spacing16}` : '0')}; + pointer-events: ${props => (props.isOpen ? 'auto' : 'none')}; + visibility: ${props => (props.isOpen ? 'visible' : 'hidden')}; +` diff --git a/opentrons-ai-client/src/molecules/PromptPreview/index.tsx b/opentrons-ai-client/src/molecules/PromptPreview/index.tsx index b789cfbb4c7..d74884ad1ae 100644 --- a/opentrons-ai-client/src/molecules/PromptPreview/index.tsx +++ b/opentrons-ai-client/src/molecules/PromptPreview/index.tsx @@ -30,6 +30,7 @@ const PromptPreviewContainer = styled(Flex)` ` const PromptPreviewHeading = styled(Flex)` + width: 100%; flex-direction: ${DIRECTION_ROW}; justify-content: ${JUSTIFY_SPACE_BETWEEN}; align-items: ${ALIGN_CENTER}; diff --git a/opentrons-ai-client/src/organisms/ApplicationSection/__tests__/ApplicationSection.test.tsx b/opentrons-ai-client/src/organisms/ApplicationSection/__tests__/ApplicationSection.test.tsx new file mode 100644 index 00000000000..aff027425e2 --- /dev/null +++ b/opentrons-ai-client/src/organisms/ApplicationSection/__tests__/ApplicationSection.test.tsx @@ -0,0 +1,81 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { ApplicationSection } from '..' +import { FormProvider, useForm } from 'react-hook-form' + +const TestFormProviderComponent = () => { + const methods = useForm({ + defaultValues: {}, + }) + + return ( + + + + ) +} + +const render = (): ReturnType => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('ApplicationSection', () => { + it('should render scientific application dropdown, describe input and confirm button', () => { + render() + + expect( + screen.getByText("What's your scientific application?") + ).toBeInTheDocument() + expect( + screen.getByText('Describe what you are trying to do') + ).toBeInTheDocument() + expect(screen.getByText('Confirm')).toBeInTheDocument() + }) + + it('should not render other application dropdown if Other option is not selected', () => { + render() + + expect(screen.queryByText('Other application')).not.toBeInTheDocument() + }) + + it('should render other application dropdown if Other option is selected', () => { + render() + + const applicationDropdown = screen.getByText('Select an option') + fireEvent.click(applicationDropdown) + + const otherOption = screen.getByText('Other') + fireEvent.click(otherOption) + + expect(screen.getByText('Other application')).toBeInTheDocument() + }) + + it('should enable confirm button when all fields are filled', async () => { + render() + + const applicationDropdown = screen.getByText('Select an option') + fireEvent.click(applicationDropdown) + + const basicAliquotingOption = screen.getByText('Basic aliquoting') + fireEvent.click(basicAliquotingOption) + + const describeInput = screen.getByRole('textbox') + fireEvent.change(describeInput, { target: { value: 'Test description' } }) + + const confirmButton = screen.getByRole('button') + await waitFor(() => { + expect(confirmButton).toBeEnabled() + }) + }) + + it('should disable confirm button when all fields are not filled', () => { + render() + + const confirmButton = screen.getByRole('button') + expect(confirmButton).toBeDisabled() + }) +}) diff --git a/opentrons-ai-client/src/organisms/ApplicationSection/index.tsx b/opentrons-ai-client/src/organisms/ApplicationSection/index.tsx new file mode 100644 index 00000000000..5e3cc523f68 --- /dev/null +++ b/opentrons-ai-client/src/organisms/ApplicationSection/index.tsx @@ -0,0 +1,98 @@ +import { + DIRECTION_COLUMN, + DISPLAY_FLEX, + Flex, + 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 { ControlledDropdownMenu } from '../../atoms/ControlledDropdownMenu' +import { ControlledInputField } from '../../atoms/ControlledInputField' +import { useAtom } from 'jotai' +import { createProtocolAtom } from '../../resources/atoms' +import { APPLICATION_STEP } from '../ProtocolSectionsContainer' + +export const BASIC_ALIQUOTING = 'basic_aliquoting' +export const PCR = 'pcr' +export const OTHER = 'other' +export const APPLICATION_SCIENTIFIC_APPLICATION = + 'application.scientificApplication' +export const APPLICATION_OTHER_APPLICATION = 'application.otherApplication' +export const APPLICATION_DESCRIBE = 'application.description' + +export function ApplicationSection(): JSX.Element | null { + const { t } = useTranslation('create_protocol') + const { + watch, + formState: { isValid }, + } = useFormContext() + const [{ currentStep }, setCreateProtocolAtom] = useAtom(createProtocolAtom) + + const options = [ + { name: t(BASIC_ALIQUOTING), value: BASIC_ALIQUOTING }, + { name: t(PCR), value: PCR }, + { name: t(OTHER), value: OTHER }, + ] + + const isOtherSelected = watch(APPLICATION_SCIENTIFIC_APPLICATION) === OTHER + + function handleConfirmButtonClick(): void { + const step = + currentStep > APPLICATION_STEP ? currentStep : APPLICATION_STEP + 1 + + setCreateProtocolAtom({ + currentStep: step, + focusStep: step, + }) + } + + return ( + + + + {isOtherSelected && ( + + )} + + + + + + + + ) +} + +const ButtonContainer = styled.div` + display: ${DISPLAY_FLEX}; + justify-content: ${JUSTIFY_FLEX_END}; +` diff --git a/opentrons-ai-client/src/organisms/ProtocolSectionsContainer/__tests__/ProtocolSectionsContainer.test.tsx b/opentrons-ai-client/src/organisms/ProtocolSectionsContainer/__tests__/ProtocolSectionsContainer.test.tsx new file mode 100644 index 00000000000..b438b2c9af6 --- /dev/null +++ b/opentrons-ai-client/src/organisms/ProtocolSectionsContainer/__tests__/ProtocolSectionsContainer.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { ProtocolSectionsContainer } from '..' +import { FormProvider, useForm } from 'react-hook-form' +import { fillApplicationSectionAndClickConfirm } from '../../../resources/utils/createProtocolTestUtils' + +const TestFormProviderComponent = () => { + const methods = useForm({ + defaultValues: {}, + }) + + return ( + + + + ) +} + +const render = (): ReturnType => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('ProtocolSectionsContainer', () => { + it('should render all five accordions for each step of Protocol Creation', () => { + render() + + expect(screen.getByText('Application')).toBeInTheDocument() + expect(screen.getByText('Instruments')).toBeInTheDocument() + expect(screen.getByText('Modules')).toBeInTheDocument() + expect(screen.getByText('Labware & Liquids')).toBeInTheDocument() + expect(screen.getByText('Steps')).toBeInTheDocument() + }) + + it('should render the ApplicationSection opened by default', () => { + render() + + expect(screen.getByRole('button', { name: 'Application' })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + }) + + it('should render all the other sections closed by default', () => { + render() + + expect(screen.getByRole('button', { name: 'Instruments' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(screen.getByRole('button', { name: 'Modules' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect( + screen.getByRole('button', { name: 'Labware & Liquids' }) + ).toHaveAttribute('aria-expanded', 'false') + expect(screen.getByRole('button', { name: 'Steps' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + }) + + it('should go back to previous section when clicking on the previous section', async () => { + render() + + const applicationButton = screen.getByRole('button', { + name: 'Application', + }) + expect(applicationButton).toHaveAttribute('aria-expanded', 'true') + + await fillApplicationSectionAndClickConfirm() + + await waitFor(() => { + expect(applicationButton).toHaveAttribute('aria-expanded', 'false') + }) + fireEvent.click(applicationButton) + + await waitFor(() => { + expect(applicationButton).toHaveAttribute('aria-expanded', 'true') + }) + }) + + it('should not allow user to go to a future section', async () => { + render() + + const instrumentsButton = screen.getByRole('button', { + name: 'Instruments', + }) + expect(instrumentsButton).toHaveAttribute('aria-expanded', 'false') + + fireEvent.click(instrumentsButton) + + await waitFor(() => { + expect(instrumentsButton).toHaveAttribute('aria-expanded', 'false') + }) + }) +}) diff --git a/opentrons-ai-client/src/organisms/ProtocolSectionsContainer/index.tsx b/opentrons-ai-client/src/organisms/ProtocolSectionsContainer/index.tsx new file mode 100644 index 00000000000..49314c1a143 --- /dev/null +++ b/opentrons-ai-client/src/organisms/ProtocolSectionsContainer/index.tsx @@ -0,0 +1,87 @@ +import { DIRECTION_COLUMN, Flex, SPACING } from '@opentrons/components' +import { useTranslation } from 'react-i18next' +import { Accordion } from '../../molecules/Accordion' +import styled from 'styled-components' +import { ApplicationSection } from '../../organisms/ApplicationSection' +import { createProtocolAtom } from '../../resources/atoms' +import { useAtom } from 'jotai' +import { useFormContext } from 'react-hook-form' + +export const APPLICATION_STEP = 0 +export const INSTRUMENTS_STEP = 1 +export const MODULES_STEP = 2 +export const LABWARE_LIQUIDS_STEP = 3 +export const STEPS_STEP = 4 + +export function ProtocolSectionsContainer(): JSX.Element | null { + const { t } = useTranslation('create_protocol') + const { + formState: { isValid }, + } = useFormContext() + const [{ currentStep, focusStep }, setCreateProtocolAtom] = useAtom( + createProtocolAtom + ) + + function handleSectionClick(stepNumber: number): void { + currentStep >= stepNumber && + isValid && + setCreateProtocolAtom({ + currentStep, + focusStep: stepNumber, + }) + } + + function displayCheckmark(stepNumber: number): boolean { + return currentStep > stepNumber && focusStep !== stepNumber + } + + return ( + + {[ + { + stepNumber: APPLICATION_STEP, + title: 'application_title', + Component: ApplicationSection, + }, + { + stepNumber: INSTRUMENTS_STEP, + title: 'instruments_title', + Component: () => Content, + }, + { + stepNumber: MODULES_STEP, + title: 'modules_title', + Component: () => Content, + }, + { + stepNumber: LABWARE_LIQUIDS_STEP, + title: 'labware_liquids_title', + Component: () => Content, + }, + { + stepNumber: STEPS_STEP, + title: 'steps_title', + Component: () => Content, + }, + ].map(({ stepNumber, title, Component }) => ( + { + handleSectionClick(stepNumber) + }} + isCompleted={displayCheckmark(stepNumber)} + > + + + ))} + + ) +} + +const ProtocolSections = styled(Flex)` + flex-direction: ${DIRECTION_COLUMN}; + width: 100%; + gap: ${SPACING.spacing16}; +` diff --git a/opentrons-ai-client/src/pages/CreateProtocol/__tests__/CreateProtocol.test.tsx b/opentrons-ai-client/src/pages/CreateProtocol/__tests__/CreateProtocol.test.tsx new file mode 100644 index 00000000000..871bab07a7b --- /dev/null +++ b/opentrons-ai-client/src/pages/CreateProtocol/__tests__/CreateProtocol.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { CreateProtocol } from '..' +import { Provider } from 'jotai' +import { fillApplicationSectionAndClickConfirm } from '../../../resources/utils/createProtocolTestUtils' + +const render = (): ReturnType => { + return renderWithProviders( + + + , + { + i18nInstance: i18n, + } + ) +} + +describe('CreateProtocol', () => { + it('should update the active section when user fills the section information and clicks the confirm button', async () => { + render() + + const buttonsAndAccordions = screen.getAllByRole('button') + expect(buttonsAndAccordions[0]).toHaveAttribute('aria-expanded', 'true') + + await fillApplicationSectionAndClickConfirm() + + await waitFor(() => { + expect(buttonsAndAccordions[0]).toHaveAttribute('aria-expanded', 'false') + }) + }) + + it('should display the Prompt preview correctly for Application section', async () => { + render() + + await fillApplicationSectionAndClickConfirm() + + const previewItems = screen.getAllByTestId('Tag_default') + + expect(previewItems).toHaveLength(2) + expect(previewItems[0]).toHaveTextContent('Basic aliquoting') + expect(previewItems[1]).toHaveTextContent('Test description') + }) + + it('should display the Prompt preview correctly for Application section if Other application is selected', () => { + render() + + const applicationDropdown = screen.getByText('Select an option') + fireEvent.click(applicationDropdown) + + const basicAliquotingOption = screen.getByText('Other') + fireEvent.click(basicAliquotingOption) + + const [otherInput, describeInput] = screen.getAllByRole('textbox') + + fireEvent.change(otherInput, { target: { value: 'Test Application' } }) + fireEvent.change(describeInput, { target: { value: 'Test description' } }) + + const confirmButton = screen.getByText('Confirm') + fireEvent.click(confirmButton) + + const promptPreview = screen.getByText('Prompt') + expect(promptPreview).toBeInTheDocument() + + const previewItems = screen.getAllByTestId('Tag_default') + expect(previewItems).toHaveLength(2) + expect(previewItems[0]).toHaveTextContent('Test Application') + expect(previewItems[1]).toHaveTextContent('Test description') + }) + + it('should display a completed checkmark if the section is completed', async () => { + render() + + expect(screen.queryByTestId('accordion-ot-check')).not.toBeInTheDocument() + + const buttonsAndAccordions = screen.getAllByRole('button') + expect(buttonsAndAccordions[0]).toHaveAttribute('aria-expanded', 'true') + + await fillApplicationSectionAndClickConfirm() + + expect(screen.getByTestId('accordion-ot-check')).toBeInTheDocument() + }) +}) diff --git a/opentrons-ai-client/src/pages/CreateProtocol/index.tsx b/opentrons-ai-client/src/pages/CreateProtocol/index.tsx new file mode 100644 index 00000000000..346e43c879a --- /dev/null +++ b/opentrons-ai-client/src/pages/CreateProtocol/index.tsx @@ -0,0 +1,101 @@ +import { + Flex, + JUSTIFY_SPACE_EVENLY, + POSITION_RELATIVE, + SPACING, +} from '@opentrons/components' +import { useTranslation } from 'react-i18next' +import { useEffect } from 'react' +import { PromptPreview } from '../../molecules/PromptPreview' +import { useForm, FormProvider } from 'react-hook-form' +import { createProtocolAtom, headerWithMeterAtom } from '../../resources/atoms' +import { useAtom } from 'jotai' +import { ProtocolSectionsContainer } from '../../organisms/ProtocolSectionsContainer' +import { OTHER } from '../../organisms/ApplicationSection' + +interface CreateProtocolFormData { + application: { + scientificApplication: string + otherApplication?: string + description: string + } +} + +const TOTAL_STEPS = 5 + +export function CreateProtocol(): JSX.Element | null { + const { t } = useTranslation('create_protocol') + const [, setHeaderWithMeterAtom] = useAtom(headerWithMeterAtom) + const [{ currentStep }] = useAtom(createProtocolAtom) + + const methods = useForm({ + defaultValues: { + application: { + scientificApplication: '', + otherApplication: '', + description: '', + }, + }, + }) + + function calculateProgress(): number { + return currentStep > 0 ? currentStep / TOTAL_STEPS : 0 + } + + useEffect(() => { + setHeaderWithMeterAtom({ + displayHeaderWithMeter: true, + progress: calculateProgress(), + }) + }, [currentStep]) + + function generatePromptPreviewApplicationItems(): string[] { + const { + application: { scientificApplication, otherApplication, description }, + } = methods.watch() + + const scientificOrOtherApplication = + scientificApplication === OTHER + ? otherApplication + : scientificApplication !== '' + ? t(scientificApplication) + : '' + + return [ + scientificOrOtherApplication !== '' && scientificOrOtherApplication, + description !== '' && description, + ].filter(Boolean) + } + + function generatePromptPreviewData(): Array<{ + title: string + items: string[] + }> { + return [ + { + title: t('application_title'), + items: generatePromptPreviewApplicationItems(), + }, + ] + } + + return ( + + + + + + + ) +} diff --git a/opentrons-ai-client/src/pages/Landing/index.tsx b/opentrons-ai-client/src/pages/Landing/index.tsx index b464ad5ff29..cda92f7052b 100644 --- a/opentrons-ai-client/src/pages/Landing/index.tsx +++ b/opentrons-ai-client/src/pages/Landing/index.tsx @@ -17,10 +17,6 @@ import { useIsMobile } from '../../resources/hooks/useIsMobile' import { useNavigate } from 'react-router-dom' import { useTrackEvent } from '../../resources/hooks/useTrackEvent' -export interface InputType { - userPrompt: string -} - export function Landing(): JSX.Element | null { const navigate = useNavigate() const { t } = useTranslation('protocol_generator') diff --git a/opentrons-ai-client/src/resources/atoms.ts b/opentrons-ai-client/src/resources/atoms.ts index 73d45fb165b..3ea530c65f6 100644 --- a/opentrons-ai-client/src/resources/atoms.ts +++ b/opentrons-ai-client/src/resources/atoms.ts @@ -1,6 +1,12 @@ // jotai's atoms import { atom } from 'jotai' -import type { Chat, ChatData, Mixpanel } from './types' +import type { + Chat, + ChatData, + createProtocolAtomProps, + HeaderWithMeterAtomProps, + Mixpanel, +} from './types' /** ChatDataAtom is for chat data (user prompt and response from OpenAI API) */ export const chatDataAtom = atom([]) @@ -10,5 +16,16 @@ export const chatHistoryAtom = atom([]) export const tokenAtom = atom(null) export const mixpanelAtom = atom({ - analytics: { hasOptedIn: true }, // TODO: set to false + analytics: { hasOptedIn: true }, // TODO: set to false when we have the opt-in modal + isInitialized: false, +}) + +export const headerWithMeterAtom = atom({ + displayHeaderWithMeter: false, + progress: 0, +}) + +export const createProtocolAtom = atom({ + currentStep: 0, + focusStep: 0, }) diff --git a/opentrons-ai-client/src/resources/hooks/__tests__/useTrackEvent.test.tsx b/opentrons-ai-client/src/resources/hooks/__tests__/useTrackEvent.test.tsx index fab96155156..0c43e0bca24 100644 --- a/opentrons-ai-client/src/resources/hooks/__tests__/useTrackEvent.test.tsx +++ b/opentrons-ai-client/src/resources/hooks/__tests__/useTrackEvent.test.tsx @@ -5,24 +5,33 @@ import { renderHook } from '@testing-library/react' import { mixpanelAtom } from '../../atoms' import type { AnalyticsEvent } from '../../../analytics/mixpanel' import type { Mixpanel } from '../../types' -import { TestProvider } from '../../utils/testUtils' +import { TestProvider } from '../../../__testing-utils__' vi.mock('../../../analytics/mixpanel', () => ({ trackEvent: vi.fn(), })) +const mockMixpanelAtom: Mixpanel = { + analytics: { + hasOptedIn: true, + }, + isInitialized: false, +} + +const wrapper = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ) +} + describe('useTrackEvent', () => { afterEach(() => { vi.resetAllMocks() }) it('should call trackEvent with the correct arguments when hasOptedIn is true', () => { - const mockMixpanelAtom: Mixpanel = { - analytics: { - hasOptedIn: true, - }, - } - const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} @@ -38,17 +47,7 @@ describe('useTrackEvent', () => { }) it('should call trackEvent with the correct arguments when hasOptedIn is false', () => { - const mockMixpanelAtomFalse: Mixpanel = { - analytics: { - hasOptedIn: false, - }, - } - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ) + mockMixpanelAtom.analytics.hasOptedIn = false const { result } = renderHook(() => useTrackEvent(), { wrapper }) diff --git a/opentrons-ai-client/src/resources/types.ts b/opentrons-ai-client/src/resources/types.ts index 067c1ef9764..410bdfd98a6 100644 --- a/opentrons-ai-client/src/resources/types.ts +++ b/opentrons-ai-client/src/resources/types.ts @@ -35,6 +35,7 @@ export interface Mixpanel { analytics: { hasOptedIn: boolean } + isInitialized: boolean } export interface AnalyticsEvent { @@ -42,3 +43,29 @@ export interface AnalyticsEvent { properties: Record superProperties?: Record } + +export interface HeaderWithMeterAtomProps { + displayHeaderWithMeter: boolean + progress: number +} + +export interface createProtocolAtomProps { + currentStep: number + focusStep: number +} + +export interface PromptData { + /** assistant: ChatGPT API, user: user */ + role: Role + /** content gathered from the user selection */ + data: { + applicationSection: { + application: string + description: string + } + instrumentsSection: { + robot: string + instruments: string[] + } + } +} diff --git a/opentrons-ai-client/src/resources/utils/createProtocolTestUtils.tsx b/opentrons-ai-client/src/resources/utils/createProtocolTestUtils.tsx new file mode 100644 index 00000000000..8a24224394e --- /dev/null +++ b/opentrons-ai-client/src/resources/utils/createProtocolTestUtils.tsx @@ -0,0 +1,19 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { expect } from 'vitest' + +export async function fillApplicationSectionAndClickConfirm(): Promise { + const applicationDropdown = screen.getByText('Select an option') + fireEvent.click(applicationDropdown) + + const basicAliquotingOption = screen.getByText('Basic aliquoting') + fireEvent.click(basicAliquotingOption) + + const describeInput = screen.getByRole('textbox') + fireEvent.change(describeInput, { target: { value: 'Test description' } }) + + const confirmButton = screen.getByText('Confirm') + await waitFor(() => { + expect(confirmButton).toBeEnabled() + }) + fireEvent.click(confirmButton) +} diff --git a/opentrons-ai-client/src/resources/utils/testUtils.tsx b/opentrons-ai-client/src/resources/utils/testUtils.tsx deleted file mode 100644 index 954307bd391..00000000000 --- a/opentrons-ai-client/src/resources/utils/testUtils.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Provider } from 'jotai' -import { useHydrateAtoms } from 'jotai/utils' - -interface HydrateAtomsProps { - initialValues: Array<[any, any]> - children: React.ReactNode -} - -interface TestProviderProps { - initialValues: Array<[any, any]> - children: React.ReactNode -} - -export const HydrateAtoms = ({ - initialValues, - children, -}: HydrateAtomsProps): React.ReactNode => { - useHydrateAtoms(initialValues) - return children -} - -export const TestProvider = ({ - initialValues, - children, -}: TestProviderProps): React.ReactNode => ( - - {children} - -)