From 75b4be8310d0a726062cd012017da25a9d3bd639 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Fri, 7 Jun 2024 15:29:46 -0400 Subject: [PATCH] chore(app): add one col simple buttons component (#15366) 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 --- ...odalContentOneColSimpleButtons.stories.tsx | 49 +++++++ .../ModalContentOneColSimpleButtons.tsx | 57 +++++++++ .../ModalContentOneColSimpleButtons.test.tsx | 120 ++++++++++++++++++ app/src/molecules/InterventionModal/index.tsx | 3 + components/src/primitives/style-props.ts | 1 + 5 files changed, 230 insertions(+) create mode 100644 app/src/molecules/InterventionModal/ModalContentOneColSimpleButtons.stories.tsx create mode 100644 app/src/molecules/InterventionModal/ModalContentOneColSimpleButtons.tsx create mode 100644 app/src/molecules/InterventionModal/__tests__/ModalContentOneColSimpleButtons.test.tsx diff --git a/app/src/molecules/InterventionModal/ModalContentOneColSimpleButtons.stories.tsx b/app/src/molecules/InterventionModal/ModalContentOneColSimpleButtons.stories.tsx new file mode 100644 index 00000000000..5eb14346b23 --- /dev/null +++ b/app/src/molecules/InterventionModal/ModalContentOneColSimpleButtons.stories.tsx @@ -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 = { + 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 + +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 ( + + label === '' || label == null + ? null + : { label: label, value: label } + ) + .filter(val => val != null)} + /> + ) + }, +} diff --git a/app/src/molecules/InterventionModal/ModalContentOneColSimpleButtons.tsx b/app/src/molecules/InterventionModal/ModalContentOneColSimpleButtons.tsx new file mode 100644 index 00000000000..8e1e76c51f6 --- /dev/null +++ b/app/src/molecules/InterventionModal/ModalContentOneColSimpleButtons.tsx @@ -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 +} + +export interface ModalContentOneColSimpleButtonsProps { + topText: string + firstButton: ButtonProps + secondButton: ButtonProps + furtherButtons?: ButtonProps[] + onSelect?: React.ChangeEventHandler +} + +export function ModalContentOneColSimpleButtons( + props: ModalContentOneColSimpleButtonsProps +): JSX.Element { + const [selected, setSelected] = React.useState(null) + const furtherButtons = props.furtherButtons ?? [] + const buttons = [props.firstButton, props.secondButton, ...furtherButtons] + return ( + + + {props.topText} + + + {buttons.map((buttonProps, idx) => ( + { + setSelected(event.target.value) + buttonProps?.onChange && buttonProps.onChange(event) + props?.onSelect && props.onSelect(event) + }} + /> + ))} + + + ) +} diff --git a/app/src/molecules/InterventionModal/__tests__/ModalContentOneColSimpleButtons.test.tsx b/app/src/molecules/InterventionModal/__tests__/ModalContentOneColSimpleButtons.test.tsx new file mode 100644 index 00000000000..cf9e7fd639c --- /dev/null +++ b/app/src/molecules/InterventionModal/__tests__/ModalContentOneColSimpleButtons.test.tsx @@ -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( + + ) + expect(screen.getByText('top text')).not.toBeNull() + }) + it('renders buttons', () => { + render( + + ) + 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( + + ) + 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( + , + }} + 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( + + ) + + 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' }), + }) + ) + }) +}) diff --git a/app/src/molecules/InterventionModal/index.tsx b/app/src/molecules/InterventionModal/index.tsx index c696434377c..e6d0b25d3d8 100644 --- a/app/src/molecules/InterventionModal/index.tsx +++ b/app/src/molecules/InterventionModal/index.tsx @@ -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' diff --git a/components/src/primitives/style-props.ts b/components/src/primitives/style-props.ts index d8c5d4d3a30..2140866eda7 100644 --- a/components/src/primitives/style-props.ts +++ b/components/src/primitives/style-props.ts @@ -57,6 +57,7 @@ const FLEXBOX_PROPS = [ 'flexWrap', 'alignSelf', 'whiteSpace', + 'gap', ] as const const GRID_PROPS = [