@bigfishtv/cockpit
Version:
414 lines (371 loc) • 13.2 kB
JavaScript
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import isEqual from 'lodash/isEqual'
import { connect } from 'react-redux'
import Tree from '../tree/Tree'
import Button from '../button/Button'
import Panel from '../container/panel/Panel'
import AssetFolderEditModal from '../asset/AssetFolderEditModal'
import AssetFoldersToolbar from '../asset/AssetFoldersToolbar'
import AssetFolderTreeCell from './AssetFolderTreeCell'
import * as DragTypes from '../../constants/DragTypes'
import newId from '../../utils/newId'
import { modalHandler } from '../modal/ModalHost'
import { get, post } from '../../api/xhrUtils'
import { userCanAccess } from '../../utils/roleUtils'
import { showPrompt } from '../../utils/promptUtils'
import {
pruneTree,
collectValues,
getChildByKeyValue,
getParentByChildId,
setKeyValueById,
sortByKey,
appendChildToParent,
collectChildrenKeyValues,
} from '../../utils/treeUtils'
// return only the folders (and their subfolders) that user has permission to access
function filterAssetFolders(data, user) {
return data.reduce((acc, folder) => {
if (userCanAccess([{ model: 'Tank.AssetFolders', foreign_key: folder.id }], user)) {
acc.push(folder)
} else if (folder.children && folder.children.length) {
acc = acc.concat(filterAssetFolders(folder.children, user))
}
return acc
}, [])
}
const unsortedTreeItem = {
id: null,
parent_id: null,
locked: true,
title: '[Unsorted]',
children: [],
uid: '_unsorted_',
}
/**
* Displays folder tree corresponding to tank asset folders
* Has functionality for creating, deleting and editing asset folders
* Doesn't have functionality for modifying assets, only callback props
*/
export default class AssetFinderSidebarTree extends Component {
static propTypes = {
/** Called when a folder is selected */
onFolderChange: PropTypes.func,
/** Optional callback for overriding adding new folder functionality */
onFolderAdd: PropTypes.func,
/** Optional callback for overriding folder edit functionality */
onEdit: PropTypes.func,
/** Called when assets are dragged into a different folder */
onMoveAssetsToFolder: PropTypes.func,
/** Asset folder id to be automatically selected on mount */
defaultSelectedId: PropTypes.number,
folderGetUrl: PropTypes.string,
folderAddUrl: PropTypes.string,
folderUpdateUrl: PropTypes.string,
folderDeleteUrl: PropTypes.string,
showUnsortedFolder: PropTypes.bool,
}
static defaultProps = {
onFolderChange: () => console.warn('[MediaSidebarTree] no onFolderChange prop'),
onFolderAdd: () => console.warn('[MediaSidebarTree] no onFolderAdd prop'),
onEdit: () => console.warn('[MediaSidebarTree] no onEdit prop'),
onMoveAssetsToFolder: () => console.warn('[MediaSidebarTree] no onMoveAssetsToFolder prop'),
showUnsortedFolder: true,
defaultSelectedId: null,
folderGetUrl: null,
folderAddUrl: null,
folderUpdateUrl: null,
folderDeleteUrl: null,
}
constructor(props) {
super()
this.state = {
data: [],
selectedId: props.defaultSelectedId,
selectedIds: props.defaultSelectedId ? [props.defaultSelectedId] : [],
lockedIds: [],
collapsedIds: localStorage.assetFinderCollapsedIds ? JSON.parse(localStorage.assetFinderCollapsedIds) : [],
allCollapsed: true,
}
}
componentDidUpdate() {
localStorage.assetFinderCollapsedIds = JSON.stringify(this.state.collapsedIds)
}
componentDidMount() {
const callback = returnedData => {
const data = sortByKey(filterAssetFolders(returnedData, this.props.viewer), 'title')
const lockedIds = collectValues(returnedData, 'id', item => item.locked)
let collapsedIds = collectValues(returnedData, 'id', item => item.children && item.children.length > 0)
if (localStorage.assetFinderCollapsedIds) {
const savedCollapsedIds = JSON.parse(localStorage.assetFinderCollapsedIds)
collapsedIds = collapsedIds.filter(id => savedCollapsedIds.indexOf(id) >= 0)
}
this.setState({ data, lockedIds, collapsedIds }, () => {
const { data, selectedId } = this.state
const folderId = selectedId === null ? (data.length ? data[0].id : null) : selectedId
const selectedFolder = getChildByKeyValue(data, 'id', folderId)
if (folderId !== selectedId) this.setState({ selectedIds: [folderId] })
this.props.onFolderChange(selectedFolder)
})
}
// first we trial the folderGetUrl, but if we get
// an error (Tank < 3.0.4) then we use our fallback url
// This is a hack so we don't need to add a breaking change
// to Cockpit just yet. We should remove this hack in the next
// major version or add some kind of detection for Tank version
const fallbackUrl = '/tank/asset_folders.json'
get({
url: this.props.folderGetUrl,
callback,
quietError: true,
callbackError: () => {
get({
url: fallbackUrl,
callback,
})
},
})
}
getSubmitUrl(data) {
return data.id ? this.props.folderUpdateUrl + '/' + data.id + '.json' : this.props.folderAddUrl
}
// when tree selection changes, will only ever be one long in this instance of tree
handleSelectionChange = selectedIds => {
if (!this.props.showUnsortedFolder && !selectedIds.length) return
const selectedId = selectedIds.length ? selectedIds[0] : null
this.setState({ selectedId, selectedIds })
const item = selectedId ? getChildByKeyValue(this.state.data, 'id', selectedId) : null
this.props.onFolderChange(item)
}
// when tree collapse occurs
handleCollapseChange = collapsedIds => {
const collapsableIds = collectValues(this.state.data, 'id', item => item.children && item.children.length > 0)
const allCollapsed = isEqual(collapsableIds.sort(), collapsedIds.sort())
this.setState({ collapsedIds, allCollapsed })
}
// called from toolbar button
handleCollapseAll = () => {
const collapsedIds = collectValues(this.state.data, 'id', item => item.children && item.children.length > 0)
this.setState({ collapsedIds, allCollapsed: true })
}
// called from toolbar button
handleExpandAll = () => {
this.setState({ collapsedIds: [], allCollapsed: false })
}
// called from controlled tree when it wants to update multiple state variables without a rerender
handleCombinationChange = mixed => {
const newState = {}
Object.keys(mixed).map(key => {
if (key in this.state) newState[key] = mixed[key]
})
this.setState(newState)
}
// called from Tray button
handleFolderAdd = () => {
this.props.onFolderAdd()
const { selectedIds } = this.state
modalHandler.add({
Component: AssetFolderEditModal,
props: {
onSave: this.handleFolderAddSave,
onClose: () => {},
selectedIds,
parentId: selectedIds.length ? selectedIds[0] : null,
data: this.state.data,
},
})
}
// called from AssetFolderEditModal upon folder creation
handleFolderAddSave = ({ folderId, folderName, parentId }) => {
const isNew = !folderId ? true : false
const tempId = newId()
const item = {
id: isNew ? tempId : folderId,
title: folderName,
children: null,
}
if (parentId == -1) parentId = null
const data = sortByKey(appendChildToParent(this.state.data, parentId, item), 'title')
// this.setState({data});
const changes = { id: folderId, title: folderName, parent_id: parentId }
post({
url: this.getSubmitUrl({ id: isNew ? folderId : false }),
data: changes,
callback: returnedData => {
const createdId = returnedData.id
const data2 = sortByKey(setKeyValueById(data, tempId, 'id', createdId), 'title')
this.setState({ data: data2 }, () => {
this.props.onFolderChange(returnedData)
})
},
})
}
// called from Tray button
handleFolderEdit = () => {
const { data, selectedId } = this.state
if (selectedId === null) return
const item = getChildByKeyValue(data, 'id', selectedId)
const parent = getParentByChildId(selectedId, data)
this.props.onEdit()
modalHandler.add({
Component: AssetFolderEditModal,
props: {
onSave: this.handleFolderEditSave,
onClose: () => {},
title: 'Edit Folder',
primaryActionText: 'Save',
folderName: item.title,
data,
parentId: parent.id,
folderId: item.id,
selectedIds: [parent.id],
},
})
}
// called from AssetFolderEditModal upon folder edit
handleFolderEditSave = ({ folderId, folderName, parentId }) => {
const item = getChildByKeyValue(this.state.data, 'id', folderId)
item.title = folderName
item.parent_id = parentId
const data = sortByKey(
appendChildToParent(
// current tree minus id of updated
pruneTree(this.state.data, 'id', [folderId]),
parentId,
item // item to be updated
),
'title'
)
const changes = { id: folderId, title: folderName }
if (parentId === null) changes.parent = null
else changes.parent_id = parentId
post({
url: this.getSubmitUrl(changes),
data: changes,
callback: () => this.setState({ data }),
})
}
// called from Tray button
handleFolderDelete = () => {
const { data, selectedId } = this.state
if (selectedId === null) {
console.warn('No folder selected for delete!')
return
}
const selectedFolder = getChildByKeyValue(data, 'id', selectedId)
const folderTitles = [selectedFolder.title, ...collectChildrenKeyValues(data, selectedId, 'title')]
showPrompt({
title: 'Delete Folder',
message: (
<div>
<p>Deleting a folder affects assets within a folder. This includes assets inside the following folders:</p>
<ul>
{folderTitles.map((title, i) => (
<li key={i}>{title}</li>
))}
</ul>
</div>
),
Buttons: props => <Button text="Delete All" style="error" onClick={() => props.callback('delete')} />,
callback: () => this.handleFolderDeleteSave(selectedId),
})
}
handleFolderDeleteSave(folderId) {
// optimistically remove folders locally
const data = sortByKey(pruneTree(this.state.data, 'id', [folderId]), 'title')
this.setState({ data, selectedIds: [], selectedId: null })
post({
url: this.props.folderDeleteUrl,
data: [folderId],
callback: () => {},
})
}
canDeleteFolder(folderId) {
const folder = getChildByKeyValue(this.state.data, 'id', folderId)
return folder && 'parent_id' in folder ? true : false
}
canEditFolder(folderId) {
const folder = getChildByKeyValue(this.state.data, 'id', folderId)
return folder && 'parent_id' in folder ? true : false
}
render() {
const { data, collapsedIds, selectedIds, selectedId, allCollapsed } = this.state
const toolProps = {
editable: selectedIds.length ? this.canEditFolder(selectedIds[0]) : false,
removable: selectedIds.length ? this.canDeleteFolder(selectedIds[0]) : false,
onCollapseAll: this.handleCollapseAll,
onExpandAll: this.handleExpandAll,
onCreate: this.handleFolderAdd,
onRemove: this.handleFolderDelete,
onEdit: this.handleFolderEdit,
currentFolder: selectedId !== null ? getChildByKeyValue(data, 'id', selectedId) : null,
allCollapsed,
}
return (
<Panel title="Folders" PanelToolbar={AssetFoldersToolbar} {...toolProps}>
<Tree
value={this.props.showUnsortedFolder ? [unsortedTreeItem, ...this.state.data] : this.state.data}
TreeCell={AssetFolderTreeCell}
multiselect={false}
reorderable={false}
stickySelect={this.props.showUnsortedFolder ? false : true}
dropTargetType={DragTypes.ASSET_CELL}
treeItemTarget={treeItemTarget(this)}
treeItemSource={treeItemSource(this)}
onSelectItem={this.handleFolderEdit}
selectedIds={selectedIds}
collapsedIds={collapsedIds}
onSelectionChange={this.handleSelectionChange}
onCollapseChange={this.handleCollapseChange}
onCombinationChange={this.handleCombinationChange}
/>
</Panel>
)
}
}
// drag source & target configs for allowing asset cells to be dropped into folders
let expandTimeout = null
const treeItemSource = () => ({
beginDrag(props) {
return { id: props.id }
},
})
const treeItemTarget = parent => ({
drop(props, monitor) {
// goes through all drop targets and resets their forceExpand state so they don't randomly open upon drag if hover expanded
for (let key in monitor.internalMonitor.registry.handlers) {
if (key.charAt(0) == 'T') {
const item = monitor.internalMonitor.registry.handlers[key].component
if (item.state && item.state.forceExpand) {
item.props.onCollapse()
item.setState({ forceExpand: false })
}
}
}
if (monitor.isOver({ shallow: true })) {
if (!props.selected) {
const assetId = monitor.getItem().id
const folderId = props.id
parent.props.onMoveAssetsToFolder(folderId, [assetId])
}
}
},
hover(props, monitor, component) {
const isOverCurrent = monitor.isOver({ shallow: true })
if (isOverCurrent) {
const ownId = props.id
const draggedId = monitor.getItem().id
if (draggedId === ownId) return
// hover expand timeout
if (component.props.collapsed && component.state.position != 'into') {
if (expandTimeout !== null) clearTimeout(expandTimeout)
expandTimeout = setTimeout(() => {
component.setState({ forceExpand: true })
}, 1000)
}
component.setState({ position: 'into' })
}
},
})