Skip to content

Commit

Permalink
feat: Opentrons ai client landing page (#16552)
Browse files Browse the repository at this point in the history
# Overview

This PR refactors Opentrons AI Client to match the new design. It also
adds the mixpanel files for tracking analytics.


![image](https://github.com/user-attachments/assets/cebc0744-c791-4426-9dda-f7b1121a4e19)

## Test Plan and Hands on Testing

Still wip, but tested manually the landing page and its buttons, and if
mixpanel is tracking the events.

## Changelog

 - New Opentron AI client flow and Landing page.
 - Add mixpanel analytics.

## Review requests

- Landing page.
- Verify if the approach for Mixpanel implementation is ok.

## Risk assessment

It breaks the current Opentrons AI client, as it's being remade from the
ground up.
  • Loading branch information
fbelginetw authored Oct 23, 2024
1 parent 7e3453d commit 1deeb88
Show file tree
Hide file tree
Showing 22 changed files with 717 additions and 130 deletions.
52 changes: 6 additions & 46 deletions opentrons-ai-client/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,13 @@
import { fireEvent, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { describe, it, vi, beforeEach, expect } from 'vitest'
import * as auth0 from '@auth0/auth0-react'

import { renderWithProviders } from './__testing-utils__'
import { i18n } from './i18n'
import { SidePanel } from './molecules/SidePanel'
import { MainContentContainer } from './organisms/MainContentContainer'
import { Loading } from './molecules/Loading'

import { App } from './App'
import { OpentronsAI } from './OpentronsAI'

vi.mock('@auth0/auth0-react')

const mockLogout = vi.fn()

vi.mock('./molecules/SidePanel')
vi.mock('./organisms/MainContentContainer')
vi.mock('./molecules/Loading')
vi.mock('./OpentronsAI')

const render = (): ReturnType<typeof renderWithProviders> => {
return renderWithProviders(<App />, {
Expand All @@ -26,42 +17,11 @@ const render = (): ReturnType<typeof renderWithProviders> => {

describe('App', () => {
beforeEach(() => {
vi.mocked(SidePanel).mockReturnValue(<div>mock SidePanel</div>)
vi.mocked(MainContentContainer).mockReturnValue(
<div>mock MainContentContainer</div>
)
vi.mocked(Loading).mockReturnValue(<div>mock Loading</div>)
})

it('should render loading screen when isLoading is true', () => {
;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({
isAuthenticated: false,
isLoading: true,
})
render()
screen.getByText('mock Loading')
})

it('should render text', () => {
;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({
isAuthenticated: true,
isLoading: false,
})
render()
screen.getByText('mock SidePanel')
screen.getByText('mock MainContentContainer')
screen.getByText('Logout')
vi.mocked(OpentronsAI).mockReturnValue(<div>mock OpentronsAI</div>)
})

it('should call a mock function when clicking logout button', () => {
;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({
isAuthenticated: true,
isLoading: false,
logout: mockLogout,
})
it('should render OpentronsAI', () => {
render()
const logoutButton = screen.getByText('Logout')
fireEvent.click(logoutButton)
expect(mockLogout).toHaveBeenCalled()
expect(screen.getByText('mock OpentronsAI')).toBeInTheDocument()
})
})
81 changes: 2 additions & 79 deletions opentrons-ai-client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,82 +1,5 @@
import { useEffect } from 'react'
import { useAuth0 } from '@auth0/auth0-react'
import { useTranslation } from 'react-i18next'
import { useForm, FormProvider } from 'react-hook-form'
import { useAtom } from 'jotai'
import {
COLORS,
Flex,
Link as LinkButton,
POSITION_ABSOLUTE,
POSITION_RELATIVE,
TYPOGRAPHY,
} from '@opentrons/components'

import { tokenAtom } from './resources/atoms'
import { useGetAccessToken } from './resources/hooks'
import { SidePanel } from './molecules/SidePanel'
import { Loading } from './molecules/Loading'
import { MainContentContainer } from './organisms/MainContentContainer'

export interface InputType {
userPrompt: string
}
import { OpentronsAI } from './OpentronsAI'

export function App(): JSX.Element | null {
const { t } = useTranslation('protocol_generator')
const { isAuthenticated, logout, isLoading, loginWithRedirect } = useAuth0()
const [, setToken] = useAtom(tokenAtom)
const { getAccessToken } = useGetAccessToken()

const fetchAccessToken = async (): Promise<void> => {
try {
const accessToken = await getAccessToken()
setToken(accessToken)
} catch (error) {
console.error('Error fetching access token:', error)
}
}
const methods = useForm<InputType>({
defaultValues: {
userPrompt: '',
},
})

useEffect(() => {
if (!isAuthenticated && !isLoading) {
void loginWithRedirect()
}
if (isAuthenticated) {
void fetchAccessToken()
}
}, [isAuthenticated, isLoading, loginWithRedirect])

if (isLoading) {
return <Loading />
}

if (!isAuthenticated) {
return null
}

return (
<Flex
position={POSITION_RELATIVE}
minHeight="100vh"
backgroundColor={COLORS.grey10}
>
<Flex position={POSITION_ABSOLUTE} top="1rem" right="1rem">
<LinkButton
onClick={() => logout()}
textDecoration={TYPOGRAPHY.textDecorationUnderline}
>
{t('logout')}
</LinkButton>
</Flex>
<FormProvider {...methods}>
<SidePanel />
<MainContentContainer />
</FormProvider>
</Flex>
)
return <OpentronsAI />
}
82 changes: 82 additions & 0 deletions opentrons-ai-client/src/OpentronsAI.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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'

vi.mock('@auth0/auth0-react')

vi.mock('./pages/Landing')
vi.mock('./molecules/Header')
vi.mock('./molecules/Footer')
vi.mock('./molecules/Loading')
vi.mock('./resources/hooks/useGetAccessToken')
vi.mock('./analytics/mixpanel')

const mockUseTrackEvent = vi.fn()

vi.mock('./resources/hooks/useTrackEvent', () => ({
useTrackEvent: () => mockUseTrackEvent,
}))

const render = (): ReturnType<typeof renderWithProviders> => {
return renderWithProviders(<OpentronsAI />, {
i18nInstance: i18n,
})
}

describe('OpentronsAI', () => {
beforeEach(() => {
vi.mocked(useGetAccessToken).mockReturnValue({
getAccessToken: vi.fn().mockResolvedValue('mock access token'),
})
vi.mocked(Landing).mockReturnValue(<div>mock Landing page</div>)
vi.mocked(Loading).mockReturnValue(<div>mock Loading</div>)
vi.mocked(Header).mockReturnValue(<div>mock Header component</div>)
vi.mocked(Footer).mockReturnValue(<div>mock Footer component</div>)
})

it('should render loading screen when isLoading is true', () => {
;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({
isAuthenticated: false,
isLoading: true,
})
render()
screen.getByText('mock Loading')
})

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,
})
render()
screen.getByText('mock Header component')
})

it('should render Footer component', () => {
;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({
isAuthenticated: true,
isLoading: false,
})
render()
screen.getByText('mock Footer component')
})
})
90 changes: 90 additions & 0 deletions opentrons-ai-client/src/OpentronsAI.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { HashRouter } from 'react-router-dom'
import {
DIRECTION_COLUMN,
Flex,
OVERFLOW_AUTO,
COLORS,
ALIGN_CENTER,
} from '@opentrons/components'
import { OpentronsAIRoutes } from './OpentronsAIRoutes'
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 { 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'

export function OpentronsAI(): JSX.Element | null {
const { isAuthenticated, isLoading, loginWithRedirect } = useAuth0()
const [, setToken] = useAtom(tokenAtom)
const [mixpanel] = useAtom(mixpanelAtom)
const { getAccessToken } = useGetAccessToken()
const trackEvent = useTrackEvent()

initializeMixpanel(mixpanel)

const fetchAccessToken = async (): Promise<void> => {
try {
const accessToken = await getAccessToken()
setToken(accessToken)
} catch (error) {
console.error('Error fetching access token:', error)
}
}

useEffect(() => {
if (!isAuthenticated && !isLoading) {
void loginWithRedirect()
}
if (isAuthenticated) {
void fetchAccessToken()
}
}, [isAuthenticated, isLoading, loginWithRedirect])

useEffect(() => {
if (isAuthenticated) {
trackEvent({ name: 'user-login', properties: {} })
}
}, [isAuthenticated])

if (isLoading) {
return <Loading />
}

if (!isAuthenticated) {
return null
}

return (
<div
id="opentrons-ai"
style={{ width: '100%', height: '100vh', overflow: OVERFLOW_AUTO }}
>
<Flex
height="100%"
flexDirection={DIRECTION_COLUMN}
backgroundColor={COLORS.grey10}
>
<Header />

<Flex
width="100%"
height="100%"
maxWidth={CLIENT_MAX_WIDTH}
alignSelf={ALIGN_CENTER}
>
<HashRouter>
<OpentronsAIRoutes />
</HashRouter>
</Flex>

<Footer />
</Flex>
</div>
)
}
39 changes: 39 additions & 0 deletions opentrons-ai-client/src/OpentronsAIRoutes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Route, Navigate, Routes } from 'react-router-dom'
import { Landing } from './pages/Landing'

import type { RouteProps } from './resources/types'

const opentronsAIRoutes: RouteProps[] = [
// replace Landing with the correct component
{
Component: Landing,
name: 'Create A New Protocol',
navLinkTo: '/new-protocol',
path: '/new-protocol',
},
{
Component: Landing,
name: 'Update An Existing Protocol',
navLinkTo: '/update-protocol',
path: '/update-protocol',
},
]

export function OpentronsAIRoutes(): JSX.Element {
const landingPage: RouteProps = {
Component: Landing,
name: 'Landing',
navLinkTo: '/',
path: '/',
}
const allRoutes: RouteProps[] = [...opentronsAIRoutes, landingPage]

return (
<Routes>
{allRoutes.map(({ Component, path }: RouteProps) => (
<Route key={path} path={path} element={<Component />} />
))}
<Route path="*" element={<Navigate to={landingPage.path} />} />
</Routes>
)
}
Loading

0 comments on commit 1deeb88

Please sign in to comment.