UNPKG

@bigfishtv/cockpit

Version:

414 lines (371 loc) 13.2 kB
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 */ @connect(({ viewer }) => ({ viewer })) 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' }) } }, })