@alyzenpublic/react-keyed-file-browser
Version:
Folder based file browser given a flat keyed list of objects, powered by React.
859 lines (779 loc) • 24.6 kB
JavaScript
import PropTypes from 'prop-types'
import React from 'react'
// drag and drop
import HTML5Backend from 'react-dnd-html5-backend'
import { DragDropContext } from 'react-dnd'
// default components (most overridable)
import { DefaultDetail } from './details'
import { DefaultFilter } from './filters'
// default renderers
import { TableHeader } from './headers'
import { TableFile } from './files'
import { TableFolder } from './folders'
// default processors
import { GroupByFolder } from './groupers'
import { SortByName } from './sorters'
const SEARCH_RESULTS_PER_PAGE = 20
function getItemProps(file, browserProps) {
return {
key: `file-${file.key}`,
fileKey: file.key,
isSelected: (file.key === browserProps.selection),
isOpen: file.key in browserProps.openFolders || browserProps.nameFilter,
isRenaming: browserProps.activeAction === 'rename' && browserProps.actionTarget === file.key,
isDeleting: browserProps.activeAction === 'delete' && browserProps.actionTarget === file.key,
isDraft: !!file.draft,
}
}
class RawFileBrowser extends React.Component {
static propTypes = {
files: PropTypes.array.isRequired,
actions: PropTypes.node,
showActionBar: PropTypes.bool.isRequired,
canFilter: PropTypes.bool.isRequired,
allowFolderDownloads: PropTypes.bool.isRequired,
noFilesMessage: PropTypes.string,
group: PropTypes.func.isRequired,
sort: PropTypes.func.isRequired,
icons: PropTypes.shape({
Folder: PropTypes.element,
FolderOpen: PropTypes.element,
File: PropTypes.element,
PDF: PropTypes.element,
Image: PropTypes.element,
Delete: PropTypes.element,
Rename: PropTypes.element,
Loading: PropTypes.element,
Download: PropTypes.element,
}),
nestChildren: PropTypes.bool.isRequired,
renderStyle: PropTypes.oneOf([
'list',
'table',
]).isRequired,
startOpen: PropTypes.bool.isRequired, // TODO: remove?
headerRenderer: PropTypes.func,
headerRendererProps: PropTypes.object,
filterRenderer: PropTypes.func,
filterRendererProps: PropTypes.object,
fileRenderer: PropTypes.func,
fileRendererProps: PropTypes.object,
folderRenderer: PropTypes.func,
folderRendererProps: PropTypes.object,
detailRenderer: PropTypes.func,
detailRendererProps: PropTypes.object,
onCreateFiles: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
onCreateFolder: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
onMoveFile: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
onMoveFolder: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
onRenameFile: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
onRenameFolder: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
onDeleteFile: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
onDeleteFolder: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
onDownloadFile: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
onSelect: PropTypes.func,
onSelectFile: PropTypes.func,
onSelectFolder: PropTypes.func,
onPreviewOpen: PropTypes.func,
onPreviewClose: PropTypes.func,
onFolderOpen: PropTypes.func,
onFolderClose: PropTypes.func,
}
static defaultProps = {
showActionBar: true,
canFilter: true,
allowFolderDownloads: false,
noFilesMessage: 'No files.',
group: GroupByFolder,
sort: SortByName,
nestChildren: false,
renderStyle: 'table',
startOpen: false,
headerRenderer: TableHeader,
headerRendererProps: {},
filterRenderer: DefaultFilter,
filterRendererProps: {},
fileRenderer: TableFile,
fileRendererProps: {},
folderRenderer: TableFolder,
folderRendererProps: {},
detailRenderer: DefaultDetail,
detailRendererProps: {},
icons: {},
onSelect: (fileOrFolder) => {}, // Always called when a file or folder is selected
onSelectFile: (file) => {}, // Called after onSelect, only on file selection
onSelectFolder: (folder) => {}, // Called after onSelect, only on folder selection
onPreviewOpen: (file) => {}, // File opened
onPreviewClose: (file) => {}, // File closed
onFolderOpen: (folder) => {}, // Folder opened
onFolderClose: (folder) => {}, // Folder closed
}
state = {
openFolders: {},
selection: null,
activeAction: null,
actionTarget: null,
nameFilter: '',
searchResultsShown: SEARCH_RESULTS_PER_PAGE,
previewFile: null,
addFolder: null,
}
componentDidMount() {
if (this.props.renderStyle === 'table' && this.props.nestChildren) {
console.warn('Invalid settings: Cannot nest table children in file browser')
}
window.addEventListener('click', this.handleGlobalClick)
}
componentWillUnmount() {
window.removeEventListener('click', this.handleGlobalClick)
}
getFile = (key) => this.props.files.find(f => f.key === key)
// item manipulation
createFiles = (files, prefix) => {
this.setState(prevState => {
const stateChanges = { selection: null }
if (prefix) {
stateChanges.openFolders = {
...prevState.openFolders,
[prefix]: true,
}
}
return stateChanges
}, () => {
this.props.onCreateFiles(files, prefix)
})
}
createFolder = (key) => {
this.setState({
activeAction: null,
actionTarget: null,
selection: key,
}, this.props.onCreateFolder(key))
}
moveFile = (oldKey, newKey) => {
this.setState({
activeAction: null,
actionTarget: null,
selection: newKey,
}, () => {
this.props.onMoveFile(oldKey, newKey)
})
}
moveFolder = (oldKey, newKey) => {
this.setState(prevState => {
const stateChanges = {
activeAction: null,
actionTarget: null,
selection: newKey,
}
if (oldKey in prevState.openFolders) {
stateChanges.openFolders = {
...prevState.openFolders,
[newKey]: true,
}
}
return stateChanges
}, () => {
this.props.onMoveFolder(oldKey, newKey)
})
}
renameFile = (oldKey, newKey) => {
this.setState({
activeAction: null,
actionTarget: null,
selection: newKey,
}, () => {
this.props.onRenameFile(oldKey, newKey)
})
}
renameFolder = (oldKey, newKey) => {
this.setState(prevState => {
const stateChanges = {
activeAction: null,
actionTarget: null,
}
if (prevState.selection.substr(0, oldKey.length) === oldKey) {
stateChanges.selection = prevState.selection.replace(oldKey, newKey)
}
if (oldKey in prevState.openFolders) {
stateChanges.openFolders = {
...prevState.openFolders,
[newKey]: true,
}
}
return stateChanges
}, () => {
this.props.onRenameFolder(oldKey, newKey)
})
}
deleteFile = (key) => {
this.setState({
activeAction: null,
actionTarget: null,
selection: null,
}, () => {
this.props.onDeleteFile(key)
})
}
deleteFolder = (key) => {
this.setState(prevState => {
const stateChanges = {
activeAction: null,
actionTarget: null,
selection: null,
}
if (key in prevState.openFolders) {
stateChanges.openFolders = { ...prevState.openFolders }
delete stateChanges.openFolders[key]
}
return stateChanges
}, () => {
this.props.onDeleteFolder(key)
})
}
downloadFile = (key) => {
this.setState({
activeAction: null,
actionTarget: null,
}, () => {
this.props.onDownloadFile(key)
})
}
// browser manipulation
beginAction = (action, key) => {
this.setState({
activeAction: action,
actionTarget: key,
})
}
endAction = () => {
if (this.state.selection !== null && this.state.selection.indexOf('__new__') !== -1) {
this.setState({ selection: null })
}
this.beginAction(null, null)
}
select = (key, selectedType) => {
const { actionTarget } = this.state
const shouldClearState = actionTarget !== null && actionTarget !== key
const selected = this.getFile(key)
this.setState(prevState => ({
selection: key,
actionTarget: shouldClearState ? null : actionTarget,
activeAction: shouldClearState ? null : prevState.activeAction,
}), () => {
this.props.onSelect(selected)
if (selectedType === 'file') this.props.onSelectFile(selected)
if (selectedType === 'folder') this.props.onSelectFolder(selected)
})
}
preview = (file) => {
if (this.state.previewFile && this.state.previewFile.key !== file.key) this.closeDetail()
this.setState({
previewFile: file,
}, () => {
this.props.onPreviewOpen(file)
})
}
closeDetail = () => {
this.setState({
previewFile: null,
}, () => {
this.props.onPreviewClose(this.state.previewFile)
})
}
handleShowMoreClick = (event) => {
event.preventDefault()
this.setState(prevState => ({
searchResultsShown: prevState.searchResultsShown + SEARCH_RESULTS_PER_PAGE,
}))
}
toggleFolder = (folderKey) => {
const isOpen = folderKey in this.state.openFolders
this.setState(prevState => {
const stateChanges = {
openFolders: { ...prevState.openFolders },
}
if (isOpen) {
delete stateChanges.openFolders[folderKey]
} else {
stateChanges.openFolders[folderKey] = true
}
return stateChanges
}, () => {
const callback = isOpen ? 'onFolderClose' : 'onFolderOpen'
this.props[callback](this.getFile(folderKey))
})
}
openFolder = (folderKey) => {
this.setState(prevState => ({
openFolders: {
...prevState.openFolders,
[folderKey]: true,
},
}), () => {
this.props.onFolderOpen(this.getFile(folderKey))
})
}
// event handlers
handleGlobalClick = (event) => {
const inBrowser = !!(this.browserRef && this.browserRef.contains(event.target))
// TODO: updated old-to-new ref styles, but this ref was never set
const inPreview = !!(this.previewRef && this.previewRef.contains(event.target))
if (!inBrowser && !inPreview) {
this.setState({
selection: null,
actionTarget: null,
activeAction: null,
})
}
}
handleActionBarRenameClick = (event) => {
event.preventDefault()
this.beginAction('rename', this.state.selection)
}
handleActionBarDeleteClick = (event) => {
event.preventDefault()
this.beginAction('delete', this.state.selection)
}
handleActionBarAddFolderClick = (event) => {
event.preventDefault()
if (this.state.activeAction === 'createFolder') {
return
}
this.setState(prevState => {
let addKey = ''
if (prevState.selection) {
addKey += prevState.selection
if (addKey.substr(addKey.length - 1, addKey.length) !== '/') {
addKey += '/'
}
}
addKey += '__new__/'
const stateChanges = {
actionTarget: addKey,
activeAction: 'createFolder',
selection: addKey,
}
if (prevState.selection) {
stateChanges.openFolders = {
...prevState.openFolders,
[this.state.selection]: true,
}
}
return stateChanges
})
}
handleActionBarDownloadClick = (event) => {
event.preventDefault()
this.downloadFile(this.state.selection)
}
updateFilter = (newValue) => {
this.setState({
nameFilter: newValue,
searchResultsShown: SEARCH_RESULTS_PER_PAGE,
})
}
getBrowserProps() {
return {
// browser config
nestChildren: this.props.nestChildren,
fileRenderer: this.props.fileRenderer,
fileRendererProps: this.props.fileRendererProps,
folderRenderer: this.props.folderRenderer,
folderRendererProps: this.props.folderRendererProps,
icons: this.props.icons,
// browser state
openFolders: this.state.openFolders,
nameFilter: this.state.nameFilter,
selection: this.state.selection,
activeAction: this.state.activeAction,
actionTarget: this.state.actionTarget,
// browser manipulation
select: this.select,
openFolder: this.openFolder,
toggleFolder: this.toggleFolder,
beginAction: this.beginAction,
endAction: this.endAction,
preview: this.preview,
// item manipulation
createFiles: this.props.onCreateFiles ? this.createFiles : undefined,
createFolder: this.props.onCreateFolder ? this.createFolder : undefined,
renameFile: this.props.onRenameFile ? this.renameFile : undefined,
renameFolder: this.props.onRenameFolder ? this.renameFolder : undefined,
moveFile: this.props.onMoveFile ? this.moveFile : undefined,
moveFolder: this.props.onMoveFolder ? this.moveFolder : undefined,
deleteFile: this.props.onDeleteFile ? this.deleteFile : undefined,
deleteFolder: this.props.onDeleteFolder ? this.deleteFolder : undefined,
getItemProps: getItemProps,
}
}
renderActionBar(selectedItem) {
const {
icons, canFilter, allowFolderDownloads, filterRendererProps,
filterRenderer: FilterRenderer, onCreateFolder,
onRenameFile, onRenameFolder, onDeleteFile, onDeleteFolder, onDownloadFile,
} = this.props
const selectionIsFolder = (selectedItem && !selectedItem.size)
let filter
if (canFilter) {
filter = (
<FilterRenderer
value={this.state.nameFilter}
updateFilter={this.updateFilter}
{...filterRendererProps}
/>
)
}
let actions
if (selectedItem) {
// Something is selected. Build custom actions depending on what it is.
if (selectedItem.action) {
// Selected item has an active action against it. Disable all other actions.
let actionText
switch (selectedItem.action) {
case 'delete':
actionText = 'Deleting ...'
break
case 'rename':
actionText = 'Renaming ...'
break
default:
actionText = 'Moving ...'
break
}
actions = (
// TODO: Enable plugging in custom spinner.
<div className="item-actions">
{icons.Loading} {actionText}
</div>
)
} else {
actions = []
if (
selectionIsFolder &&
typeof onCreateFolder === 'function' &&
!this.state.nameFilter
) {
actions.push(
<li key="action-add-folder">
<a
onClick={this.handleActionBarAddFolderClick}
href="#"
role="button"
>
{icons.Folder}
Add Subfolder
</a>
</li>
)
}
if (
selectedItem.keyDerived && (
(!selectionIsFolder && typeof onRenameFile === 'function') ||
(selectionIsFolder && typeof onRenameFolder === 'function')
)
) {
actions.push(
<li key="action-rename">
<a
onClick={this.handleActionBarRenameClick}
href="#"
role="button"
>
{icons.Rename}
Rename
</a>
</li>
)
}
if (
selectedItem.keyDerived && (
(!selectionIsFolder && typeof onDeleteFile === 'function') ||
(selectionIsFolder && typeof onDeleteFolder === 'function')
)
) {
actions.push(
<li key="action-delete">
<a
onClick={this.handleActionBarDeleteClick}
href="#"
role="button"
>
{icons.Delete}
Delete
</a>
</li>
)
}
if ((!selectionIsFolder || allowFolderDownloads) && typeof onDownloadFile === 'function') {
actions.push(
<li key="action-download">
<a
onClick={this.handleActionBarDownloadClick}
href="#"
role="button"
>
{icons.Download}
Download
</a>
</li>
)
}
if (actions.length) {
actions = (<ul className="item-actions">{actions}</ul>)
} else {
actions = (<div className="item-actions"> </div>)
}
}
} else {
// Nothing selected: We're in the 'root' folder. Only allowed action is adding a folder.
actions = []
if (
typeof onCreateFolder === 'function' &&
!this.state.nameFilter
) {
actions.push(
<li key="action-add-folder">
<a
onClick={this.handleActionBarAddFolderClick}
href="#"
role="button"
>
{icons.Folder}
Add Folder
</a>
</li>
)
}
if (actions.length) {
actions = (<ul className="item-actions">{actions}</ul>)
} else {
actions = (<div className="item-actions"> </div>)
}
}
return (
<div className="action-bar">
{filter}
{actions}
</div>
)
}
renderFiles(files, depth) {
const {
fileRenderer: FileRenderer, fileRendererProps,
folderRenderer: FolderRenderer, folderRendererProps,
} = this.props
const browserProps = this.getBrowserProps()
let renderedFiles = []
files.map((file) => {
const thisItemProps = {
...browserProps.getItemProps(file, browserProps),
depth: this.state.nameFilter ? 0 : depth,
}
if (file.size) {
renderedFiles.push(
<FileRenderer
{...file}
{...thisItemProps}
browserProps={browserProps}
{...fileRendererProps}
/>
)
} else {
if (!this.state.nameFilter) {
renderedFiles.push(
<FolderRenderer
{...file}
{...thisItemProps}
browserProps={browserProps}
{...folderRendererProps}
/>
)
}
if (this.state.nameFilter || (thisItemProps.isOpen && !browserProps.nestChildren)) {
renderedFiles = renderedFiles.concat(this.renderFiles(file.children, depth + 1))
}
}
})
return renderedFiles
}
render() {
const browserProps = this.getBrowserProps()
const headerProps = {
browserProps,
fileKey: '',
fileCount: this.props.files.length,
}
let renderedFiles
let files = this.props.files.concat([])
if (this.state.activeAction === 'createFolder') {
files.push({
key: this.state.actionTarget,
size: 0,
draft: true,
})
}
if (this.state.nameFilter) {
const filteredFiles = []
const terms = this.state.nameFilter.toLowerCase().split(' ')
files.map((file) => {
let skip = false
terms.map((term) => {
if (file.key.toLowerCase().trim().indexOf(term) === -1) {
skip = true
}
})
if (skip) {
return
}
filteredFiles.push(file)
})
files = filteredFiles
}
if (typeof this.props.group === 'function') {
files = this.props.group(files, '')
} else {
const newFiles = []
files.map((file) => {
if (file.size) {
newFiles.push(file)
}
})
files = newFiles
}
let selectedItem = null
const findSelected = (item) => {
if (item.key === this.state.selection) {
selectedItem = item
}
if (item.children) {
item.children.map(findSelected)
}
}
files.map(findSelected)
if (typeof this.props.sort === 'function') {
files = this.props.sort(files)
}
let header
let contents = this.renderFiles(files, 0)
switch (this.props.renderStyle) {
case 'table':
if (!contents.length) {
if (this.state.nameFilter) {
contents = (<tr>
<td colSpan="100">
No files matching "{this.state.nameFilter}".
</td>
</tr>)
} else {
contents = (<tr>
<td colSpan="100">
{this.props.noFilesMessage}
</td>
</tr>)
}
} else {
if (this.state.nameFilter) {
const numFiles = contents.length
contents = contents.slice(0, this.state.searchResultsShown)
if (numFiles > contents.length) {
contents.push(<tr key="show-more">
<td colSpan="100">
<a
onClick={this.handleShowMoreClick}
href="#"
>
Show more results
</a>
</td>
</tr>)
}
}
}
if (this.props.headerRenderer) {
header = (
<thead>
<this.props.headerRenderer
{...headerProps}
{...this.props.headerRendererProps}
/>
</thead>
)
}
renderedFiles = (
<table cellSpacing="0" cellPadding="0">
{header}
<tbody>
{contents}
</tbody>
</table>
)
break
case 'list':
if (!contents.length) {
if (this.state.nameFilter) {
contents = (<p className="empty">No files matching "{this.state.nameFilter}"</p>)
} else {
contents = (<p className="empty">No files.</p>)
}
} else {
let more
if (this.state.nameFilter) {
const numFiles = contents.length
contents = contents.slice(0, this.state.searchResultsShown)
if (numFiles > contents.length) {
more = (<a
onClick={this.handleShowMoreClick}
href="#"
>
Show more results
</a>)
}
}
contents = (
<div>
<ul>{contents}</ul>
{more}
</div>
)
}
if (this.props.headerRenderer) {
header = (
<this.props.headerRenderer
{...headerProps}
{...this.props.headerRendererProps}
/>
)
}
renderedFiles = (
<div>
{header}
{contents}
</div>
)
break
}
return (
<div className="rendered-react-keyed-file-browser">
{this.props.actions}
<div className="rendered-file-browser" ref={el => { this.browserRef = el }}>
{this.props.showActionBar && this.renderActionBar(selectedItem)}
<div className="files">
{renderedFiles}
</div>
</div>
{this.state.previewFile !== null && (
<this.props.detailRenderer
file={this.state.previewFile}
close={this.closeDetail}
{...this.props.detailRendererProps}
/>
)}
</div>
)
}
}
class FileBrowser extends RawFileBrowser {}
export default FileBrowser
export { RawFileBrowser }