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 6, 2022
1 parent be4d626 commit 2079362
Show file tree
Hide file tree
Showing 15 changed files with 876 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
90 changes: 90 additions & 0 deletions react/FilePicker/FilePickerBodyItem.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from 'react'
import PropTypes from 'prop-types'
import cx from 'classnames'

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'

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

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

const Input = multiple ? Checkbox : Radio

return (
<>
<ListItem button className={cx('u-p-0')}>
<div
data-testid="choice-onclick"
className={cx('u-ph-half')}
onClick={hasChoice ? handleChoiceClick(file) : undefined}
>
<Input
data-testid={multiple ? 'checkbox-btn' : 'radio-btn'}
onChange={() => {
// handled by onClick on the container
}}
checked={filesIdsSelected.includes(file._id)}
value={file._id}
className={cx('u-ph-half', {
'u-o-100': hasChoice,
'u-o-0': !hasChoice
})}
disabled={!hasChoice}
/>
</div>
<div
data-testid="listitem-onclick"
className={'u-flex u-flex-items-center u-w-100'}
onClick={handleListItemClick(file)}
>
<ListItemIcon>
<Icon
icon={isDirectory(file) ? FileTypeFolder : FileTypeText}
width="32"
height="32"
/>
</ListItemIcon>
<ListItemText primary={file.name} />
</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
127 changes: 127 additions & 0 deletions react/FilePicker/FilePickerBodyItem.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
'use strict'
import React from 'react'
import { render, fireEvent } from '@testing-library/react'

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

const mockFile01 = { _id: '001', type: 'file', name: 'Filename' }
const mockFolder01 = { _id: '002', type: 'directory', name: 'Foldername' }

describe('FilePickerBodyItem components:', () => {
const mockHandleChoiceClick = jest.fn()
const mockHandleListItemClick = jest.fn()
const mockOnCheck = jest.fn()

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}
onCheck={mockOnCheck}
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()
})

it('should NOT call "onCheck" function when click on Checkbox button', () => {
const { getByTestId } = setup({ multiple: true })
fireEvent.click(getByTestId('checkbox-btn'))

expect(mockOnCheck).not.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('')
})
})
})
52 changes: 52 additions & 0 deletions react/FilePicker/FilePickerBreadcrumb.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React, { Fragment, useCallback } from 'react'
import PropTypes from 'prop-types'

import Typography from '../Typography'
import Icon from '../Icon'
import RightIcon from '../Icons/Right'
import useBreakpoints from '../hooks/useBreakpoints'

const previousColorStyle = { color: 'var(--actionColorActive)' }

const FilePickerBreadcrumb = ({ path, onBreadcrumbClick }) => {
const { isMobile } = useBreakpoints()
const hasPath = path && path.length > 0

const navigateTo = useCallback(folder => () => onBreadcrumbClick(folder), [
onBreadcrumbClick
])

return (
<Typography variant="h4" className={'u-flex u-flex-items-center'}>
{hasPath
? isMobile
? path[path.length - 1].name
: path.map((folder, index) => {
if (index < path.length - 1) {
return (
<Fragment key={index}>
<span
className={'u-c-pointer'}
style={previousColorStyle}
onClick={navigateTo(folder)}
>
{folder.name}
</span>
<Icon icon={RightIcon} style={previousColorStyle} />
</Fragment>
)
} else {
return <span key={index}>{folder.name}</span>
}
})
: null}
</Typography>
)
}

FilePickerBreadcrumb.propTypes = {
path: PropTypes.array,
onBreadcrumbClick: PropTypes.func
}

export default FilePickerBreadcrumb
Loading

0 comments on commit 2079362

Please sign in to comment.