Skip to content

Commit

Permalink
chore(app): add one col simple buttons component (#15366)
Browse files Browse the repository at this point in the history
This is a subcomponent that can provide modal content for
InterventionModal. It lives inside the InterventionModal molecule (at
least for now). It's a radio button list where you can select one of the
provided buttons.

The argument passing style is a little weird but it's because there must
be at least two buttons, and this is a nice way to make it hard to do
anything else. They aren't child nodes because the whole point of this
component is to enforce a single button style.

Figma:

https://www.figma.com/design/8dMeu8MuPfXoORtOV6cACO/Feature%3A-Error-Recovery?node-id=2523-42677&t=yaBnNzMY2BoDzZZm-0

Storybook:

https://s3-us-west-2.amazonaws.com/opentrons-components/exec-490-one-col-simple-buttons/index.html?path=%2Fdocs%2Fdesign-tokens-borderradius--docs

Closes [EXEC-490](https://opentrons.atlassian.net/browse/EXEC-490)

[EXEC-490]:
https://opentrons.atlassian.net/browse/EXEC-490?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
  • Loading branch information
sfoster1 authored Jun 7, 2024
1 parent 1fee05f commit 75b4be8
Show file tree
Hide file tree
Showing 5 changed files with 230 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as React from 'react'
import { ModalContentOneColSimpleButtons as ModalContentOneColSimpleButtonsComponent } from './ModalContentOneColSimpleButtons'

import type { Meta, StoryObj } from '@storybook/react'

const meta: Meta<typeof ModalContentOneColSimpleButtonsComponent> = {
title: 'App/Molecules/InterventionModal/ModalContentOneColSimpleButtons',
component: ModalContentOneColSimpleButtonsComponent,
argTypes: {
firstButton: {
control: { type: 'text' },
},
secondButton: {
control: { type: 'text' },
},
furtherButtons: {
control: { type: 'array' },
},
},
}

export default meta

type Story = StoryObj<typeof ModalContentOneColSimpleButtonsComponent>

export const ModalContentOneColSimpleButtons: Story = {
args: {
topText: 'This is the top text area.',
firstButton: 'This is the first button',
secondButton: 'This is the second button',
furtherButtons: ['this is the third button', 'this is the fourth button'],
},
render: (args, context) => {
return (
<ModalContentOneColSimpleButtonsComponent
topText={args.topText}
firstButton={{ label: args.firstButton, value: args.firstButton }}
secondButton={{ label: args.secondButton, value: args.secondButton }}
furtherButtons={args.furtherButtons
.map(label =>
label === '' || label == null
? null
: { label: label, value: label }
)
.filter(val => val != null)}
/>
)
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import * as React from 'react'
import {
Flex,
DIRECTION_COLUMN,
SPACING,
StyledText,
TYPOGRAPHY,
} from '@opentrons/components'
import { RadioButton } from '../../atoms/buttons/RadioButton'

export interface ButtonProps {
label: string
value: string
onChange?: React.ChangeEventHandler<HTMLInputElement>
}

export interface ModalContentOneColSimpleButtonsProps {
topText: string
firstButton: ButtonProps
secondButton: ButtonProps
furtherButtons?: ButtonProps[]
onSelect?: React.ChangeEventHandler<HTMLInputElement>
}

export function ModalContentOneColSimpleButtons(
props: ModalContentOneColSimpleButtonsProps
): JSX.Element {
const [selected, setSelected] = React.useState<string | null>(null)
const furtherButtons = props.furtherButtons ?? []
const buttons = [props.firstButton, props.secondButton, ...furtherButtons]
return (
<Flex flexDirection={DIRECTION_COLUMN} gap={SPACING.spacing16}>
<StyledText
fontSize={TYPOGRAPHY.fontSize28}
fontWeight={TYPOGRAPHY.fontWeightSemiBold}
lineHeight={TYPOGRAPHY.lineHeight36}
>
{props.topText}
</StyledText>
<Flex flexDirection={DIRECTION_COLUMN} gap={SPACING.spacing4}>
{buttons.map((buttonProps, idx) => (
<RadioButton
key={`button${idx}-${buttonProps.value}`}
buttonLabel={buttonProps.label}
buttonValue={buttonProps.value}
isSelected={selected === buttonProps.value}
onChange={event => {
setSelected(event.target.value)
buttonProps?.onChange && buttonProps.onChange(event)
props?.onSelect && props.onSelect(event)
}}
/>
))}
</Flex>
</Flex>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import * as React from 'react'
import { vi, describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'

import { ModalContentOneColSimpleButtons } from '../ModalContentOneColSimpleButtons'

/* eslint-disable testing-library/no-node-access */
const inputElForButtonFromButtonText = (text: string): HTMLInputElement =>
((screen.getByText(text)?.parentElement?.parentElement
?.firstChild as any) as HTMLInputElement) ||
(() => {
throw new Error(`Could not find el for ${text}`)
})()
/* eslint-enable testing-library/no-node-access */

describe('InterventionModal', () => {
it('renders top text', () => {
render(
<ModalContentOneColSimpleButtons
topText={'top text'}
firstButton={{ label: 'first button', value: 'first' }}
secondButton={{ label: 'second button', value: 'second' }}
/>
)
expect(screen.getByText('top text')).not.toBeNull()
})
it('renders buttons', () => {
render(
<ModalContentOneColSimpleButtons
topText={'top text'}
firstButton={{ label: 'first button', value: 'first' }}
secondButton={{ label: 'second button', value: 'second' }}
furtherButtons={[
{ label: 'third button', value: 'third' },
{ label: 'fourth button', value: 'fourth' },
]}
/>
)
expect(screen.getByText('first button')).not.toBeNull()
expect(screen.getByText('second button')).not.toBeNull()
expect(screen.getByText('third button')).not.toBeNull()
expect(screen.getByText('fourth button')).not.toBeNull()
})
it('enforces single-item selection', () => {
render(
<ModalContentOneColSimpleButtons
topText={'top text'}
firstButton={{ label: 'first button', value: 'first' }}
secondButton={{ label: 'second button', value: 'second' }}
furtherButtons={[{ label: 'third button', value: 'third' }]}
/>
)
expect(inputElForButtonFromButtonText('first button').checked).toBeFalsy()
expect(inputElForButtonFromButtonText('second button').checked).toBeFalsy()
expect(inputElForButtonFromButtonText('third button').checked).toBeFalsy()

fireEvent.click(inputElForButtonFromButtonText('first button'))
expect(inputElForButtonFromButtonText('first button').checked).toBeTruthy()
expect(inputElForButtonFromButtonText('second button').checked).toBeFalsy()
expect(inputElForButtonFromButtonText('third button').checked).toBeFalsy()

fireEvent.click(inputElForButtonFromButtonText('third button'))
expect(inputElForButtonFromButtonText('first button').checked).toBeFalsy()
expect(inputElForButtonFromButtonText('second button').checked).toBeFalsy()
expect(inputElForButtonFromButtonText('third button').checked).toBeTruthy()
})

it('propagates individual button onChange', () => {
const onChange = vi.fn()
render(
<ModalContentOneColSimpleButtons
topText={'top text'}
firstButton={{
label: 'first button',
value: 'first',
onChange: onChange as React.ChangeEventHandler<HTMLInputElement>,
}}
secondButton={{ label: 'second button', value: 'second' }}
furtherButtons={[{ label: 'third button', value: 'third' }]}
/>
)
fireEvent.click(inputElForButtonFromButtonText('first button'))
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.objectContaining({ value: 'first' }),
})
)
vi.restoreAllMocks()

fireEvent.click(inputElForButtonFromButtonText('second button'))
expect(onChange).not.toHaveBeenCalled()
})

it('propagates whole-list onSelect', () => {
const onSelect = vi.fn()
render(
<ModalContentOneColSimpleButtons
topText={'top text'}
firstButton={{ label: 'first button', value: 'first' }}
secondButton={{ label: 'second button', value: 'second' }}
furtherButtons={[{ label: 'third button', value: 'third' }]}
onSelect={onSelect}
/>
)

fireEvent.click(inputElForButtonFromButtonText('first button'))
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.objectContaining({ value: 'first' }),
})
)
vi.restoreAllMocks()
fireEvent.click(inputElForButtonFromButtonText('third button'))
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.objectContaining({ value: 'third' }),
})
)
})
})
3 changes: 3 additions & 0 deletions app/src/molecules/InterventionModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import {
import { getIsOnDevice } from '../../redux/config'

import type { IconName } from '@opentrons/components'
import { ModalContentOneColSimpleButtons } from './ModalContentOneColSimpleButtons'

export { ModalContentOneColSimpleButtons }

export type ModalType = 'intervention-required' | 'error'

Expand Down
1 change: 1 addition & 0 deletions components/src/primitives/style-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const FLEXBOX_PROPS = [
'flexWrap',
'alignSelf',
'whiteSpace',
'gap',
] as const

const GRID_PROPS = [
Expand Down

0 comments on commit 75b4be8

Please sign in to comment.