Skip to content

Commit

Permalink
feat: Add FilePicker component
Browse files Browse the repository at this point in the history
Inspired by MoveTo on Drive.
FilePicker allows to select a file or a folder
in Drive a single or multiple way
and returns an array of id of the selected items
  • Loading branch information
Merkur39 committed Jan 18, 2022
1 parent 4ed7661 commit aeca351
Show file tree
Hide file tree
Showing 15 changed files with 1,116 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/styleguide.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ module.exports = {
'../react/AppSections/index.jsx',
'../react/CipherIcon/index.jsx',
'../react/CozyTheme/index.jsx',
'../react/FilePicker/index.jsx',
'../react/Overlay/index.jsx',
'../react/PasswordExample/index.jsx',
'../react/Popup/index.jsx',
Expand Down
115 changes: 115 additions & 0 deletions react/FilePicker/FilePickerBody.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import React, { useCallback } from 'react'
import PropTypes from 'prop-types'

import { models, useQuery } from 'cozy-client'
import List from '../MuiCozyTheme/List'
import LoadMore from '../LoadMore'

import { buildContentFolderQuery } from './queries'
import FilePickerBodyItem from './FilePickerBodyItem'

const {
file: { isDirectory, isFile }
} = models

const FilePickerBody = ({
navigateTo,
folderId,
onSelectFileId,
filesIdsSelected,
fileTypesAccepted,
multiple
}) => {
const contentFolderQuery = buildContentFolderQuery(folderId)
const { data: contentFolder, hasMore, fetchMore } = useQuery(
contentFolderQuery.definition,
contentFolderQuery.options
)

const onCheck = useCallback(
fileId => {
const isChecked = filesIdsSelected.some(
fileIdSelected => fileIdSelected === fileId
)
if (isChecked) {
onSelectFileId(
filesIdsSelected.filter(fileIdSelected => fileIdSelected !== fileId)
)
} else onSelectFileId(prev => [...prev, fileId])
},
[filesIdsSelected, onSelectFileId]
)

// When click on checkbox/radio area...
const handleChoiceClick = useCallback(
file => () => {
if (multiple) onCheck(file._id)
else onSelectFileId(file._id)
},
[multiple, onCheck, onSelectFileId]
)

// ...when click anywhere on the rest of the line
const handleListItemClick = useCallback(
file => () => {
if (isDirectory(file)) {
navigateTo(contentFolder.find(f => f._id === file._id))
}

if (isFile(file)) {
if (!fileTypesAccepted.file) return
if (fileTypesAccepted.file) {
if (multiple) onCheck(file._id)
else onSelectFileId(file._id)
}
}
},
[
contentFolder,
fileTypesAccepted.file,
multiple,
navigateTo,
onCheck,
onSelectFileId
]
)

return (
<List>
{contentFolder &&
contentFolder.map((file, idx) => {
const hasDivider = contentFolder
? idx !== contentFolder.length - 1
: false

return (
<FilePickerBodyItem
key={file._id}
file={file}
fileTypesAccepted={fileTypesAccepted}
multiple={multiple}
handleChoiceClick={handleChoiceClick}
handleListItemClick={handleListItemClick}
onCheck={onCheck}
filesIdsSelected={filesIdsSelected}
hasDivider={hasDivider}
/>
)
})}
{hasMore && <LoadMore label={'loadMore'} fetchMore={fetchMore} />}
</List>
)
}

FilePickerBody.propTypes = {
onSelectFileId: PropTypes.func.isRequired,
filesIdsSelected: PropTypes.arrayOf(PropTypes.string).isRequired,
folderId: PropTypes.string.isRequired,
navigateTo: PropTypes.func.isRequired,
fileTypesAccepted: PropTypes.shape({
file: PropTypes.bool,
folder: PropTypes.bool
})
}

export default FilePickerBody
127 changes: 127 additions & 0 deletions react/FilePicker/FilePickerBodyItem.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React from 'react'
import PropTypes from 'prop-types'
import cx from 'classnames'
import filesize from 'filesize'
import { makeStyles } from '@material-ui/core/styles'

import { models } from 'cozy-client'

import ListItem from '../MuiCozyTheme/ListItem'
import ListItemIcon from '../MuiCozyTheme/ListItemIcon'
import ListItemText from '../ListItemText'
import Divider from '../MuiCozyTheme/Divider'
import Icon from '../Icon'
import FileTypeText from '../Icons/FileTypeText'
import FileTypeFolder from '../Icons/FileTypeFolder'
import Checkbox from '../Checkbox'
import Radio from '../Radio'
import { useI18n } from '../I18n'

const {
file: { isDirectory, isFile }
} = models

const useStyles = makeStyles(() => ({
verticalDivider: {
height: '2rem',
display: 'flex',
alignSelf: 'auto',
alignItems: 'center',
marginLeft: '0.5rem'
},
listItemIcon: {
marginLeft: '1rem'
}
}))

const FilePickerBodyItem = ({
file,
fileTypesAccepted,
multiple,
handleChoiceClick,
handleListItemClick,
filesIdsSelected,
hasDivider
}) => {
const classes = useStyles()
const { f } = useI18n()
const hasChoice =
(fileTypesAccepted.file && isFile(file)) ||
(fileTypesAccepted.folder && isDirectory(file))

const Input = multiple ? Checkbox : Radio

const listItemSecondaryContent = isFile(file)
? `${f(file.attributes.updated_at, 'DD MMM YYYY')} - ${filesize(
file.attributes.size,
{ base: 10 }
)}`
: null

return (
<>
<ListItem button className={cx('u-p-0')}>
<div
data-testid="listitem-onclick"
style={{ display: 'contents' }}
onClick={handleListItemClick(file)}
>
<ListItemIcon className={classes.listItemIcon}>
<Icon
icon={isDirectory(file) ? FileTypeFolder : FileTypeText}
width="32"
height="32"
/>
</ListItemIcon>
<ListItemText
primary={file.name}
secondary={listItemSecondaryContent}
/>
</div>
{isDirectory(file) && hasChoice && (
<Divider
orientation="vertical"
flexItem
className={classes.verticalDivider}
/>
)}
<div
data-testid="choice-onclick"
className={cx('u-ph-1 u-pv-half u-h-2 u-flex u-flex-items-center')}
onClick={hasChoice ? handleChoiceClick(file) : undefined}
>
<Input
data-testid={multiple ? 'checkbox-btn' : 'radio-btn'}
gutter={false}
onChange={() => {
// handled by onClick on the container
}}
checked={filesIdsSelected.includes(file._id)}
value={file._id}
className={cx('u-p-0', {
'u-o-100': hasChoice,
'u-o-0': !hasChoice
})}
disabled={!hasChoice}
/>
</div>
</ListItem>
{hasDivider && <Divider component="li" />}
</>
)
}

FilePickerBodyItem.propTypes = {
file: PropTypes.object.isRequired,
fileTypesAccepted: PropTypes.shape({
file: PropTypes.bool,
folder: PropTypes.bool
}),
multiple: PropTypes.bool,
handleChoiceClick: PropTypes.func.isRequired,
handleListItemClick: PropTypes.func.isRequired,
filesIdsSelected: PropTypes.arrayOf(PropTypes.string).isRequired,
hasDivider: PropTypes.bool.isRequired
}

export default FilePickerBodyItem
132 changes: 132 additions & 0 deletions react/FilePicker/FilePickerBodyItem.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
'use strict'
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import filesize from 'filesize'

import DemoProvider from './docs/DemoProvider'
import FilePickerBodyItem from './FilePickerBodyItem'

const mockFile01 = {
_id: '001',
type: 'file',
name: 'Filename',
attributes: { updated_at: '2021-01-01T12:00:00.000000+01:00' }
}
const mockFolder01 = {
_id: '002',
type: 'directory',
name: 'Foldername',
attributes: { updated_at: '2021-01-01T12:00:00.000000+01:00' }
}

jest.mock('filesize', () => jest.fn())

describe('FilePickerBodyItem components:', () => {
const mockHandleChoiceClick = jest.fn()
const mockHandleListItemClick = jest.fn()
filesize.mockReturnValue('111Ko')

const setup = ({
file = mockFile01,
multiple = false,
fileTypesAccepted = { file: true, folder: false }
}) => {
return render(
<DemoProvider>
<FilePickerBodyItem
file={file}
fileTypesAccepted={fileTypesAccepted}
multiple={multiple}
handleChoiceClick={mockHandleChoiceClick}
handleListItemClick={mockHandleListItemClick}
filesIdsSelected={[]}
hasDivider={false}
/>
</DemoProvider>
)
}

afterEach(() => {
jest.clearAllMocks()
})

it('should be rendered correctly', () => {
const { container } = setup({})

expect(container).toBeDefined()
})

it('should display filename', () => {
const { getByText } = setup({})

expect(getByText('Filename'))
})

it('should display foldername', () => {
const { getByText } = setup({ file: mockFolder01 })

expect(getByText('Foldername'))
})

describe('Functions called', () => {
it('should call "handleChoiceClick" function when click on checkbox/radio area', () => {
const { getByTestId } = setup({})
fireEvent.click(getByTestId('choice-onclick'))

expect(mockHandleChoiceClick).toHaveBeenCalled()
})

it('should NOT call "handleChoiceClick" function when click on checkbox/radio area, if is Folder & not accepted', () => {
const { getByTestId } = setup({ file: mockFolder01 })
fireEvent.click(getByTestId('choice-onclick'))

expect(mockHandleChoiceClick).not.toHaveBeenCalled()
})
it('should NOT call "handleChoiceClick" function when click on checkbox/radio area, if is File & not accepted', () => {
const { getByTestId } = setup({
fileTypesAccepted: { file: false, folder: true }
})
fireEvent.click(getByTestId('choice-onclick'))

expect(mockHandleChoiceClick).not.toHaveBeenCalled()
})

it('should call "handleListItemClick" function when click on ListItem node', () => {
const { getByTestId } = setup({})
fireEvent.click(getByTestId('listitem-onclick'))

expect(mockHandleListItemClick).toHaveBeenCalled()
})
})

describe('Attribute "multiple"', () => {
it('should radio button exists if "multiple" atribute is False', () => {
const { getByTestId } = setup({})
const radioBtn = getByTestId('radio-btn')
expect(radioBtn).not.toBeNull()
})

it('should checkbox button exists if "multiple" atribute is True', () => {
const { getByTestId } = setup({ multiple: true })
const checkboxBtn = getByTestId('checkbox-btn')
expect(checkboxBtn).not.toBeNull()
})
})

describe('Radio/Checkbox button', () => {
it('should disable and not display the Radio button if it is a File and is not accepted', () => {
const { getByTestId } = setup({
fileTypesAccepted: { file: false }
})
const radioBtn = getByTestId('radio-btn')

expect(radioBtn.getAttribute('disabled')).toBe('')
})
it('should disable and not display the Radio button if it is a Folder and is not accepted', () => {
const { getByTestId } = setup({ file: mockFolder01 })
const radioBtn = getByTestId('radio-btn')

expect(radioBtn.getAttribute('disabled')).toBe('')
})
})
})
Loading

0 comments on commit aeca351

Please sign in to comment.