Skip to content

Commit

Permalink
feat(app): allow multi file upload for labware and protocols (#13898)
Browse files Browse the repository at this point in the history
Within the file browser that launches when importing protocols or labware into the Opentrons App,
support multi file select.

Closes RAUT-218
  • Loading branch information
b-cooper authored Nov 9, 2023
1 parent 4c97e43 commit e03e988
Show file tree
Hide file tree
Showing 11 changed files with 57 additions and 37 deletions.
18 changes: 18 additions & 0 deletions app-shell/src/dialogs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ interface BaseDialogOptions {

interface FileDialogOptions extends BaseDialogOptions {
filters: Array<{ name: string; extensions: string[] }>
properties: Array<
| 'openDirectory'
| 'createDirectory'
| 'openFile'
| 'multiSelections'
| 'showHiddenFiles'
| 'promptToCreate'
| 'noResolveAliases'
| 'treatPackageAsDirectory'
| 'dontAddToRecent'
>
}

const BASE_DIRECTORY_OPTS = {
Expand Down Expand Up @@ -55,6 +66,13 @@ export function showOpenFileDialog(
openDialogOpts = { ...openDialogOpts, filters: options.filters }
}

if (options.properties != null) {
openDialogOpts = {
...openDialogOpts,
properties: [...(openDialogOpts.properties ?? []), ...options.properties],
}
}

return dialog
.showOpenDialog(browserWindow, openDialogOpts)
.then((result: OpenDialogReturnValue) => {
Expand Down
8 changes: 7 additions & 1 deletion app-shell/src/labware/__tests__/dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,13 @@ describe('labware module dispatches', () => {
return flush().then(() => {
expect(showOpenFileDialog).toHaveBeenCalledWith(mockMainWindow, {
defaultPath: '__mock-app-path__',
filters: [{ name: 'JSON Labware Definitions', extensions: ['json'] }],
filters: [
{
name: 'JSON Labware Definitions',
extensions: ['json'],
},
],
properties: ['multiSelections'],
})
expect(dispatch).not.toHaveBeenCalled()
})
Expand Down
1 change: 1 addition & 0 deletions app-shell/src/labware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export function registerLabware(
filters: [
{ name: 'JSON Labware Definitions', extensions: ['json'] },
],
properties: ['multiSelections' as const],
}

addLabwareTask = showOpenFileDialog(mainWindow, dialogOptions).then(
Expand Down
2 changes: 1 addition & 1 deletion app/src/assets/localization/en/protocol_info.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"browse_protocol_library": "Open Protocol Library",
"cancel_run": "Cancel Run",
"choose_file": "Choose File...",
"choose_protocol_file": "Choose File",
"choose_snippet_type": "Choose the Labware Offset Data Python Snippet based on target execution environment.",
"continue_proceed_to_calibrate": "Proceed to Calibrate",
"continue_verify_calibrations": "Verify pipette and labware calibrations",
Expand Down Expand Up @@ -86,6 +85,7 @@
"unpin_protocol": "Unpin protocol",
"unpinned_protocol": "Unpinned protocol",
"update_robot_for_custom_labware": "You have custom labware definitions saved to your app, but this robot needs to be updated before you can use these definitions with Python protocols",
"upload": "Upload",
"upload_and_simulate": "Open a protocol to run on {{robot_name}}",
"valid_file_types": "Valid file types: Python files (.py) or Protocol Designer files (.json)"
}
4 changes: 2 additions & 2 deletions app/src/molecules/UploadInput/__tests__/UploadInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ describe('UploadInput', () => {
it('renders correct contents for empty state', () => {
const { getByRole } = render()

expect(getByRole('button', { name: 'Choose File' })).toBeTruthy()
expect(getByRole('button', { name: 'Upload' })).toBeTruthy()
})

it('opens file select on button click', () => {
const { getByRole, getByTestId } = render()
const button = getByRole('button', { name: 'Choose File' })
const button = getByRole('button', { name: 'Upload' })
const input = getByTestId('file_input')
input.click = jest.fn()
fireEvent.click(button)
Expand Down
30 changes: 11 additions & 19 deletions app/src/molecules/UploadInput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react'
import { css } from 'styled-components'
import styled, { css } from 'styled-components'
import { useTranslation } from 'react-i18next'
import {
Icon,
Expand All @@ -16,7 +16,7 @@ import {
} from '@opentrons/components'
import { StyledText } from '../../atoms/text'

const DROP_ZONE_STYLES = css`
const StyledLabel = styled.label`
display: flex;
cursor: pointer;
flex-direction: ${DIRECTION_COLUMN};
Expand All @@ -39,7 +39,7 @@ const DRAG_OVER_STYLES = css`
border: 2px dashed ${COLORS.blueEnabled};
`

const INPUT_STYLES = css`
const StyledInput = styled.input`
position: fixed;
clip: rect(1px 1px 1px 1px);
`
Expand All @@ -61,8 +61,7 @@ export function UploadInput(props: UploadInputProps): JSX.Element | null {
const handleDrop: React.DragEventHandler<HTMLLabelElement> = e => {
e.preventDefault()
e.stopPropagation()
const { files = [] } = 'dataTransfer' in e ? e.dataTransfer : {}
props.onUpload(files[0])
Array.from(e.dataTransfer.files).forEach(f => props.onUpload(f))
setIsFileOverDropZone(false)
}
const handleDragEnter: React.DragEventHandler<HTMLLabelElement> = e => {
Expand All @@ -85,17 +84,10 @@ export function UploadInput(props: UploadInputProps): JSX.Element | null {
}

const onChange: React.ChangeEventHandler<HTMLInputElement> = event => {
const { files = [] } = event.target ?? {}
files?.[0] != null && props.onUpload(files?.[0])
;[...(event.target.files ?? [])].forEach(f => props.onUpload(f))
if ('value' in event.currentTarget) event.currentTarget.value = ''
}

const dropZoneStyles = isFileOverDropZone
? css`
${DROP_ZONE_STYLES} ${DRAG_OVER_STYLES}
`
: DROP_ZONE_STYLES

return (
<Flex
height="100%"
Expand All @@ -115,16 +107,16 @@ export function UploadInput(props: UploadInputProps): JSX.Element | null {
onClick={handleClick}
id="UploadInput_protocolUploadButton"
>
{t('choose_protocol_file')}
{t('upload')}
</PrimaryButton>

<label
<StyledLabel
data-testid="file_drop_zone"
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
css={dropZoneStyles}
css={isFileOverDropZone ? DRAG_OVER_STYLES : undefined}
>
<Icon
width={SIZE_3}
Expand All @@ -133,15 +125,15 @@ export function UploadInput(props: UploadInputProps): JSX.Element | null {
marginBottom={SPACING.spacing24}
/>
{props.dragAndDropText}
<input
<StyledInput
id="file_input"
data-testid="file_input"
ref={fileInput}
css={INPUT_STYLES}
type="file"
onChange={onChange}
multiple
/>
</label>
</StyledLabel>
</Flex>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('AddCustomLabwareSlideout', () => {
const [{ getByText, getByRole }] = render(props)
getByText('Import a Custom Labware Definition')
getByText('Or choose a file from your computer to upload.')
const btn = getByRole('button', { name: 'Choose File' })
const btn = getByRole('button', { name: 'Upload' })
fireEvent.click(btn)
expect(mockTrackEvent).toHaveBeenCalledWith({
name: ANALYTICS_ADD_CUSTOM_LABWARE,
Expand Down
6 changes: 4 additions & 2 deletions app/src/organisms/ProtocolsLanding/ProtocolList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { StyledText } from '../../atoms/text'
import { Slideout } from '../../atoms/Slideout'
import { ChooseRobotToRunProtocolSlideout } from '../ChooseRobotToRunProtocolSlideout'
import { SendProtocolToOT3Slideout } from '../SendProtocolToOT3Slideout'
import { UploadInput } from './UploadInput'
import { ProtocolUploadInput } from './ProtocolUploadInput'
import { ProtocolCard } from './ProtocolCard'
import { EmptyStateLinks } from './EmptyStateLinks'
import { MenuItem } from '../../atoms/MenuList/MenuItem'
Expand Down Expand Up @@ -254,7 +254,9 @@ export function ProtocolList(props: ProtocolListProps): JSX.Element | null {
onCloseClick={() => setShowImportProtocolSlideout(false)}
>
<Box marginTop={SPACING.spacing16}>
<UploadInput onUpload={() => setShowImportProtocolSlideout(false)} />
<ProtocolUploadInput
onUpload={() => setShowImportProtocolSlideout(false)}
/>
</Box>
</Slideout>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
SPACING,
} from '@opentrons/components'
import { StyledText } from '../../atoms/text'
import { UploadInput as FileImporter } from '../../molecules/UploadInput'
import { UploadInput } from '../../molecules/UploadInput'
import { addProtocol } from '../../redux/protocol-storage'
import {
useTrackEvent,
Expand All @@ -24,8 +24,9 @@ export interface UploadInputProps {
onUpload?: () => void
}

// TODO(bc, 2022-3-21): consider making this generic for any file upload and adding it to molecules/organisms with onUpload taking the files from the event
export function UploadInput(props: UploadInputProps): JSX.Element | null {
export function ProtocolUploadInput(
props: UploadInputProps
): JSX.Element | null {
const { t } = useTranslation(['protocol_info', 'shared'])
const dispatch = useDispatch<Dispatch>()
const logger = useLogger(__filename)
Expand All @@ -49,7 +50,7 @@ export function UploadInput(props: UploadInputProps): JSX.Element | null {
alignItems={ALIGN_CENTER}
marginY={SPACING.spacing20}
>
<FileImporter
<UploadInput
onUpload={(file: File) => handleUpload(file)}
uploadText={t('valid_file_types')}
dragAndDropText={
Expand Down
4 changes: 2 additions & 2 deletions app/src/organisms/ProtocolsLanding/ProtocolsEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from '@opentrons/components'

import { StyledText } from '../../atoms/text'
import { UploadInput } from './UploadInput'
import { ProtocolUploadInput } from './ProtocolUploadInput'
import { EmptyStateLinks } from './EmptyStateLinks'
export function ProtocolsEmptyState(): JSX.Element | null {
const { t } = useTranslation('protocol_info')
Expand All @@ -25,7 +25,7 @@ export function ProtocolsEmptyState(): JSX.Element | null {
<StyledText role="complementary" as="h1">
{t('import_a_file')}
</StyledText>
<UploadInput />
<ProtocolUploadInput />
<EmptyStateLinks title={t('no_protocol_yet')} />
</Flex>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import {
useTrackEvent,
ANALYTICS_IMPORT_PROTOCOL_TO_APP,
} from '../../../redux/analytics'
import { UploadInput } from '../UploadInput'
import { ProtocolUploadInput } from '../ProtocolUploadInput'

jest.mock('../../../redux/analytics')

const mockUseTrackEvent = useTrackEvent as jest.Mock<typeof useTrackEvent>

describe('UploadInput', () => {
describe('ProtocolUploadInput', () => {
let onUpload: jest.MockedFunction<() => {}>
let trackEvent: jest.MockedFunction<any>
let render: () => ReturnType<typeof renderWithProviders>[0]
Expand All @@ -26,7 +26,7 @@ describe('UploadInput', () => {
render = () => {
return renderWithProviders(
<BrowserRouter>
<UploadInput onUpload={onUpload} />
<ProtocolUploadInput onUpload={onUpload} />
</BrowserRouter>,
{
i18nInstance: i18n,
Expand All @@ -41,7 +41,7 @@ describe('UploadInput', () => {
it('renders correct contents for empty state', () => {
const { findByText, getByRole } = render()

getByRole('button', { name: 'Choose File' })
getByRole('button', { name: 'Upload' })
findByText('Drag and drop or')
findByText('your files')
findByText(
Expand All @@ -52,7 +52,7 @@ describe('UploadInput', () => {

it('opens file select on button click', () => {
const { getByRole, getByTestId } = render()
const button = getByRole('button', { name: 'Choose File' })
const button = getByRole('button', { name: 'Upload' })
const input = getByTestId('file_input')
input.click = jest.fn()
fireEvent.click(button)
Expand Down

0 comments on commit e03e988

Please sign in to comment.