mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #3232 from overleaf/ta-file-tree-react
React File Tree GitOrigin-RevId: fb3141ba8cd9ca0d68e87edb74764a360144c8fe
This commit is contained in:
parent
d5544f0626
commit
420aa4a657
66 changed files with 4210 additions and 24 deletions
|
@ -721,6 +721,8 @@ const ProjectController = {
|
|||
const allowedImageNames = ProjectHelper.getAllowedImagesForUser(
|
||||
sessionUser
|
||||
)
|
||||
const wantsOldFileTreeUI =
|
||||
req.query && req.query.new_file_tree_ui === 'false'
|
||||
AuthorizationManager.getPrivilegeLevelForProject(
|
||||
userId,
|
||||
projectId,
|
||||
|
@ -832,7 +834,8 @@ const ProjectController = {
|
|||
wsUrl,
|
||||
showSupport: Features.hasFeature('support'),
|
||||
showNewLogsUI: req.query && req.query.new_logs_ui === 'true',
|
||||
showNewChatUI: user.betaProgram && !wantsOldChatUI
|
||||
showNewChatUI: user.betaProgram && !wantsOldChatUI,
|
||||
showReactFileTree: user.alphaProgram && !wantsOldFileTreeUI
|
||||
})
|
||||
timer.done()
|
||||
}
|
||||
|
|
|
@ -92,7 +92,10 @@ block content
|
|||
custom-toggler-msg-when-closed=hasFeature('custom-togglers') ? translate("tooltip_show_filetree") : false
|
||||
)
|
||||
.ui-layout-west
|
||||
include ./editor/file-tree
|
||||
if showReactFileTree
|
||||
include ./editor/file-tree-react
|
||||
else
|
||||
include ./editor/file-tree
|
||||
include ./editor/history/fileTreeV2
|
||||
|
||||
.ui-layout-center
|
||||
|
@ -183,6 +186,7 @@ block content
|
|||
//- used in public/js/libs/sharejs.js
|
||||
window.useShareJsHash = true
|
||||
window.wsRetryHandshake = #{settings.wsRetryHandshake}
|
||||
window.showReactFileTree = "!{showReactFileTree}" === 'true'
|
||||
|
||||
- if (settings.overleaf != null)
|
||||
script(type='text/javascript').
|
||||
|
|
38
services/web/app/views/project/editor/file-tree-react.pug
Normal file
38
services/web/app/views/project/editor/file-tree-react.pug
Normal file
|
@ -0,0 +1,38 @@
|
|||
aside.editor-sidebar.full-size(
|
||||
ng-show="ui.view != 'history' || !history.isV2"
|
||||
vertical-resizable-panes="outline-resizer"
|
||||
vertical-resizable-panes-toggled-externally-on="outline-toggled"
|
||||
vertical-resizable-panes-min-size="32"
|
||||
vertical-resizable-panes-max-size="75%"
|
||||
vertical-resizable-panes-resize-on="left-pane-resize-all"
|
||||
)
|
||||
|
||||
.file-tree(
|
||||
ng-controller="ReactFileTreeController"
|
||||
vertical-resizable-top
|
||||
)
|
||||
file-tree-root(
|
||||
project-id="projectId"
|
||||
root-folder="rootFolder"
|
||||
root-doc-id="rootDocId"
|
||||
has-write-permissions="hasWritePermissions"
|
||||
on-select="onSelect"
|
||||
on-init="onInit"
|
||||
)
|
||||
|
||||
div(ng-controller="FileTreeController")
|
||||
include ./new-file-modal
|
||||
|
||||
.outline-container(
|
||||
vertical-resizable-bottom
|
||||
ng-controller="OutlineController"
|
||||
)
|
||||
outline-pane(
|
||||
is-tex-file="isTexFile"
|
||||
outline="outline"
|
||||
project-id="project_id"
|
||||
jump-to-line="jumpToLine"
|
||||
on-toggle="onToggle"
|
||||
event-tracking="eventTracking"
|
||||
highlighted-line="highlightedLine"
|
||||
)
|
|
@ -28,7 +28,7 @@ header.toolbar.toolbar-header.toolbar-with-labels(
|
|||
span(ng-controller="PdfViewToggleController")
|
||||
a.btn.btn-full-height.btn-full-height-no-border(
|
||||
href,
|
||||
ng-show="ui.pdfLayout == 'flat' && fileTreeClosed",
|
||||
ng-show="ui.pdfLayout == 'flat'",
|
||||
tooltip="PDF",
|
||||
tooltip-placement="bottom",
|
||||
tooltip-append-to-body="true",
|
||||
|
|
|
@ -74,5 +74,22 @@
|
|||
"view_warnings",
|
||||
"we_cant_find_any_sections_or_subsections_in_this_file",
|
||||
"your_message",
|
||||
"your_project_has_errors"
|
||||
"your_project_has_errors",
|
||||
"recompile_from_scratch",
|
||||
"run_syntax_check_now",
|
||||
"toggle_compile_options_menu",
|
||||
"sure_you_want_to_delete",
|
||||
"delete",
|
||||
"deleting",
|
||||
"cancel",
|
||||
"new_file",
|
||||
"new_folder",
|
||||
"create",
|
||||
"creating",
|
||||
"upload",
|
||||
"rename",
|
||||
"n_items",
|
||||
"please_refresh",
|
||||
"generic_something_went_wrong",
|
||||
"refresh"
|
||||
]
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
import React, { useContext } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { FileTreeMainContext } from '../contexts/file-tree-main'
|
||||
|
||||
import FileTreeItemMenuItems from './file-tree-item/file-tree-item-menu-items'
|
||||
|
||||
function FileTreeContextMenu() {
|
||||
const {
|
||||
hasWritePermissions,
|
||||
contextMenuCoords,
|
||||
setContextMenuCoords
|
||||
} = useContext(FileTreeMainContext)
|
||||
|
||||
if (!hasWritePermissions || !contextMenuCoords) return null
|
||||
|
||||
function close() {
|
||||
// reset context menu
|
||||
setContextMenuCoords(null)
|
||||
}
|
||||
|
||||
function handleToggle(wantOpen) {
|
||||
if (!wantOpen) close()
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
handleToggle(false)
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<Dropdown
|
||||
onClick={handleClick}
|
||||
open
|
||||
id={'dropdown-file-tree-context-menu'}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
<FakeDropDownToggle bsRole="toggle" />
|
||||
<Dropdown.Menu className="context-menu" style={contextMenuCoords}>
|
||||
<FileTreeItemMenuItems />
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>,
|
||||
document.querySelector('body')
|
||||
)
|
||||
}
|
||||
|
||||
// fake component required as Dropdowns require a Toggle, even tho we don't want
|
||||
// one for the context menu
|
||||
const FakeDropDownToggle = React.forwardRef((props, ref) => {
|
||||
return null
|
||||
})
|
||||
|
||||
export default FileTreeContextMenu
|
|
@ -0,0 +1,55 @@
|
|||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import { FileTreeMainProvider } from '../contexts/file-tree-main'
|
||||
import { FileTreeActionableProvider } from '../contexts/file-tree-actionable'
|
||||
import { FileTreeMutableProvider } from '../contexts/file-tree-mutable'
|
||||
import { FileTreeSelectableProvider } from '../contexts/file-tree-selectable'
|
||||
import { FileTreeDraggableProvider } from '../contexts/file-tree-draggable'
|
||||
|
||||
// renders all the contexts needed for the file tree:
|
||||
// FileTreeMain: generic store
|
||||
// FileTreeActionable: global UI state for actions (rename, delete, etc.)
|
||||
// FileTreeMutable: provides entities mutation operations
|
||||
// FileTreeSelectable: handles selection and multi-selection
|
||||
function FileTreeContext({
|
||||
projectId,
|
||||
rootFolder,
|
||||
hasWritePermissions,
|
||||
initialSelectedEntityId,
|
||||
onSelect,
|
||||
children
|
||||
}) {
|
||||
return (
|
||||
<FileTreeMainProvider
|
||||
projectId={projectId}
|
||||
hasWritePermissions={hasWritePermissions}
|
||||
>
|
||||
<FileTreeActionableProvider hasWritePermissions={hasWritePermissions}>
|
||||
<FileTreeMutableProvider rootFolder={rootFolder}>
|
||||
<FileTreeSelectableProvider
|
||||
hasWritePermissions={hasWritePermissions}
|
||||
initialSelectedEntityId={initialSelectedEntityId}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<FileTreeDraggableProvider>{children}</FileTreeDraggableProvider>
|
||||
</FileTreeSelectableProvider>
|
||||
</FileTreeMutableProvider>
|
||||
</FileTreeActionableProvider>
|
||||
</FileTreeMainProvider>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeContext.propTypes = {
|
||||
projectId: PropTypes.string.isRequired,
|
||||
rootFolder: PropTypes.array.isRequired,
|
||||
hasWritePermissions: PropTypes.bool.isRequired,
|
||||
initialSelectedEntityId: PropTypes.string,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
]).isRequired
|
||||
}
|
||||
|
||||
export default FileTreeContext
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import Icon from '../../../shared/components/icon'
|
||||
import iconTypeFromName from '../util/icon-type-from-name'
|
||||
|
||||
import { useSelectableEntity } from '../contexts/file-tree-selectable'
|
||||
|
||||
import FileTreeItemInner from './file-tree-item/file-tree-item-inner'
|
||||
|
||||
function FileTreeDoc({ name, id, isLinkedFile }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { isSelected, props: selectableEntityProps } = useSelectableEntity(id)
|
||||
|
||||
const icons = (
|
||||
<>
|
||||
<Icon
|
||||
type={iconTypeFromName(name)}
|
||||
modifier="fw"
|
||||
classes={{ icon: 'spaced' }}
|
||||
>
|
||||
{isLinkedFile ? (
|
||||
<Icon
|
||||
type="external-link-square"
|
||||
modifier="rotate-180"
|
||||
classes={{ icon: 'linked-file-highlight' }}
|
||||
accessibilityLabel={t('linked_file')}
|
||||
/>
|
||||
) : null}
|
||||
</Icon>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<li
|
||||
role="treeitem"
|
||||
{...selectableEntityProps}
|
||||
aria-label={name}
|
||||
tabIndex="0"
|
||||
>
|
||||
<FileTreeItemInner
|
||||
id={id}
|
||||
name={name}
|
||||
isSelected={isSelected}
|
||||
icons={icons}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeDoc.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
isLinkedFile: PropTypes.bool
|
||||
}
|
||||
|
||||
export default FileTreeDoc
|
|
@ -0,0 +1,72 @@
|
|||
import React, { useRef } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useDragLayer } from 'react-dnd'
|
||||
import classNames from 'classnames'
|
||||
|
||||
// a custom component rendered on top of a draggable area that renders the
|
||||
// dragged item. See
|
||||
// https://react-dnd.github.io/react-dnd/examples/drag-around/custom-drag-layer
|
||||
// for more details.
|
||||
// Also used to display a container border when hovered.
|
||||
function FileTreeDraggablePreviewLayer({ isOver }) {
|
||||
const { isDragging, item, clientOffset } = useDragLayer(monitor => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
item: monitor.getItem(),
|
||||
clientOffset: monitor.getClientOffset()
|
||||
}))
|
||||
const ref = useRef()
|
||||
|
||||
const containerOffset = ref.current
|
||||
? ref.current.getBoundingClientRect()
|
||||
: null
|
||||
|
||||
if (!isDragging) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames('dnd-draggable-preview-layer', {
|
||||
'dnd-droppable-hover': isOver
|
||||
})}
|
||||
>
|
||||
<div style={getItemStyle(clientOffset, containerOffset)}>
|
||||
<DraggablePreviewItem title={item.title} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeDraggablePreviewLayer.propTypes = {
|
||||
isOver: PropTypes.bool.isRequired
|
||||
}
|
||||
|
||||
function DraggablePreviewItem({ title }) {
|
||||
return <div className="dnd-draggable-preview-item">{title}</div>
|
||||
}
|
||||
|
||||
DraggablePreviewItem.propTypes = {
|
||||
title: PropTypes.string.isRequired
|
||||
}
|
||||
|
||||
// makes the preview item follow the cursor.
|
||||
// See https://react-dnd.github.io/react-dnd/docs/api/drag-layer-monitor
|
||||
function getItemStyle(clientOffset, containerOffset) {
|
||||
if (!containerOffset || !clientOffset) {
|
||||
return {
|
||||
display: 'none'
|
||||
}
|
||||
}
|
||||
const { x: containerX, y: containerY } = containerOffset
|
||||
const { x: clientX, y: clientY } = clientOffset
|
||||
const posX = clientX - containerX - 15
|
||||
const posY = clientY - containerY - 15
|
||||
const transform = `translate(${posX}px, ${posY}px)`
|
||||
return {
|
||||
transform,
|
||||
WebkitTransform: transform
|
||||
}
|
||||
}
|
||||
|
||||
export default FileTreeDraggablePreviewLayer
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function FileTreeError() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
function reload() {
|
||||
location.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="file-tree-error">
|
||||
<p>{t('generic_something_went_wrong')}</p>
|
||||
<p>{t('please_refresh')}</p>
|
||||
<Button bsStyle="primary" onClick={reload}>
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeError
|
|
@ -0,0 +1,61 @@
|
|||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import FileTreeDoc from './file-tree-doc'
|
||||
import FileTreeFolder from './file-tree-folder'
|
||||
|
||||
function FileTreeFolderList({
|
||||
folders,
|
||||
docs,
|
||||
files,
|
||||
classes = {},
|
||||
dropRef = null
|
||||
}) {
|
||||
const docsAndFiles = [...docs, ...files].sort(
|
||||
(one, two) => one.name.toLowerCase() > two.name.toLowerCase()
|
||||
)
|
||||
|
||||
return (
|
||||
<ul
|
||||
className={classNames('list-unstyled', classes.root)}
|
||||
role="tree"
|
||||
ref={dropRef}
|
||||
>
|
||||
{folders.map(folder => {
|
||||
return (
|
||||
<FileTreeFolder
|
||||
key={folder._id}
|
||||
name={folder.name}
|
||||
id={folder._id}
|
||||
folders={folder.folders}
|
||||
docs={folder.docs}
|
||||
files={folder.fileRefs}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{docsAndFiles.map(doc => {
|
||||
return (
|
||||
<FileTreeDoc
|
||||
key={doc._id}
|
||||
name={doc.name}
|
||||
id={doc._id}
|
||||
isLinkedFile={doc.linkedFileData && !!doc.linkedFileData.provider}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeFolderList.propTypes = {
|
||||
folders: PropTypes.array.isRequired,
|
||||
docs: PropTypes.array.isRequired,
|
||||
files: PropTypes.array.isRequired,
|
||||
classes: PropTypes.exact({
|
||||
root: PropTypes.string
|
||||
}),
|
||||
dropRef: PropTypes.func
|
||||
}
|
||||
|
||||
export default FileTreeFolderList
|
|
@ -0,0 +1,78 @@
|
|||
import React, { useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import Icon from '../../../shared/components/icon'
|
||||
import { useSelectableEntity } from '../contexts/file-tree-selectable'
|
||||
import { useDroppable } from '../contexts/file-tree-draggable'
|
||||
|
||||
import FileTreeItemInner from './file-tree-item/file-tree-item-inner'
|
||||
import FileTreeFolderList from './file-tree-folder-list'
|
||||
|
||||
function FileTreeFolder({ name, id, folders, docs, files }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { isSelected, props: selectableEntityProps } = useSelectableEntity(id)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
function handleExpandCollapseClick() {
|
||||
setExpanded(!expanded)
|
||||
}
|
||||
|
||||
const { isOver: isOverRoot, dropRef: dropRefRoot } = useDroppable(id)
|
||||
const { isOver: isOverList, dropRef: dropRefList } = useDroppable(id)
|
||||
|
||||
const icons = (
|
||||
<>
|
||||
<button
|
||||
onClick={handleExpandCollapseClick}
|
||||
aria-label={expanded ? t('collapse') : t('expand')}
|
||||
>
|
||||
<Icon type={expanded ? 'angle-down' : 'angle-right'} modifier="fw" />
|
||||
</button>
|
||||
<Icon type={expanded ? 'folder-open' : 'folder'} modifier="fw" />
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<li
|
||||
role="treeitem"
|
||||
{...selectableEntityProps}
|
||||
aria-expanded={expanded}
|
||||
aria-label={name}
|
||||
tabIndex="0"
|
||||
ref={dropRefRoot}
|
||||
className={classNames(selectableEntityProps.className, {
|
||||
'dnd-droppable-hover': isOverRoot || isOverList
|
||||
})}
|
||||
>
|
||||
<FileTreeItemInner
|
||||
id={id}
|
||||
name={name}
|
||||
isSelected={isSelected}
|
||||
icons={icons}
|
||||
/>
|
||||
</li>
|
||||
{expanded ? (
|
||||
<FileTreeFolderList
|
||||
folders={folders}
|
||||
docs={docs}
|
||||
files={files}
|
||||
dropRef={dropRefList}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeFolder.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
folders: PropTypes.array.isRequired,
|
||||
docs: PropTypes.array.isRequired,
|
||||
files: PropTypes.array.isRequired
|
||||
}
|
||||
|
||||
export default FileTreeFolder
|
|
@ -0,0 +1,71 @@
|
|||
import React, { useContext, useEffect, createRef } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import classNames from 'classnames'
|
||||
import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed'
|
||||
|
||||
import { FileTreeMainContext } from '../../contexts/file-tree-main'
|
||||
import { useDraggable } from '../../contexts/file-tree-draggable'
|
||||
|
||||
import FileTreeItemName from './file-tree-item-name'
|
||||
import FileTreeItemMenu from './file-tree-item-menu'
|
||||
|
||||
function FileTreeItemInner({ id, name, isSelected, icons }) {
|
||||
const { hasWritePermissions, setContextMenuCoords } = useContext(
|
||||
FileTreeMainContext
|
||||
)
|
||||
|
||||
const hasMenu = hasWritePermissions && isSelected
|
||||
|
||||
const { isDragging, dragRef } = useDraggable(id)
|
||||
|
||||
const itemRef = createRef()
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (isSelected && itemRef.current) {
|
||||
scrollIntoViewIfNeeded(itemRef.current, {
|
||||
scrollMode: 'if-needed'
|
||||
})
|
||||
}
|
||||
},
|
||||
[isSelected, itemRef]
|
||||
)
|
||||
|
||||
function handleContextMenu(ev) {
|
||||
ev.preventDefault()
|
||||
setContextMenuCoords({
|
||||
top: ev.pageY,
|
||||
left: ev.pageX
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('entity', {
|
||||
'dnd-draggable-dragging': isDragging
|
||||
})}
|
||||
role="presentation"
|
||||
ref={dragRef}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div
|
||||
className="entity-name entity-name-react"
|
||||
role="presentation"
|
||||
ref={itemRef}
|
||||
>
|
||||
{icons}
|
||||
<FileTreeItemName name={name} isSelected={isSelected} />
|
||||
{hasMenu ? <FileTreeItemMenu id={id} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeItemInner.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
isSelected: PropTypes.bool.isRequired,
|
||||
icons: PropTypes.node
|
||||
}
|
||||
|
||||
export default FileTreeItemInner
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { MenuItem } from 'react-bootstrap'
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
|
||||
function FileTreeItemMenuItems() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
canRename,
|
||||
canDelete,
|
||||
canCreate,
|
||||
startRenaming,
|
||||
startDeleting,
|
||||
startCreatingFolder,
|
||||
startCreatingDocOrFile,
|
||||
startUploadingDocOrFile
|
||||
} = useFileTreeActionable()
|
||||
|
||||
return (
|
||||
<>
|
||||
{canRename ? (
|
||||
<MenuItem onClick={startRenaming}>{t('rename')}</MenuItem>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<MenuItem onClick={startDeleting}>{t('delete')}</MenuItem>
|
||||
) : null}
|
||||
{canCreate ? (
|
||||
<>
|
||||
<MenuItem divider />
|
||||
<MenuItem onClick={startCreatingDocOrFile}>{t('new_file')}</MenuItem>
|
||||
<MenuItem onClick={startCreatingFolder}>{t('new_folder')}</MenuItem>
|
||||
<MenuItem onClick={startUploadingDocOrFile}>{t('upload')}</MenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeItemMenuItems
|
|
@ -0,0 +1,50 @@
|
|||
import React, { useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import withoutPropagation from '../../../../infrastructure/without-propagation'
|
||||
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
|
||||
import FileTreeItemMenuItems from './file-tree-item-menu-items'
|
||||
|
||||
function FileTreeItemMenu({ id }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
|
||||
function handleToggle(wantOpen) {
|
||||
setDropdownOpen(wantOpen)
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
handleToggle(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
onClick={withoutPropagation(handleClick)}
|
||||
pullRight
|
||||
open={dropdownOpen}
|
||||
id={`dropdown-${id}`}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
<Dropdown.Toggle
|
||||
noCaret
|
||||
className="dropdown-toggle-no-background entity-menu-toggle"
|
||||
onClick={withoutPropagation()}
|
||||
>
|
||||
<Icon type="ellipsis-v" accessibilityLabel={t('menu')} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
<FileTreeItemMenuItems />
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeItemMenu.propTypes = {
|
||||
id: PropTypes.string.isRequired
|
||||
}
|
||||
|
||||
export default FileTreeItemMenu
|
|
@ -0,0 +1,129 @@
|
|||
import React, { useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import { useRefWithAutoFocus } from '../../../../infrastructure/auto-focus'
|
||||
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
|
||||
function FileTreeItemName({ name, isSelected }) {
|
||||
const {
|
||||
isRenaming,
|
||||
startRenaming,
|
||||
finishRenaming,
|
||||
cancel
|
||||
} = useFileTreeActionable()
|
||||
|
||||
const isRenamingEntity = isRenaming && isSelected
|
||||
|
||||
if (isRenamingEntity) {
|
||||
return (
|
||||
<InputName
|
||||
initialValue={name}
|
||||
finishRenaming={finishRenaming}
|
||||
cancel={cancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DisplayName
|
||||
name={name}
|
||||
isSelected={isSelected}
|
||||
startRenaming={startRenaming}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeItemName.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
isSelected: PropTypes.bool.isRequired
|
||||
}
|
||||
|
||||
function DisplayName({ name, isSelected, startRenaming }) {
|
||||
const [clicksInSelectedCount, setClicksInSelectedCount] = useState(0)
|
||||
|
||||
function onClick() {
|
||||
setClicksInSelectedCount(clicksInSelectedCount + 1)
|
||||
if (!isSelected) setClicksInSelectedCount(0)
|
||||
}
|
||||
|
||||
function onDoubleClick() {
|
||||
// only start renaming if the button got two or more clicks while the item
|
||||
// was selected. This is to prevent starting a rename on an unselected item.
|
||||
// When the item is being selected it can trigger a loss of focus which
|
||||
// causes UI problems.
|
||||
if (clicksInSelectedCount < 2) return
|
||||
startRenaming()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="item-name-button"
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<span>{name}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
DisplayName.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
startRenaming: PropTypes.func.isRequired,
|
||||
isSelected: PropTypes.bool.isRequired
|
||||
}
|
||||
|
||||
function InputName({ initialValue, finishRenaming, cancel }) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
|
||||
// The react-bootstrap Dropdown re-focuses on the Dropdown.Toggle
|
||||
// after a menu item is clicked, following the ARIA authoring practices:
|
||||
// https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-links.html
|
||||
// To improve UX, we want to auto-focus to the input when renaming. We use
|
||||
// requestAnimationFrame to immediately move focus to the input after it is
|
||||
// shown
|
||||
const { autoFocusedRef } = useRefWithAutoFocus()
|
||||
|
||||
function handleFocus(ev) {
|
||||
const lastDotIndex = ev.target.value.lastIndexOf('.')
|
||||
ev.target.setSelectionRange(0, lastDotIndex)
|
||||
}
|
||||
|
||||
function handleChange(ev) {
|
||||
setValue(ev.target.value)
|
||||
}
|
||||
|
||||
function handleKeyDown(ev) {
|
||||
if (ev.key === 'Enter') {
|
||||
finishRenaming(value)
|
||||
}
|
||||
if (ev.key === 'Escape') {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
finishRenaming(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="rename-input">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
ref={autoFocusedRef}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
InputName.propTypes = {
|
||||
initialValue: PropTypes.string.isRequired,
|
||||
finishRenaming: PropTypes.func.isRequired,
|
||||
cancel: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
export default FileTreeItemName
|
|
@ -0,0 +1,88 @@
|
|||
import React, { useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import withErrorBoundary from '../../../infrastructure/error-boundary'
|
||||
import FileTreeContext from './file-tree-context'
|
||||
import FileTreeDraggablePreviewLayer from './file-tree-draggable-preview-layer'
|
||||
import FileTreeFolderList from './file-tree-folder-list'
|
||||
import FileTreeToolbar from './file-tree-toolbar'
|
||||
import FileTreeModalDelete from './modals/file-tree-modal-delete'
|
||||
import FileTreeModalCreateFolder from './modals/file-tree-modal-create-folder'
|
||||
import FileTreeContextMenu from './file-tree-context-menu'
|
||||
import FileTreeError from './file-tree-error'
|
||||
|
||||
import { useFileTreeMutable } from '../contexts/file-tree-mutable'
|
||||
import { useDroppable } from '../contexts/file-tree-draggable'
|
||||
|
||||
import { useFileTreeAngularListener } from '../hooks/file-tree-angular-listener'
|
||||
import { useFileTreeSocketListener } from '../hooks/file-tree-socket-listener'
|
||||
|
||||
function FileTreeRoot({
|
||||
projectId,
|
||||
rootFolder,
|
||||
rootDocId,
|
||||
hasWritePermissions,
|
||||
onSelect,
|
||||
onInit
|
||||
}) {
|
||||
const isReady = projectId && rootFolder
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (isReady) onInit()
|
||||
},
|
||||
[isReady]
|
||||
)
|
||||
if (!isReady) return null
|
||||
|
||||
return (
|
||||
<FileTreeContext
|
||||
projectId={projectId}
|
||||
hasWritePermissions={hasWritePermissions}
|
||||
rootFolder={rootFolder}
|
||||
initialSelectedEntityId={rootDocId}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<FileTreeToolbar />
|
||||
<FileTreeContextMenu />
|
||||
<div className="file-tree-inner">
|
||||
<FileTreeRootFolder />
|
||||
</div>
|
||||
<FileTreeModalDelete />
|
||||
<FileTreeModalCreateFolder />
|
||||
</FileTreeContext>
|
||||
)
|
||||
}
|
||||
|
||||
function FileTreeRootFolder() {
|
||||
useFileTreeSocketListener()
|
||||
useFileTreeAngularListener()
|
||||
const { fileTreeData } = useFileTreeMutable()
|
||||
|
||||
const { isOver, dropRef } = useDroppable(fileTreeData._id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileTreeDraggablePreviewLayer isOver={isOver} />
|
||||
<FileTreeFolderList
|
||||
folders={fileTreeData.folders}
|
||||
docs={fileTreeData.docs}
|
||||
files={fileTreeData.fileRefs}
|
||||
classes={{ root: 'file-tree-list' }}
|
||||
dropRef={dropRef}
|
||||
isOver={isOver}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeRoot.propTypes = {
|
||||
projectId: PropTypes.string,
|
||||
rootFolder: PropTypes.array,
|
||||
rootDocId: PropTypes.string,
|
||||
hasWritePermissions: PropTypes.bool.isRequired,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
onInit: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
export default withErrorBoundary(FileTreeRoot, FileTreeError)
|
|
@ -0,0 +1,100 @@
|
|||
import React, { useContext } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import Icon from '../../../shared/components/icon'
|
||||
import TooltipButton from '../../../shared/components/tooltip-button'
|
||||
|
||||
import { FileTreeMainContext } from '../contexts/file-tree-main'
|
||||
import { useFileTreeActionable } from '../contexts/file-tree-actionable'
|
||||
|
||||
function FileTreeToolbar() {
|
||||
const { hasWritePermissions } = useContext(FileTreeMainContext)
|
||||
|
||||
if (!hasWritePermissions) return null
|
||||
|
||||
return (
|
||||
<div className="toolbar toolbar-filetree">
|
||||
<FileTreeToolbarLeft />
|
||||
<FileTreeToolbarRight />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileTreeToolbarLeft() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
canCreate,
|
||||
startCreatingFolder,
|
||||
startCreatingDocOrFile,
|
||||
startUploadingDocOrFile
|
||||
} = useFileTreeActionable()
|
||||
|
||||
if (!canCreate) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipButton
|
||||
id="new_file"
|
||||
description={t('new_file')}
|
||||
onClick={startCreatingDocOrFile}
|
||||
>
|
||||
<Icon type="file" modifier="fw" accessibilityLabel={t('new_file')} />
|
||||
</TooltipButton>
|
||||
<TooltipButton
|
||||
id="new_folder"
|
||||
description={t('new_folder')}
|
||||
onClick={startCreatingFolder}
|
||||
>
|
||||
<Icon
|
||||
type="folder"
|
||||
modifier="fw"
|
||||
accessibilityLabel={t('new_folder')}
|
||||
/>
|
||||
</TooltipButton>
|
||||
<TooltipButton
|
||||
id="upload"
|
||||
description={t('upload')}
|
||||
onClick={startUploadingDocOrFile}
|
||||
>
|
||||
<Icon type="upload" modifier="fw" accessibilityLabel={t('upload')} />
|
||||
</TooltipButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FileTreeToolbarRight() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
canRename,
|
||||
canDelete,
|
||||
startRenaming,
|
||||
startDeleting
|
||||
} = useFileTreeActionable()
|
||||
|
||||
if (!canRename && !canDelete) return null
|
||||
|
||||
return (
|
||||
<div className="toolbar-right">
|
||||
{canRename ? (
|
||||
<TooltipButton
|
||||
id="rename"
|
||||
description={t('rename')}
|
||||
onClick={startRenaming}
|
||||
>
|
||||
<Icon type="pencil" modifier="fw" accessibilityLabel={t('rename')} />
|
||||
</TooltipButton>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<TooltipButton
|
||||
id="delete"
|
||||
description={t('delete')}
|
||||
onClick={startDeleting}
|
||||
>
|
||||
<Icon type="trash-o" modifier="fw" accessibilityLabel={t('delete')} />
|
||||
</TooltipButton>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeToolbar
|
|
@ -0,0 +1,105 @@
|
|||
import React, { useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import { Button, Modal } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRefWithAutoFocus } from '../../../../infrastructure/auto-focus'
|
||||
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
|
||||
function FileTreeModalCreateFolder() {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState('')
|
||||
|
||||
const {
|
||||
isCreatingFolder,
|
||||
inFlight,
|
||||
finishCreatingFolder,
|
||||
cancel,
|
||||
error
|
||||
} = useFileTreeActionable()
|
||||
|
||||
if (!isCreatingFolder) return null // the modal will not be rendered; return early
|
||||
|
||||
function handleHide() {
|
||||
cancel()
|
||||
}
|
||||
|
||||
function handleCreateFolder() {
|
||||
finishCreatingFolder(name)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal show={isCreatingFolder} onHide={handleHide}>
|
||||
<Modal.Header>
|
||||
<Modal.Title>{t('new_folder')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body>
|
||||
<InputName
|
||||
name={name}
|
||||
setName={setName}
|
||||
handleCreateFolder={handleCreateFolder}
|
||||
/>
|
||||
{error && (
|
||||
<div className="alert alert-danger file-tree-modal-alert">
|
||||
{t('generic_something_went_wrong')}
|
||||
</div>
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
{inFlight ? (
|
||||
<Button bsStyle="primary" disabled>
|
||||
{t('creating')}…
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={handleHide}>{t('cancel')}</Button>
|
||||
<Button bsStyle="primary" onClick={handleCreateFolder}>
|
||||
{t('create')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function InputName({ name, setName, handleCreateFolder }) {
|
||||
const { autoFocusedRef } = useRefWithAutoFocus()
|
||||
|
||||
function handleFocus(ev) {
|
||||
ev.target.setSelectionRange(0, -1)
|
||||
}
|
||||
|
||||
function handleChange(ev) {
|
||||
setName(ev.target.value)
|
||||
}
|
||||
|
||||
function handleKeyDown(ev) {
|
||||
if (ev.key === 'Enter') {
|
||||
handleCreateFolder()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={autoFocusedRef}
|
||||
className="form-control"
|
||||
type="text"
|
||||
value={name}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
InputName.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
setName: PropTypes.func.isRequired,
|
||||
handleCreateFolder: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
export default FileTreeModalCreateFolder
|
|
@ -0,0 +1,67 @@
|
|||
import React from 'react'
|
||||
import { Button, Modal } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
|
||||
function FileTreeModalDelete() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
isDeleting,
|
||||
inFlight,
|
||||
finishDeleting,
|
||||
actionedEntities,
|
||||
cancel,
|
||||
error
|
||||
} = useFileTreeActionable()
|
||||
|
||||
if (!isDeleting) return null // the modal will not be rendered; return early
|
||||
|
||||
function handleHide() {
|
||||
cancel()
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
finishDeleting()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal show={isDeleting} onHide={handleHide}>
|
||||
<Modal.Header>
|
||||
<Modal.Title>{t('delete')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body>
|
||||
<p>{t('sure_you_want_to_delete')}</p>
|
||||
<ul>
|
||||
{actionedEntities.map(entity => (
|
||||
<li key={entity._id}>{entity.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
{error && (
|
||||
<div className="alert alert-danger file-tree-modal-alert">
|
||||
{t('generic_something_went_wrong')}
|
||||
</div>
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
{inFlight ? (
|
||||
<Button bsStyle="danger" disabled>
|
||||
{t('deleting')}…
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={handleHide}>{t('cancel')}</Button>
|
||||
<Button bsStyle="danger" onClick={handleDelete}>
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeModalDelete
|
|
@ -0,0 +1,301 @@
|
|||
import React, { createContext, useReducer, useContext } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import { mapSeries } from '../../../infrastructure/promise'
|
||||
|
||||
import {
|
||||
syncRename,
|
||||
syncDelete,
|
||||
syncMove,
|
||||
syncCreateEntity
|
||||
} from '../util/sync-mutation'
|
||||
import { findInTreeOrThrow } from '../util/find-in-tree'
|
||||
|
||||
import { FileTreeMainContext } from './file-tree-main'
|
||||
import { useFileTreeMutable } from './file-tree-mutable'
|
||||
import { useFileTreeSelectable } from './file-tree-selectable'
|
||||
|
||||
const FileTreeActionableContext = createContext()
|
||||
|
||||
const ACTION_TYPES = {
|
||||
START_RENAME: 'START_RENAME',
|
||||
START_DELETE: 'START_DELETE',
|
||||
DELETING: 'DELETING',
|
||||
START_CREATE_FOLDER: 'START_CREATE_FOLDER',
|
||||
CREATING_FOLDER: 'CREATING_FOLDER',
|
||||
CANCEL: 'CANCEL',
|
||||
CLEAR: 'CLEAR',
|
||||
ERROR: 'ERROR'
|
||||
}
|
||||
|
||||
const defaultState = {
|
||||
isDeleting: false,
|
||||
isRenaming: false,
|
||||
isCreatingFolder: false,
|
||||
inFlight: false,
|
||||
actionedEntities: null,
|
||||
error: null
|
||||
}
|
||||
|
||||
function fileTreeActionableReadOnlyReducer(state) {
|
||||
return state
|
||||
}
|
||||
|
||||
function fileTreeActionableReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case ACTION_TYPES.START_RENAME:
|
||||
return { ...defaultState, isRenaming: true }
|
||||
case ACTION_TYPES.START_DELETE:
|
||||
return {
|
||||
...defaultState,
|
||||
isDeleting: true,
|
||||
actionedEntities: action.actionedEntities
|
||||
}
|
||||
case ACTION_TYPES.START_CREATE_FOLDER:
|
||||
return { ...defaultState, isCreatingFolder: true }
|
||||
case ACTION_TYPES.CREATING_FOLDER:
|
||||
return { ...defaultState, isCreatingFolder: true, inFlight: true }
|
||||
case ACTION_TYPES.DELETING:
|
||||
// keep `actionedEntities` so the entities list remains displayed in the
|
||||
// delete modal
|
||||
return {
|
||||
...defaultState,
|
||||
isDeleting: true,
|
||||
inFlight: true,
|
||||
actionedEntities: state.actionedEntities
|
||||
}
|
||||
case ACTION_TYPES.CLEAR:
|
||||
return { ...defaultState }
|
||||
case ACTION_TYPES.CANCEL:
|
||||
if (state.inFlight) return state
|
||||
return { ...defaultState }
|
||||
case ACTION_TYPES.ERROR:
|
||||
return { ...state, inFlight: false, error: action.error }
|
||||
default:
|
||||
throw new Error(`Unknown user action type: ${action.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function FileTreeActionableProvider({ hasWritePermissions, children }) {
|
||||
const [state, dispatch] = useReducer(
|
||||
hasWritePermissions
|
||||
? fileTreeActionableReducer
|
||||
: fileTreeActionableReadOnlyReducer,
|
||||
defaultState
|
||||
)
|
||||
|
||||
return (
|
||||
<FileTreeActionableContext.Provider value={{ ...state, dispatch }}>
|
||||
{children}
|
||||
</FileTreeActionableContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeActionableProvider.propTypes = {
|
||||
hasWritePermissions: PropTypes.bool.isRequired,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
]).isRequired
|
||||
}
|
||||
|
||||
export function useFileTreeActionable() {
|
||||
const {
|
||||
isDeleting,
|
||||
isRenaming,
|
||||
isCreatingFolder,
|
||||
inFlight,
|
||||
error,
|
||||
actionedEntities,
|
||||
dispatch
|
||||
} = useContext(FileTreeActionableContext)
|
||||
const { projectId } = useContext(FileTreeMainContext)
|
||||
const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeMutable()
|
||||
const { selectedEntityIds } = useFileTreeSelectable()
|
||||
|
||||
function startRenaming() {
|
||||
dispatch({ type: ACTION_TYPES.START_RENAME })
|
||||
}
|
||||
|
||||
// update the entity with the new name immediately in the tree, but revert to
|
||||
// the old name if the sync fails
|
||||
function finishRenaming(newName) {
|
||||
dispatch({ type: ACTION_TYPES.CLEAR })
|
||||
const selectedEntityId = Array.from(selectedEntityIds)[0]
|
||||
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
|
||||
const oldName = found.entity.name
|
||||
dispatchRename(selectedEntityId, newName)
|
||||
return syncRename(projectId, found.type, found.entity._id, newName).catch(
|
||||
error => {
|
||||
dispatch({ type: ACTION_TYPES.ERROR, error })
|
||||
dispatchRename(selectedEntityId, oldName)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// init deletion flow (this will open the delete modal).
|
||||
// A copy of the selected entities is set as `actionedEntities` so it is kept
|
||||
// unchanged as the entities are deleted and the selection is updated
|
||||
function startDeleting() {
|
||||
const actionedEntities = Array.from(selectedEntityIds).map(
|
||||
entityId => findInTreeOrThrow(fileTreeData, entityId).entity
|
||||
)
|
||||
dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities })
|
||||
}
|
||||
|
||||
// deletes entities in serie. Tree will be updated via the socket event
|
||||
function finishDeleting() {
|
||||
dispatch({ type: ACTION_TYPES.DELETING })
|
||||
|
||||
return mapSeries(Array.from(selectedEntityIds), id => {
|
||||
const found = findInTreeOrThrow(fileTreeData, id)
|
||||
return syncDelete(projectId, found.type, found.entity._id).catch(
|
||||
error => {
|
||||
// throw unless 404
|
||||
if (error.message !== '404') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
.then(() => {
|
||||
dispatch({ type: ACTION_TYPES.CLEAR })
|
||||
})
|
||||
.catch(error => {
|
||||
// set an error and allow user to retry
|
||||
dispatch({ type: ACTION_TYPES.ERROR, error })
|
||||
})
|
||||
}
|
||||
|
||||
// moves entities. Tree is updated immediately and data are sync'd after.
|
||||
function finishMoving(toFolderId, draggedEntityIds) {
|
||||
draggedEntityIds.forEach(selectedEntityId => {
|
||||
dispatchMove(selectedEntityId, toFolderId)
|
||||
})
|
||||
|
||||
return mapSeries(Array.from(draggedEntityIds), id => {
|
||||
const found = findInTreeOrThrow(fileTreeData, id)
|
||||
return syncMove(projectId, found.type, found.entity._id, toFolderId)
|
||||
})
|
||||
}
|
||||
|
||||
function startCreatingFolder() {
|
||||
dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER })
|
||||
}
|
||||
|
||||
function finishCreatingEntity(entity) {
|
||||
const selectedEntityId = Array.from(selectedEntityIds)[0]
|
||||
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
|
||||
const parentFolderId =
|
||||
found.type === 'folder' ? found.entity._id : found.parentFolderId
|
||||
|
||||
return syncCreateEntity(projectId, parentFolderId, entity)
|
||||
}
|
||||
|
||||
function finishCreatingFolder(name) {
|
||||
dispatch({ type: ACTION_TYPES.CREATING_FOLDER })
|
||||
return finishCreatingEntity({ endpoint: 'folder', name })
|
||||
.then(() => {
|
||||
dispatch({ type: ACTION_TYPES.CLEAR })
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch({ type: ACTION_TYPES.ERROR, error })
|
||||
})
|
||||
}
|
||||
|
||||
// bypass React file tree entirely; requesting the Angular new doc or file
|
||||
// modal instead
|
||||
function startCreatingDocOrFile() {
|
||||
const selectedEntityId = Array.from(selectedEntityIds)[0]
|
||||
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
|
||||
const parentFolderId =
|
||||
found.type === 'folder' ? found.entity._id : found.parentFolderId
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('FileTreeReactBridge.openNewDocModal', {
|
||||
detail: {
|
||||
mode: 'doc',
|
||||
parentFolderId
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function startUploadingDocOrFile() {
|
||||
const selectedEntityId = Array.from(selectedEntityIds)[0]
|
||||
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
|
||||
const parentFolderId =
|
||||
found.type === 'folder' ? found.entity._id : found.parentFolderId
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('FileTreeReactBridge.openNewDocModal', {
|
||||
detail: {
|
||||
mode: 'upload',
|
||||
parentFolderId
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function finishCreatingDocOrFile(entity) {
|
||||
return finishCreatingEntity(entity)
|
||||
.then(() => {
|
||||
// dispatch FileTreeReactBridge event to update the Angular modal
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('FileTreeReactBridge.openNewFileModal', {
|
||||
detail: {
|
||||
done: true
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
.catch(error => {
|
||||
// dispatch FileTreeReactBridge event to update the Angular modal with
|
||||
// an error
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('FileTreeReactBridge.openNewFileModal', {
|
||||
detail: {
|
||||
error: true,
|
||||
data: error.message
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function finishCreatingDoc(entity) {
|
||||
entity.endpoint = 'doc'
|
||||
return finishCreatingDocOrFile(entity)
|
||||
}
|
||||
|
||||
function finishCreatingLinkedFile(entity) {
|
||||
entity.endpoint = 'linked_file'
|
||||
return finishCreatingDocOrFile(entity)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
dispatch({ type: ACTION_TYPES.CANCEL })
|
||||
}
|
||||
|
||||
return {
|
||||
canDelete: selectedEntityIds.size > 0,
|
||||
canRename: selectedEntityIds.size === 1,
|
||||
canCreate: selectedEntityIds.size === 1,
|
||||
isDeleting,
|
||||
isRenaming,
|
||||
isCreatingFolder,
|
||||
inFlight,
|
||||
actionedEntities,
|
||||
error,
|
||||
startRenaming,
|
||||
finishRenaming,
|
||||
startDeleting,
|
||||
finishDeleting,
|
||||
finishMoving,
|
||||
startCreatingFolder,
|
||||
finishCreatingFolder,
|
||||
startCreatingDocOrFile,
|
||||
startUploadingDocOrFile,
|
||||
finishCreatingDoc,
|
||||
finishCreatingLinkedFile,
|
||||
cancel
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
import React, { useRef, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DndProvider, createDndContext, useDrag, useDrop } from 'react-dnd'
|
||||
import { HTML5Backend, getEmptyImage } from 'react-dnd-html5-backend'
|
||||
|
||||
import {
|
||||
findAllInTreeOrThrow,
|
||||
findAllFolderIdsInFolders
|
||||
} from '../util/find-in-tree'
|
||||
|
||||
import { useFileTreeActionable } from './file-tree-actionable'
|
||||
import { useFileTreeMutable } from './file-tree-mutable'
|
||||
import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
|
||||
|
||||
const DndContext = createDndContext(HTML5Backend)
|
||||
|
||||
const DRAGGABLE_TYPE = 'ENTITY'
|
||||
|
||||
export function FileTreeDraggableProvider({ children }) {
|
||||
const DndManager = useRef(DndContext)
|
||||
|
||||
return (
|
||||
<DndProvider manager={DndManager.current.dragDropManager}>
|
||||
{children}
|
||||
</DndProvider>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeDraggableProvider.propTypes = {
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
]).isRequired
|
||||
}
|
||||
|
||||
export function useDraggable(draggedEntityId) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { fileTreeData } = useFileTreeMutable()
|
||||
const { selectedEntityIds } = useFileTreeSelectable()
|
||||
|
||||
const item = { type: DRAGGABLE_TYPE }
|
||||
const [{ isDragging }, dragRef, preview] = useDrag({
|
||||
item, // required, but overwritten by the return value of `begin`
|
||||
begin: () => {
|
||||
const draggedEntityIds = getDraggedEntityIds(
|
||||
selectedEntityIds,
|
||||
draggedEntityId
|
||||
)
|
||||
const draggedItems = findAllInTreeOrThrow(fileTreeData, draggedEntityIds)
|
||||
const title = getDraggedTitle(draggedItems, t)
|
||||
const forbiddenFolderIds = getForbiddenFolderIds(draggedItems)
|
||||
return { ...item, title, forbiddenFolderIds, draggedEntityIds }
|
||||
},
|
||||
collect: monitor => ({
|
||||
isDragging: !!monitor.isDragging()
|
||||
})
|
||||
})
|
||||
|
||||
// remove the automatic preview as we're using a custom preview via
|
||||
// FileTreeDraggablePreviewLayer
|
||||
useEffect(
|
||||
() => {
|
||||
preview(getEmptyImage())
|
||||
},
|
||||
[preview]
|
||||
)
|
||||
|
||||
return {
|
||||
dragRef,
|
||||
isDragging
|
||||
}
|
||||
}
|
||||
|
||||
export function useDroppable(droppedEntityId) {
|
||||
const { finishMoving } = useFileTreeActionable()
|
||||
|
||||
const [{ isOver }, dropRef] = useDrop({
|
||||
accept: DRAGGABLE_TYPE,
|
||||
canDrop: (item, monitor) => {
|
||||
const isOver = monitor.isOver({ shallow: true })
|
||||
if (!isOver) return false
|
||||
if (item.forbiddenFolderIds.has(droppedEntityId)) return false
|
||||
return true
|
||||
},
|
||||
drop: (item, monitor) => {
|
||||
const didDropInChild = monitor.didDrop()
|
||||
if (didDropInChild) return
|
||||
finishMoving(droppedEntityId, item.draggedEntityIds)
|
||||
},
|
||||
collect: monitor => ({
|
||||
isOver: monitor.canDrop()
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
dropRef,
|
||||
isOver
|
||||
}
|
||||
}
|
||||
|
||||
// Get the list of dragged entity ids. If the dragged entity is one of the
|
||||
// selected entities then all the selected entites are dragged entities,
|
||||
// otherwise it's the dragged entity only.
|
||||
function getDraggedEntityIds(selectedEntityIds, draggedEntityId) {
|
||||
if (selectedEntityIds.size > 1 && selectedEntityIds.has(draggedEntityId)) {
|
||||
// dragging the multi-selected entities
|
||||
return new Set(selectedEntityIds)
|
||||
} else {
|
||||
// not dragging the selection; only the current item
|
||||
return new Set([draggedEntityId])
|
||||
}
|
||||
}
|
||||
|
||||
// Get the draggable title. This is the name of the dragged entities if there's
|
||||
// only one, otherwise it's the number of dragged entities.
|
||||
function getDraggedTitle(draggedItems, t) {
|
||||
if (draggedItems.size === 1) {
|
||||
const draggedItem = Array.from(draggedItems)[0]
|
||||
return draggedItem.entity.name
|
||||
}
|
||||
return t('n_items', { count: draggedItems.size })
|
||||
}
|
||||
|
||||
// Get all children folder ids of any of the dragged items.
|
||||
function getForbiddenFolderIds(draggedItems) {
|
||||
const draggedFoldersArray = Array.from(draggedItems)
|
||||
.filter(draggedItem => {
|
||||
return draggedItem.type === 'folder'
|
||||
})
|
||||
.map(draggedItem => draggedItem.entity)
|
||||
const draggedFolders = new Set(draggedFoldersArray)
|
||||
return findAllFolderIdsInFolders(draggedFolders)
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import React, { createContext, useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
export const FileTreeMainContext = createContext({})
|
||||
|
||||
export const FileTreeMainProvider = function({
|
||||
projectId,
|
||||
hasWritePermissions,
|
||||
children
|
||||
}) {
|
||||
const [contextMenuCoords, setContextMenuCoords] = useState()
|
||||
|
||||
return (
|
||||
<FileTreeMainContext.Provider
|
||||
value={{
|
||||
projectId,
|
||||
hasWritePermissions,
|
||||
contextMenuCoords,
|
||||
setContextMenuCoords
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FileTreeMainContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeMainProvider.propTypes = {
|
||||
projectId: PropTypes.string.isRequired,
|
||||
hasWritePermissions: PropTypes.bool.isRequired,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
]).isRequired
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
import React, { createContext, useReducer, useContext } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import {
|
||||
renameInTree,
|
||||
deleteInTree,
|
||||
moveInTree,
|
||||
createEntityInTree
|
||||
} from '../util/mutate-in-tree'
|
||||
|
||||
const FileTreeMutableContext = createContext()
|
||||
|
||||
const ACTION_TYPES = {
|
||||
RENAME: 'RENAME',
|
||||
DELETE: 'DELETE',
|
||||
MOVE: 'MOVE',
|
||||
CREATE_ENTITY: 'CREATE_ENTITY'
|
||||
}
|
||||
|
||||
function fileTreeMutableReducer({ fileTreeData }, action) {
|
||||
switch (action.type) {
|
||||
case ACTION_TYPES.RENAME:
|
||||
return {
|
||||
fileTreeData: renameInTree(fileTreeData, action.id, {
|
||||
newName: action.newName
|
||||
})
|
||||
}
|
||||
case ACTION_TYPES.DELETE:
|
||||
return {
|
||||
fileTreeData: deleteInTree(fileTreeData, action.id)
|
||||
}
|
||||
case ACTION_TYPES.MOVE:
|
||||
return {
|
||||
fileTreeData: moveInTree(
|
||||
fileTreeData,
|
||||
action.entityId,
|
||||
action.toFolderId
|
||||
)
|
||||
}
|
||||
case ACTION_TYPES.CREATE_ENTITY:
|
||||
return {
|
||||
fileTreeData: createEntityInTree(
|
||||
fileTreeData,
|
||||
action.parentFolderId,
|
||||
action.entity
|
||||
)
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown mutable file tree action type: ${action.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const FileTreeMutableProvider = function({ rootFolder, children }) {
|
||||
const [{ fileTreeData }, dispatch] = useReducer(fileTreeMutableReducer, {
|
||||
fileTreeData: rootFolder[0]
|
||||
})
|
||||
|
||||
return (
|
||||
<FileTreeMutableContext.Provider value={{ fileTreeData, dispatch }}>
|
||||
{children}
|
||||
</FileTreeMutableContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeMutableProvider.propTypes = {
|
||||
rootFolder: PropTypes.array.isRequired,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
]).isRequired
|
||||
}
|
||||
|
||||
export function useFileTreeMutable() {
|
||||
const { fileTreeData, dispatch } = useContext(FileTreeMutableContext)
|
||||
|
||||
function dispatchCreateFolder(parentFolderId, entity) {
|
||||
entity.type = 'folder'
|
||||
dispatch({
|
||||
type: ACTION_TYPES.CREATE_ENTITY,
|
||||
parentFolderId,
|
||||
entity
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchCreateDoc(parentFolderId, entity) {
|
||||
entity.type = 'doc'
|
||||
dispatch({
|
||||
type: ACTION_TYPES.CREATE_ENTITY,
|
||||
parentFolderId,
|
||||
entity
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchCreateFile(parentFolderId, entity) {
|
||||
entity.type = 'fileRef'
|
||||
dispatch({
|
||||
type: ACTION_TYPES.CREATE_ENTITY,
|
||||
parentFolderId,
|
||||
entity
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchRename(id, newName) {
|
||||
dispatch({
|
||||
type: ACTION_TYPES.RENAME,
|
||||
newName,
|
||||
id
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchDelete(id) {
|
||||
dispatch({ type: ACTION_TYPES.DELETE, id })
|
||||
}
|
||||
|
||||
function dispatchMove(entityId, toFolderId) {
|
||||
dispatch({ type: ACTION_TYPES.MOVE, entityId, toFolderId })
|
||||
}
|
||||
|
||||
return {
|
||||
fileTreeData,
|
||||
dispatchRename,
|
||||
dispatchDelete,
|
||||
dispatchMove,
|
||||
dispatchCreateFolder,
|
||||
dispatchCreateDoc,
|
||||
dispatchCreateFile
|
||||
}
|
||||
}
|
|
@ -0,0 +1,167 @@
|
|||
import React, { createContext, useContext, useReducer, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import { findInTree } from '../util/find-in-tree'
|
||||
import { useFileTreeMutable } from './file-tree-mutable'
|
||||
|
||||
const FileTreeSelectableContext = createContext(new Set())
|
||||
|
||||
const ACTION_TYPES = {
|
||||
SELECT: 'SELECT',
|
||||
MULTI_SELECT: 'MULTI_SELECT',
|
||||
UNSELECT: 'UNSELECT'
|
||||
}
|
||||
|
||||
function fileTreeSelectableReadWriteReducer(selectedEntityIds, action) {
|
||||
switch (action.type) {
|
||||
case ACTION_TYPES.SELECT: {
|
||||
// reset selection
|
||||
return new Set([action.id])
|
||||
}
|
||||
|
||||
case ACTION_TYPES.MULTI_SELECT: {
|
||||
const selectedEntityIdsCopy = new Set(selectedEntityIds)
|
||||
if (selectedEntityIdsCopy.has(action.id)) {
|
||||
// entity already selected
|
||||
if (selectedEntityIdsCopy.size > 1) {
|
||||
// entity already multi-selected; remove from set
|
||||
selectedEntityIdsCopy.delete(action.id)
|
||||
}
|
||||
} else {
|
||||
// entity not selected: add to set
|
||||
selectedEntityIdsCopy.add(action.id)
|
||||
}
|
||||
|
||||
return selectedEntityIdsCopy
|
||||
}
|
||||
|
||||
case ACTION_TYPES.UNSELECT: {
|
||||
const selectedEntityIdsCopy = new Set(selectedEntityIds)
|
||||
selectedEntityIdsCopy.delete(action.id)
|
||||
return selectedEntityIdsCopy
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown selectable action type: ${action.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
function fileTreeSelectableReadOnlyReducer(selectedEntityIds, action) {
|
||||
switch (action.type) {
|
||||
case ACTION_TYPES.SELECT:
|
||||
return new Set([action.id])
|
||||
|
||||
case ACTION_TYPES.MULTI_SELECT:
|
||||
case ACTION_TYPES.UNSELECT:
|
||||
return selectedEntityIds
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown selectable action type: ${action.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function FileTreeSelectableProvider({
|
||||
hasWritePermissions,
|
||||
initialSelectedEntityId = null,
|
||||
onSelect,
|
||||
children
|
||||
}) {
|
||||
const [selectedEntityIds, dispatch] = useReducer(
|
||||
hasWritePermissions
|
||||
? fileTreeSelectableReadWriteReducer
|
||||
: fileTreeSelectableReadOnlyReducer,
|
||||
initialSelectedEntityId ? new Set([initialSelectedEntityId]) : new Set()
|
||||
)
|
||||
const { fileTreeData } = useFileTreeMutable()
|
||||
|
||||
// calls `onSelect` on entities selection
|
||||
useEffect(
|
||||
() => {
|
||||
const selectedEntities = Array.from(selectedEntityIds).map(id =>
|
||||
findInTree(fileTreeData, id)
|
||||
)
|
||||
onSelect(selectedEntities)
|
||||
},
|
||||
[fileTreeData, selectedEntityIds]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// listen for `editor.openDoc` and selected that doc
|
||||
function handleOpenDoc(ev) {
|
||||
dispatch({ type: ACTION_TYPES.SELECT, id: ev.detail })
|
||||
}
|
||||
window.addEventListener('editor.openDoc', handleOpenDoc)
|
||||
return () => window.removeEventListener('editor.openDoc', handleOpenDoc)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<FileTreeSelectableContext.Provider value={{ selectedEntityIds, dispatch }}>
|
||||
{children}
|
||||
</FileTreeSelectableContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
FileTreeSelectableProvider.propTypes = {
|
||||
hasWritePermissions: PropTypes.bool.isRequired,
|
||||
initialSelectedEntityId: PropTypes.string,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
]).isRequired
|
||||
}
|
||||
|
||||
export function useSelectableEntity(id) {
|
||||
const { selectedEntityIds, dispatch } = useContext(FileTreeSelectableContext)
|
||||
|
||||
const isSelected = selectedEntityIds.has(id)
|
||||
|
||||
function selectOrMultiSelectEntity(ev) {
|
||||
const isMultiSelect = ev.ctrlKey || ev.metaKey
|
||||
const actionType = isMultiSelect
|
||||
? ACTION_TYPES.MULTI_SELECT
|
||||
: ACTION_TYPES.SELECT
|
||||
|
||||
dispatch({ type: actionType, id })
|
||||
}
|
||||
|
||||
function handleClick(ev) {
|
||||
selectOrMultiSelectEntity(ev)
|
||||
}
|
||||
|
||||
function handleKeyPress(ev) {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
selectOrMultiSelectEntity(ev)
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(ev) {
|
||||
// make sure the right-clicked entity gets selected
|
||||
if (!selectedEntityIds.has(id)) selectOrMultiSelectEntity(ev)
|
||||
}
|
||||
|
||||
return {
|
||||
isSelected,
|
||||
props: {
|
||||
className: classNames({ selected: isSelected }),
|
||||
'aria-selected': isSelected,
|
||||
onClick: handleClick,
|
||||
onContextMenu: handleContextMenu,
|
||||
onKeyPress: handleKeyPress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useFileTreeSelectable() {
|
||||
const { selectedEntityIds, dispatch } = useContext(FileTreeSelectableContext)
|
||||
|
||||
function unselect(id) {
|
||||
dispatch({ type: ACTION_TYPES.UNSELECT, id })
|
||||
}
|
||||
|
||||
return {
|
||||
selectedEntityIds,
|
||||
unselect
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import App from '../../../base'
|
||||
import { react2angular } from 'react2angular'
|
||||
|
||||
import FileTreeRoot from '../components/file-tree-root'
|
||||
|
||||
App.controller('ReactFileTreeController', function(
|
||||
$scope,
|
||||
$timeout,
|
||||
ide,
|
||||
eventTracking
|
||||
) {
|
||||
$scope.projectId = ide.project_id
|
||||
$scope.rootFolder = null
|
||||
$scope.rootDocId = null
|
||||
$scope.hasWritePermissions = false
|
||||
|
||||
$scope.$on('project:joined', () => {
|
||||
$scope.rootFolder = $scope.project.rootFolder
|
||||
$scope.rootDocId = $scope.project.rootDoc_id
|
||||
$scope.$emit('file-tree:initialized')
|
||||
})
|
||||
|
||||
$scope.$watch('permissions.write', hasWritePermissions => {
|
||||
$scope.hasWritePermissions = hasWritePermissions
|
||||
})
|
||||
|
||||
$scope.$watch('editor.open_doc_id', openDocId => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('editor.openDoc', { detail: openDocId })
|
||||
)
|
||||
})
|
||||
|
||||
$scope.onInit = () => {
|
||||
// HACK: resize the vertical pane on init after a 0ms timeout. We do not
|
||||
// understand why this is necessary but without this the resized handle is
|
||||
// stuck at the bottom. The vertical resize will soon be migrated to React
|
||||
// so we accept to live with this hack for now.
|
||||
$timeout(() => {
|
||||
$scope.$emit('left-pane-resize-all')
|
||||
})
|
||||
}
|
||||
|
||||
$scope.onSelect = selectedEntities => {
|
||||
if (selectedEntities.length === 1) {
|
||||
const selectedEntity = selectedEntities[0]
|
||||
$scope.$emit('entity:selected', {
|
||||
id: selectedEntity.entity._id,
|
||||
name: selectedEntity.entity.name,
|
||||
type: selectedEntity.type
|
||||
})
|
||||
|
||||
// in the react implementation there is no such concept as "1
|
||||
// multi-selected entity" so here we pass a count of 0
|
||||
$scope.$emit('entities:multiSelected', { count: 0 })
|
||||
} else if (selectedEntities.length > 1) {
|
||||
$scope.$emit('entities:multiSelected', { count: selectedEntities.length })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
App.component('fileTreeRoot', react2angular(FileTreeRoot))
|
|
@ -0,0 +1,50 @@
|
|||
import { useEffect } from 'react'
|
||||
|
||||
import { useFileTreeActionable } from '../contexts/file-tree-actionable'
|
||||
|
||||
// A temporary hack to listen to events dispatched from the Angular and update
|
||||
// the React file tree accordingly
|
||||
export function useFileTreeAngularListener() {
|
||||
const {
|
||||
finishCreatingDoc,
|
||||
finishCreatingLinkedFile
|
||||
} = useFileTreeActionable()
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
function handleDispatchCreateDoc(ev) {
|
||||
const { ...doc } = ev.detail
|
||||
finishCreatingDoc(doc)
|
||||
}
|
||||
window.addEventListener(
|
||||
'FileTreeReactBridge.createDoc',
|
||||
handleDispatchCreateDoc
|
||||
)
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
'FileTreeReactBridge.createDoc',
|
||||
handleDispatchCreateDoc
|
||||
)
|
||||
},
|
||||
[finishCreatingDoc]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
function handleDispatchCreateLinkedFile(ev) {
|
||||
const { ...file } = ev.detail
|
||||
finishCreatingLinkedFile(file)
|
||||
}
|
||||
window.addEventListener(
|
||||
'FileTreeReactBridge.createLinkedFile',
|
||||
handleDispatchCreateLinkedFile
|
||||
)
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
'FileTreeReactBridge.createLinkedFile',
|
||||
handleDispatchCreateLinkedFile
|
||||
)
|
||||
},
|
||||
[finishCreatingLinkedFile]
|
||||
)
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
import { useEffect } from 'react'
|
||||
|
||||
import { useFileTreeMutable } from '../contexts/file-tree-mutable'
|
||||
import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
|
||||
|
||||
export function useFileTreeSocketListener() {
|
||||
const {
|
||||
dispatchRename,
|
||||
dispatchDelete,
|
||||
dispatchMove,
|
||||
dispatchCreateFolder,
|
||||
dispatchCreateDoc,
|
||||
dispatchCreateFile
|
||||
} = useFileTreeMutable()
|
||||
const { unselect } = useFileTreeSelectable()
|
||||
const socket = window._ide && window._ide.socket
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
function handleDispatchRename(entityId, name) {
|
||||
dispatchRename(entityId, name)
|
||||
}
|
||||
if (socket) socket.on('reciveEntityRename', handleDispatchRename)
|
||||
return () => {
|
||||
if (socket)
|
||||
socket.removeListener('reciveEntityRename', handleDispatchRename)
|
||||
}
|
||||
},
|
||||
[socket, dispatchRename]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
function handleDispatchDelete(entityId) {
|
||||
unselect(entityId)
|
||||
dispatchDelete(entityId)
|
||||
}
|
||||
if (socket) socket.on('removeEntity', handleDispatchDelete)
|
||||
return () => {
|
||||
if (socket) socket.removeListener('removeEntity', handleDispatchDelete)
|
||||
}
|
||||
},
|
||||
[socket, unselect, dispatchDelete]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
function handleDispatchMove(entityId, toFolderId) {
|
||||
dispatchMove(entityId, toFolderId)
|
||||
}
|
||||
if (socket) socket.on('reciveEntityMove', handleDispatchMove)
|
||||
return () => {
|
||||
if (socket)
|
||||
socket.removeListener('reciveEntityMove', handleDispatchMove)
|
||||
}
|
||||
},
|
||||
[socket, dispatchMove]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
function handleDispatchCreateFolder(parentFolderId, folder) {
|
||||
dispatchCreateFolder(parentFolderId, folder)
|
||||
}
|
||||
if (socket) socket.on('reciveNewFolder', handleDispatchCreateFolder)
|
||||
return () => {
|
||||
if (socket)
|
||||
socket.removeListener('reciveNewFolder', handleDispatchCreateFolder)
|
||||
}
|
||||
},
|
||||
[socket, dispatchCreateFolder]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
function handleDispatchCreateDoc(parentFolderId, doc) {
|
||||
dispatchCreateDoc(parentFolderId, doc)
|
||||
}
|
||||
if (socket) socket.on('reciveNewDoc', handleDispatchCreateDoc)
|
||||
return () => {
|
||||
if (socket)
|
||||
socket.removeListener('reciveNewDoc', handleDispatchCreateDoc)
|
||||
}
|
||||
},
|
||||
[socket, dispatchCreateDoc]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
function handleDispatchCreateFile(parentFolderId, file) {
|
||||
dispatchCreateFile(parentFolderId, file)
|
||||
}
|
||||
if (socket) socket.on('reciveNewFile', handleDispatchCreateFile)
|
||||
return () => {
|
||||
if (socket)
|
||||
socket.removeListener('reciveNewFile', handleDispatchCreateFile)
|
||||
}
|
||||
},
|
||||
[socket, dispatchCreateFile]
|
||||
)
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
export function findInTreeOrThrow(tree, id) {
|
||||
const found = findInTree(tree, id)
|
||||
if (found) return found
|
||||
throw new Error(`Entity not found with id=${id}`)
|
||||
}
|
||||
|
||||
export function findAllInTreeOrThrow(tree, ids) {
|
||||
let list = new Set()
|
||||
ids.forEach(id => {
|
||||
list.add(findInTreeOrThrow(tree, id))
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
export function findAllFolderIdsInFolder(folder) {
|
||||
const list = new Set([folder._id])
|
||||
for (const index in folder.folders) {
|
||||
const subFolder = folder.folders[index]
|
||||
findAllFolderIdsInFolder(subFolder).forEach(subFolderId => {
|
||||
list.add(subFolderId)
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
export function findAllFolderIdsInFolders(folders) {
|
||||
let list = new Set()
|
||||
folders.forEach(folder => {
|
||||
findAllFolderIdsInFolder(folder).forEach(folderId => {
|
||||
list.add(folderId)
|
||||
})
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
export function findInTree(tree, id) {
|
||||
for (const index in tree.docs) {
|
||||
const doc = tree.docs[index]
|
||||
if (doc._id === id) {
|
||||
return {
|
||||
entity: doc,
|
||||
type: 'doc',
|
||||
parent: tree.docs,
|
||||
parentFolderId: tree._id,
|
||||
index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const index in tree.fileRefs) {
|
||||
const file = tree.fileRefs[index]
|
||||
if (file._id === id) {
|
||||
return {
|
||||
entity: file,
|
||||
type: 'fileRef',
|
||||
parent: tree.fileRefs,
|
||||
parentFolderId: tree._id,
|
||||
index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const index in tree.folders) {
|
||||
const folder = tree.folders[index]
|
||||
if (folder._id === id) {
|
||||
return {
|
||||
entity: folder,
|
||||
type: 'folder',
|
||||
parent: tree.folders,
|
||||
parentFolderId: tree._id,
|
||||
index
|
||||
}
|
||||
}
|
||||
const found = findInTree(folder, id)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
export default function iconTypeFromName(name) {
|
||||
let ext = name.split('.').pop()
|
||||
ext = ext ? ext.toLowerCase() : ext
|
||||
|
||||
if (['png', 'pdf', 'jpg', 'jpeg', 'gif'].includes(ext)) {
|
||||
return 'image'
|
||||
} else if (['csv', 'xls', 'xlsx'].includes(ext)) {
|
||||
return 'table'
|
||||
} else if (['py', 'r'].includes(ext)) {
|
||||
return 'file-text'
|
||||
} else if (['bib'].includes(ext)) {
|
||||
return 'book'
|
||||
} else {
|
||||
return 'file'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
import { findInTreeOrThrow } from './find-in-tree'
|
||||
|
||||
export function renameInTree(tree, id, { newName }) {
|
||||
return mutateInTree(tree, id, (parent, entity, index) => {
|
||||
const newParent = Object.assign([], parent)
|
||||
const newEntity = {
|
||||
...entity,
|
||||
name: newName
|
||||
}
|
||||
newParent[index] = newEntity
|
||||
return newParent
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteInTree(tree, id) {
|
||||
return mutateInTree(tree, id, (parent, entity, index) => {
|
||||
return [...parent.slice(0, index), ...parent.slice(index + 1)]
|
||||
})
|
||||
}
|
||||
|
||||
export function moveInTree(tree, entityId, toFolderId) {
|
||||
const found = findInTreeOrThrow(tree, entityId)
|
||||
if (found.parentFolderId === toFolderId) {
|
||||
// nothing to do (the entity was probably already moved)
|
||||
return tree
|
||||
}
|
||||
const newFileTreeData = deleteInTree(tree, entityId)
|
||||
return createEntityInTree(newFileTreeData, toFolderId, {
|
||||
...found.entity,
|
||||
type: found.type
|
||||
})
|
||||
}
|
||||
|
||||
export function createEntityInTree(tree, parentFolderId, newEntityData) {
|
||||
const { type, ...newEntity } = newEntityData
|
||||
if (!type) throw new Error('Entity has no type')
|
||||
const entityType = `${type}s`
|
||||
|
||||
return mutateInTree(tree, parentFolderId, (parent, folder, index) => {
|
||||
parent[index] = {
|
||||
...folder,
|
||||
[entityType]: [...folder[entityType], newEntity]
|
||||
}
|
||||
return parent
|
||||
})
|
||||
}
|
||||
|
||||
function mutateInTree(tree, id, mutationFunction) {
|
||||
if (tree._id === id) {
|
||||
// covers the root folder case: it has no parent so in order to use
|
||||
// mutationFunction we pass an empty array as the parent and return the
|
||||
// mutated tree directly
|
||||
const [newTree] = mutationFunction([], tree, 0)
|
||||
return newTree
|
||||
}
|
||||
|
||||
for (const entityType of ['docs', 'fileRefs', 'folders']) {
|
||||
for (let index = 0; index < tree[entityType].length; index++) {
|
||||
const entity = tree[entityType][index]
|
||||
if (entity._id === id) {
|
||||
return {
|
||||
...tree,
|
||||
[entityType]: mutationFunction(tree[entityType], entity, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newFolders = tree.folders.map(folder =>
|
||||
mutateInTree(folder, id, mutationFunction)
|
||||
)
|
||||
|
||||
return { ...tree, folders: newFolders }
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { postJSON, deleteJSON } from '../../../infrastructure/fetch-json'
|
||||
|
||||
export function syncRename(projectId, entityType, entityId, newName) {
|
||||
return postJSON(
|
||||
`/project/${projectId}/${getEntityPathName(entityType)}/${entityId}/rename`,
|
||||
{
|
||||
body: {
|
||||
name: newName
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function syncDelete(projectId, entityType, entityId) {
|
||||
return deleteJSON(
|
||||
`/project/${projectId}/${getEntityPathName(entityType)}/${entityId}`
|
||||
)
|
||||
}
|
||||
|
||||
export function syncMove(projectId, entityType, entityId, toFolderId) {
|
||||
return postJSON(
|
||||
`/project/${projectId}/${getEntityPathName(entityType)}/${entityId}/move`,
|
||||
{
|
||||
body: {
|
||||
folder_id: toFolderId
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function syncCreateEntity(projectId, parentFolderId, newEntityData) {
|
||||
const { endpoint, ...newEntity } = newEntityData
|
||||
return postJSON(`/project/${projectId}/${endpoint}`, {
|
||||
body: {
|
||||
parent_folder_id: parentFolderId,
|
||||
...newEntity
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getEntityPathName(entityType) {
|
||||
return entityType === 'fileRef' ? 'file' : entityType
|
||||
}
|
|
@ -41,6 +41,7 @@ export default App.directive('verticalResizablePanes', localStorage => ({
|
|||
}
|
||||
|
||||
const toggledExternally = attrs.verticalResizablePanesToggledExternallyOn
|
||||
const resizeOn = attrs.verticalResizablePanesResizeOn
|
||||
const resizerDisabledClass = `${layoutOptions.south.resizerClass}-disabled`
|
||||
|
||||
function enableResizer() {
|
||||
|
@ -81,6 +82,12 @@ export default App.directive('verticalResizablePanes', localStorage => ({
|
|||
})
|
||||
}
|
||||
|
||||
if (resizeOn) {
|
||||
scope.$on(resizeOn, () => {
|
||||
layoutHandle.resizeAll()
|
||||
})
|
||||
}
|
||||
|
||||
if (maxSize) {
|
||||
layoutOptions.south.maxSize = maxSize
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ import './controllers/FileTreeController'
|
|||
import './controllers/FileTreeEntityController'
|
||||
import './controllers/FileTreeFolderController'
|
||||
import './controllers/FileTreeRootFolderController'
|
||||
import '../../features/file-tree/controllers/file-tree-controller'
|
||||
let FileTreeManager
|
||||
|
||||
export default (FileTreeManager = class FileTreeManager {
|
||||
|
@ -37,6 +38,12 @@ export default (FileTreeManager = class FileTreeManager {
|
|||
return this.$scope.$emit('file-tree:initialized')
|
||||
})
|
||||
|
||||
this.$scope.$on('entities:multiSelected', (_event, data) => {
|
||||
this.$scope.$apply(() => {
|
||||
this.$scope.multiSelectedCount = data.count
|
||||
})
|
||||
})
|
||||
|
||||
this.$scope.$watch('rootFolder', rootFolder => {
|
||||
if (rootFolder != null) {
|
||||
return this.recalculateDocList()
|
||||
|
@ -557,6 +564,22 @@ export default (FileTreeManager = class FileTreeManager {
|
|||
}
|
||||
|
||||
createDoc(name, parent_folder) {
|
||||
if (window.showReactFileTree) {
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
this.$scope.FileTreeReactBridgePromise = {
|
||||
resolve,
|
||||
reject
|
||||
}
|
||||
})
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('FileTreeReactBridge.createDoc', {
|
||||
detail: {
|
||||
name
|
||||
}
|
||||
})
|
||||
)
|
||||
return promise
|
||||
}
|
||||
// check if a doc/file/folder already exists with this name
|
||||
if (parent_folder == null) {
|
||||
parent_folder = this.getCurrentFolder()
|
||||
|
@ -591,6 +614,24 @@ export default (FileTreeManager = class FileTreeManager {
|
|||
}
|
||||
|
||||
createLinkedFile(name, parent_folder, provider, data) {
|
||||
if (window.showReactFileTree) {
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
this.$scope.FileTreeReactBridgePromise = {
|
||||
resolve,
|
||||
reject
|
||||
}
|
||||
})
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('FileTreeReactBridge.createLinkedFile', {
|
||||
detail: {
|
||||
name,
|
||||
provider,
|
||||
data
|
||||
}
|
||||
})
|
||||
)
|
||||
return promise
|
||||
}
|
||||
// check if a doc/file/folder already exists with this name
|
||||
if (parent_folder == null) {
|
||||
parent_folder = this.getCurrentFolder()
|
||||
|
|
|
@ -17,13 +17,16 @@ import _ from 'lodash'
|
|||
*/
|
||||
import App from '../../../base'
|
||||
App.controller('FileTreeController', function($scope, $modal, ide, $rootScope) {
|
||||
$scope.openNewDocModal = () =>
|
||||
$scope.openNewDocModal = reactBridgeParentFolderId =>
|
||||
$modal.open({
|
||||
templateUrl: 'newFileModalTemplate',
|
||||
controller: 'NewFileModalController',
|
||||
size: 'lg',
|
||||
resolve: {
|
||||
parent_folder() {
|
||||
if (reactBridgeParentFolderId) {
|
||||
return { id: reactBridgeParentFolderId }
|
||||
}
|
||||
return ide.fileTreeManager.getCurrentFolder()
|
||||
},
|
||||
projectFeatures() {
|
||||
|
@ -49,7 +52,7 @@ App.controller('FileTreeController', function($scope, $modal, ide, $rootScope) {
|
|||
}
|
||||
})
|
||||
|
||||
$scope.openUploadFileModal = () =>
|
||||
$scope.openUploadFileModal = reactBridgeParentFolderId =>
|
||||
$modal.open({
|
||||
templateUrl: 'newFileModalTemplate',
|
||||
controller: 'NewFileModalController',
|
||||
|
@ -59,6 +62,9 @@ App.controller('FileTreeController', function($scope, $modal, ide, $rootScope) {
|
|||
return ide.$scope.project.features
|
||||
},
|
||||
parent_folder() {
|
||||
if (reactBridgeParentFolderId) {
|
||||
return { id: reactBridgeParentFolderId }
|
||||
}
|
||||
return ide.fileTreeManager.getCurrentFolder()
|
||||
},
|
||||
type() {
|
||||
|
@ -70,6 +76,19 @@ App.controller('FileTreeController', function($scope, $modal, ide, $rootScope) {
|
|||
}
|
||||
})
|
||||
|
||||
if (window.showReactFileTree) {
|
||||
window.addEventListener(
|
||||
'FileTreeReactBridge.openNewDocModal',
|
||||
({ detail }) => {
|
||||
if (detail.mode === 'upload') {
|
||||
$scope.openUploadFileModal(detail.parentFolderId)
|
||||
} else {
|
||||
$scope.openNewDocModal(detail.parentFolderId)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
$scope.orderByFoldersFirst = function(entity) {
|
||||
if ((entity != null ? entity.type : undefined) === 'folder') {
|
||||
return '0'
|
||||
|
@ -176,13 +195,27 @@ App.controller('NewFileModalController', function(
|
|||
)
|
||||
}
|
||||
})
|
||||
return $scope.$on('done', (e, opts = {}) => {
|
||||
$scope.$on('done', (e, opts = {}) => {
|
||||
const isBibFile = opts.name && /^.*\.bib$/.test(opts.name)
|
||||
if (opts.shouldReindexReferences || isBibFile) {
|
||||
ide.$scope.$emit('references:should-reindex', {})
|
||||
}
|
||||
$modalInstance.dismiss('done')
|
||||
})
|
||||
|
||||
if (window.showReactFileTree) {
|
||||
window.addEventListener(
|
||||
'FileTreeReactBridge.openNewFileModal',
|
||||
({ detail }) => {
|
||||
if (detail.done) {
|
||||
ide.$scope.FileTreeReactBridgePromise.resolve()
|
||||
}
|
||||
if (detail.error) {
|
||||
ide.$scope.FileTreeReactBridgePromise.reject(detail)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
App.controller('NewDocModalController', function($scope, ide, $timeout) {
|
||||
|
@ -207,6 +240,11 @@ App.controller('NewDocModalController', function($scope, ide, $timeout) {
|
|||
$scope.error = data
|
||||
$scope.state.inflight = false
|
||||
})
|
||||
.finally(function() {
|
||||
if (!$scope.$$phase) {
|
||||
$scope.$apply()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -562,6 +600,11 @@ App.controller('ProjectLinkedFileModalController', function(
|
|||
const { data } = response
|
||||
$scope.error = data
|
||||
})
|
||||
.finally(function() {
|
||||
if (!$scope.$$phase) {
|
||||
$scope.$apply()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -622,6 +665,11 @@ export default App.controller('UrlLinkedFileModalController', function(
|
|||
$scope.error = data
|
||||
return ($scope.state.inflight = false)
|
||||
})
|
||||
.finally(function() {
|
||||
if (!$scope.$$phase) {
|
||||
$scope.$apply()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
18
services/web/frontend/js/infrastructure/auto-focus.js
Normal file
18
services/web/frontend/js/infrastructure/auto-focus.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { createRef, useEffect } from 'react'
|
||||
|
||||
export function useRefWithAutoFocus() {
|
||||
const autoFocusedRef = createRef()
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (autoFocusedRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
if (autoFocusedRef.current) autoFocusedRef.current.focus()
|
||||
})
|
||||
}
|
||||
},
|
||||
[autoFocusedRef]
|
||||
)
|
||||
|
||||
return { autoFocusedRef }
|
||||
}
|
9
services/web/frontend/js/infrastructure/promise.js
Normal file
9
services/web/frontend/js/infrastructure/promise.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
// run `fn` in serie for all values, and resolve with an array of the resultss
|
||||
// inspired by https://stackoverflow.com/a/50506360/1314820
|
||||
export function mapSeries(values, fn) {
|
||||
return values.reduce((promiseChain, value) => {
|
||||
return promiseChain.then(chainResults =>
|
||||
fn(value).then(currentResult => [...chainResults, currentResult])
|
||||
)
|
||||
}, Promise.resolve([]))
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export default function withoutPropagation(callback) {
|
||||
return ev => {
|
||||
ev.stopPropagation()
|
||||
if (callback) callback(ev)
|
||||
}
|
||||
}
|
|
@ -2,7 +2,14 @@ import React from 'react'
|
|||
import PropTypes from 'prop-types'
|
||||
import classNames from 'classnames'
|
||||
|
||||
function Icon({ type, spin, modifier, classes = {}, accessibilityLabel }) {
|
||||
function Icon({
|
||||
type,
|
||||
spin,
|
||||
modifier,
|
||||
classes = {},
|
||||
accessibilityLabel,
|
||||
children
|
||||
}) {
|
||||
const iconClassName = classNames(
|
||||
'fa',
|
||||
`fa-${type}`,
|
||||
|
@ -15,7 +22,9 @@ function Icon({ type, spin, modifier, classes = {}, accessibilityLabel }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<i className={iconClassName} aria-hidden="true" />
|
||||
<i className={iconClassName} aria-hidden="true">
|
||||
{children}
|
||||
</i>
|
||||
{accessibilityLabel ? (
|
||||
<span className="sr-only">{accessibilityLabel}</span>
|
||||
) : null}
|
||||
|
@ -30,7 +39,11 @@ Icon.propTypes = {
|
|||
classes: PropTypes.exact({
|
||||
icon: PropTypes.string
|
||||
}),
|
||||
accessibilityLabel: PropTypes.string
|
||||
accessibilityLabel: PropTypes.string,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
])
|
||||
}
|
||||
|
||||
export default Icon
|
||||
|
|
25
services/web/frontend/js/shared/components/tooltip-button.js
Normal file
25
services/web/frontend/js/shared/components/tooltip-button.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Button, Tooltip, OverlayTrigger } from 'react-bootstrap'
|
||||
|
||||
function TooltipButton({ id, description, onClick, children }) {
|
||||
const tooltip = <Tooltip id={`${id}_tooltip`}>{description}</Tooltip>
|
||||
|
||||
return (
|
||||
<OverlayTrigger placement="bottom" overlay={tooltip}>
|
||||
<Button onClick={onClick}>{children}</Button>
|
||||
</OverlayTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
TooltipButton.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
])
|
||||
}
|
||||
|
||||
export default TooltipButton
|
140
services/web/frontend/stories/file-tree.stories.js
Normal file
140
services/web/frontend/stories/file-tree.stories.js
Normal file
|
@ -0,0 +1,140 @@
|
|||
import React from 'react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import MockedSocket from 'socket.io-mock'
|
||||
|
||||
import { rootFolderBase } from './fixtures/file-tree-base'
|
||||
import { rootFolderLimit } from './fixtures/file-tree-limit'
|
||||
import FileTreeRoot from '../js/features/file-tree/components/file-tree-root'
|
||||
import FileTreeError from '../js/features/file-tree/components/file-tree-error'
|
||||
|
||||
const MOCK_DELAY = 2000
|
||||
|
||||
window._ide = {
|
||||
socket: new MockedSocket()
|
||||
}
|
||||
|
||||
function defaultSetupMocks() {
|
||||
fetchMock
|
||||
.restore()
|
||||
.post(
|
||||
/\/project\/\w+\/(file|doc|folder)\/\w+\/rename/,
|
||||
(path, req) => {
|
||||
const body = JSON.parse(req.body)
|
||||
const entityId = path.match(/([^/]+)\/rename$/)[1]
|
||||
window._ide.socket.socketClient.emit(
|
||||
'reciveEntityRename',
|
||||
entityId,
|
||||
body.name
|
||||
)
|
||||
return 204
|
||||
},
|
||||
|
||||
{
|
||||
delay: MOCK_DELAY
|
||||
}
|
||||
)
|
||||
.post(
|
||||
/\/project\/\w+\/folder/,
|
||||
(_path, req) => {
|
||||
const body = JSON.parse(req.body)
|
||||
const newFolder = {
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
docs: [],
|
||||
_id: Math.random()
|
||||
.toString(16)
|
||||
.replace(/0\./, 'random-test-id-'),
|
||||
name: body.name
|
||||
}
|
||||
window._ide.socket.socketClient.emit(
|
||||
'reciveNewFolder',
|
||||
body.parent_folder_id,
|
||||
newFolder
|
||||
)
|
||||
return newFolder
|
||||
},
|
||||
{
|
||||
delay: MOCK_DELAY
|
||||
}
|
||||
)
|
||||
.delete(
|
||||
/\/project\/\w+\/(file|doc|folder)\/\w+/,
|
||||
path => {
|
||||
const entityId = path.match(/[^/]+$/)[0]
|
||||
window._ide.socket.socketClient.emit('removeEntity', entityId)
|
||||
return 204
|
||||
},
|
||||
{
|
||||
delay: MOCK_DELAY
|
||||
}
|
||||
)
|
||||
.post(/\/project\/\w+\/(file|doc|folder)\/\w+\/move/, (path, req) => {
|
||||
const body = JSON.parse(req.body)
|
||||
const entityId = path.match(/([^/]+)\/move/)[1]
|
||||
window._ide.socket.socketClient.emit(
|
||||
'reciveEntityMove',
|
||||
entityId,
|
||||
body.folder_id
|
||||
)
|
||||
return 204
|
||||
})
|
||||
}
|
||||
|
||||
export const FullTree = args => <FileTreeRoot {...args} />
|
||||
FullTree.parameters = { setupMocks: defaultSetupMocks }
|
||||
|
||||
export const ReadOnly = args => <FileTreeRoot {...args} />
|
||||
ReadOnly.args = { hasWritePermissions: false }
|
||||
|
||||
export const NetworkErrors = args => <FileTreeRoot {...args} />
|
||||
NetworkErrors.parameters = {
|
||||
setupMocks: () => {
|
||||
fetchMock
|
||||
.restore()
|
||||
.post(/\/project\/\w+\/folder/, 500, {
|
||||
delay: MOCK_DELAY
|
||||
})
|
||||
.post(/\/project\/\w+\/(file|doc|folder)\/\w+\/rename/, 500, {
|
||||
delay: MOCK_DELAY
|
||||
})
|
||||
.delete(/\/project\/\w+\/(file|doc|folder)\/\w+/, 500, {
|
||||
delay: MOCK_DELAY
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const FallbackError = args => <FileTreeError {...args} />
|
||||
|
||||
export const FilesLimit = args => <FileTreeRoot {...args} />
|
||||
FilesLimit.args = { rootFolder: rootFolderLimit }
|
||||
FilesLimit.parameters = { setupMocks: defaultSetupMocks }
|
||||
|
||||
export default {
|
||||
title: 'File Tree',
|
||||
component: FileTreeRoot,
|
||||
args: {
|
||||
rootFolder: rootFolderBase,
|
||||
hasWritePermissions: true,
|
||||
projectId: '123abc',
|
||||
rootDocId: '5e74f1a7ce17ae0041dfd056'
|
||||
},
|
||||
argTypes: {
|
||||
onSelect: { action: 'onSelect' }
|
||||
},
|
||||
decorators: [
|
||||
(Story, { parameters: { setupMocks } }) => {
|
||||
if (setupMocks) setupMocks()
|
||||
return <Story />
|
||||
},
|
||||
Story => (
|
||||
<>
|
||||
<style>{'html, body, .file-tree { height: 100%; width: 100%; }'}</style>
|
||||
<div className="editor-sidebar full-size">
|
||||
<div className="file-tree">
|
||||
<Story />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
]
|
||||
}
|
42
services/web/frontend/stories/fixtures/file-tree-base.js
Normal file
42
services/web/frontend/stories/fixtures/file-tree-base.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
export const rootFolderBase = [
|
||||
{
|
||||
_id: '5e74f1a7ce17ae0041dfd054',
|
||||
name: 'rootFolder',
|
||||
folders: [
|
||||
{
|
||||
_id: '5f638e58b652df0026c5c8f5',
|
||||
name: 'a folder',
|
||||
folders: [
|
||||
{
|
||||
_id: '5f956f62700e19000177daa0',
|
||||
name: 'sub folder',
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
docs: []
|
||||
}
|
||||
],
|
||||
fileRefs: [
|
||||
{ _id: '5cffb9d93da45d3995d05362', name: 'file-in-a-folder.pdf' }
|
||||
],
|
||||
docs: [{ _id: '5f46786322d556004e72a555', name: 'doc-in-a-folder.tex' }]
|
||||
},
|
||||
{
|
||||
_id: '5f638e68b652df0026c5c8f6',
|
||||
name: 'another folder',
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
docs: []
|
||||
}
|
||||
],
|
||||
fileRefs: [{ _id: '5f11c78e0924770027412a67', name: 'univers.jpg' }],
|
||||
docs: [
|
||||
{ _id: '5e74f1a7ce17ae0041dfd056', name: 'main.tex' },
|
||||
{ _id: '5f46789522d556004e72a556', name: 'perso.bib' },
|
||||
{
|
||||
_id: '5da532e29019e800015321c6',
|
||||
name: 'zotero.bib',
|
||||
linkedFileData: { provider: 'zotero' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
54
services/web/frontend/stories/fixtures/file-tree-limit.js
Normal file
54
services/web/frontend/stories/fixtures/file-tree-limit.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
const FILE_PER_FOLDER = 2
|
||||
const DOC_PER_FOLDER = 3
|
||||
const FOLDER_PER_FOLDER = 2
|
||||
const MAX_DEPTH = 7
|
||||
|
||||
function fakeId() {
|
||||
return Math.random()
|
||||
.toString(16)
|
||||
.replace(/0\./, 'random-test-id-')
|
||||
}
|
||||
|
||||
function makeFileRefs(path) {
|
||||
const fileRefs = []
|
||||
|
||||
for (let index = 0; index < FILE_PER_FOLDER; index++) {
|
||||
fileRefs.push({ _id: fakeId(), name: `${path}-file-${index}.jpg` })
|
||||
}
|
||||
return fileRefs
|
||||
}
|
||||
|
||||
function makeDocs(path) {
|
||||
const docs = []
|
||||
|
||||
for (let index = 0; index < DOC_PER_FOLDER; index++) {
|
||||
docs.push({ _id: fakeId(), name: `${path}-doc-${index}.tex` })
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
function makeFolders(path, depth = 0) {
|
||||
const folders = []
|
||||
|
||||
for (let index = 0; index < FOLDER_PER_FOLDER; index++) {
|
||||
const folderPath = `${path}-folder-${index}`
|
||||
folders.push({
|
||||
_id: fakeId(),
|
||||
name: folderPath,
|
||||
folders: depth < MAX_DEPTH ? makeFolders(folderPath, depth + 1) : [],
|
||||
fileRefs: makeFileRefs(folderPath),
|
||||
docs: makeDocs(folderPath)
|
||||
})
|
||||
}
|
||||
return folders
|
||||
}
|
||||
|
||||
export const rootFolderLimit = [
|
||||
{
|
||||
_id: fakeId(),
|
||||
name: 'rootFolder',
|
||||
folders: makeFolders('root'),
|
||||
fileRefs: makeFileRefs('root'),
|
||||
docs: makeDocs('root')
|
||||
}
|
||||
]
|
|
@ -27,11 +27,15 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> file-tree-root,
|
||||
.file-tree-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
|
||||
&.no-toolbar {
|
||||
top: 0;
|
||||
|
@ -63,6 +67,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
li.dnd-droppable-hover .entity-name,
|
||||
li .entity-name.droppable-hover {
|
||||
font-weight: bold;
|
||||
background-color: @file-tree-item-hover-bg;
|
||||
|
@ -86,16 +91,39 @@
|
|||
position: relative;
|
||||
overflow-y: auto;
|
||||
|
||||
ul {
|
||||
.entity > ul,
|
||||
ul[role='tree'] {
|
||||
margin-left: (@line-height-computed / 2);
|
||||
}
|
||||
|
||||
li {
|
||||
line-height: @file-tree-line-height;
|
||||
position: relative;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.entity {
|
||||
user-select: none;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.entity > .entity-name > button {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
&.item-name-button {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
padding-right: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.entity-name {
|
||||
|
@ -104,6 +132,12 @@
|
|||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
&.entity-name-react {
|
||||
text-overflow: clip;
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
&:hover {
|
||||
background-color: @file-tree-item-hover-bg;
|
||||
}
|
||||
|
@ -117,6 +151,17 @@
|
|||
input {
|
||||
line-height: 1.6;
|
||||
}
|
||||
.dropdown-toggle > i {
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.entity.dnd-draggable-dragging {
|
||||
.entity-name:hover {
|
||||
background-color: transparent;
|
||||
.fake-full-width-bg(transparent);
|
||||
}
|
||||
}
|
||||
|
||||
i.fa {
|
||||
|
@ -139,6 +184,10 @@
|
|||
font-size: 14px;
|
||||
}
|
||||
|
||||
i.spaced {
|
||||
margin-left: 18px;
|
||||
}
|
||||
|
||||
i.toggle {
|
||||
width: 24px;
|
||||
padding: 6px;
|
||||
|
@ -169,6 +218,7 @@
|
|||
> .entity {
|
||||
> .entity-name {
|
||||
> div > i.fa,
|
||||
> button > i.fa,
|
||||
> i.fa,
|
||||
.entity-menu-toggle i.fa {
|
||||
color: #fff;
|
||||
|
@ -237,6 +287,7 @@
|
|||
> .entity-name {
|
||||
color: @file-tree-item-selected-color;
|
||||
> div > i.fa,
|
||||
> button > i.fa,
|
||||
> i.fa,
|
||||
.entity-menu-toggle i.fa {
|
||||
color: @file-tree-item-selected-color;
|
||||
|
@ -255,14 +306,40 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
ul.file-tree-list li.selected.dnd-droppable-hover {
|
||||
> .entity {
|
||||
> .entity-name {
|
||||
background-color: @file-tree-item-hover-bg;
|
||||
.fake-full-width-bg(@file-tree-item-hover-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dnd-draggable-preview-layer {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&.dnd-droppable-hover {
|
||||
border: 3px solid @file-tree-item-selected-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.dnd-draggable-preview-item,
|
||||
.ui-draggable-dragging {
|
||||
background-color: fade(@file-tree-item-selected-bg, 60%);
|
||||
color: @file-tree-item-selected-color;
|
||||
width: 75%;
|
||||
padding-left: @line-height-computed;
|
||||
}
|
||||
|
||||
.dnd-draggable-preview-item {
|
||||
line-height: @file-tree-line-height;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-new-file {
|
||||
|
@ -301,6 +378,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
.file-tree-error {
|
||||
text-align: center;
|
||||
color: @file-tree-error-color;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.file-tree-modal-alert {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.modal-new-file--body {
|
||||
padding: 20px;
|
||||
padding-top: (@line-height-computed / 4);
|
||||
|
|
|
@ -24,10 +24,14 @@
|
|||
}
|
||||
|
||||
> a:not(.btn),
|
||||
> button,
|
||||
.toolbar-left > a:not(.btn),
|
||||
.toolbar-right > a:not(.btn) {
|
||||
.toolbar-left > button,
|
||||
.toolbar-right > a:not(.btn),
|
||||
.toolbar-right > button {
|
||||
display: inline-block;
|
||||
color: @toolbar-icon-btn-color;
|
||||
background-color: transparent;
|
||||
padding: 4px 2px;
|
||||
line-height: 1;
|
||||
height: 24px;
|
||||
|
@ -40,6 +44,7 @@
|
|||
&:hover {
|
||||
text-shadow: @toolbar-icon-btn-hover-shadow;
|
||||
color: @toolbar-icon-btn-hover-color;
|
||||
background-color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
&.active,
|
||||
|
|
|
@ -1011,6 +1011,7 @@
|
|||
@file-tree-multiselect-bg: @ol-blue;
|
||||
@file-tree-multiselect-hover-bg: @ol-dark-blue;
|
||||
@file-tree-droppable-bg-color: @ol-blue-gray-2;
|
||||
@file-tree-error-color: @ol-blue-gray-1;
|
||||
|
||||
// File outline
|
||||
@outline-v-rhythm: 24px;
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
"log_entry_description": "Log entry with level: __level__",
|
||||
"navigate_log_source": "Navigate to log position in source code: __location__",
|
||||
"other_output_files": "Download other output files",
|
||||
"refresh": "Refresh",
|
||||
"toggle_output_files_list": "Toggle output files list",
|
||||
"n_warnings": "__count__ warning",
|
||||
"n_warnings_plural": "__count__ warnings",
|
||||
|
@ -1324,5 +1325,7 @@
|
|||
"link_to_github": "Link to your GitHub account",
|
||||
"github_integration": "GitHub Integration",
|
||||
"github_is_premium": "GitHub sync is a premium feature",
|
||||
"remote_service_error": "The remote service produced an error"
|
||||
"remote_service_error": "The remote service produced an error",
|
||||
"linked_file": "Imported file",
|
||||
"n_items": "__count__ items"
|
||||
}
|
||||
|
|
105
services/web/package-lock.json
generated
105
services/web/package-lock.json
generated
|
@ -4025,6 +4025,21 @@
|
|||
"react-lifecycles-compat": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"@react-dnd/asap": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz",
|
||||
"integrity": "sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ=="
|
||||
},
|
||||
"@react-dnd/invariant": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz",
|
||||
"integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw=="
|
||||
},
|
||||
"@react-dnd/shallowequal": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz",
|
||||
"integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg=="
|
||||
},
|
||||
"@sentry/browser": {
|
||||
"version": "5.15.4",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-5.15.4.tgz",
|
||||
|
@ -8394,6 +8409,15 @@
|
|||
"integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/hoist-non-react-statics": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
|
||||
"integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==",
|
||||
"requires": {
|
||||
"@types/react": "*",
|
||||
"hoist-non-react-statics": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"@types/html-minifier-terser": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.0.tgz",
|
||||
|
@ -11605,7 +11629,7 @@
|
|||
"bintrees": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz",
|
||||
"integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ="
|
||||
"integrity": "sha512-tbaUB1QpTIj4cKY8c1rvNAvEQXA+ekzHmbe4jzNfW3QWsF9GnnP/BRWyl6/qqS53heoYJ93naaFcm/jooONH8g=="
|
||||
},
|
||||
"bl": {
|
||||
"version": "4.0.3",
|
||||
|
@ -15094,6 +15118,16 @@
|
|||
"integrity": "sha512-xxD4VSH67GbRvSGUrckvha94RD7hjgOH7rqGxiytLpkaeMvixOHFZTGFK6EkIm3T761OVHT8ABHmGkq9gXgu6Q==",
|
||||
"dev": true
|
||||
},
|
||||
"dnd-core": {
|
||||
"version": "11.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-11.1.3.tgz",
|
||||
"integrity": "sha512-QugF55dNW+h+vzxVJ/LSJeTeUw9MCJ2cllhmVThVPEtF16ooBkxj0WBE5RB+AceFxMFo1rO6bJKXtqKl+JNnyA==",
|
||||
"requires": {
|
||||
"@react-dnd/asap": "^4.0.0",
|
||||
"@react-dnd/invariant": "^2.0.0",
|
||||
"redux": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"dns-equal": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz",
|
||||
|
@ -17538,7 +17572,7 @@
|
|||
"findit2": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/findit2/-/findit2-2.2.3.tgz",
|
||||
"integrity": "sha1-WKRmaX34piBc39vzlVNri9d3pfY="
|
||||
"integrity": "sha512-lg/Moejf4qXovVutL0Lz4IsaPoNYMuxt4PA0nGqFxnJ1CTTGGlEO2wKgoDpwknhvZ8k4Q2F+eesgkLbG2Mxfog=="
|
||||
},
|
||||
"flat": {
|
||||
"version": "4.1.0",
|
||||
|
@ -19643,7 +19677,6 @@
|
|||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
|
@ -23877,7 +23910,7 @@
|
|||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==",
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
|
@ -24045,7 +24078,7 @@
|
|||
"module-details-from-path": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz",
|
||||
"integrity": "sha1-EUyUlnPiqKNenTV4hSeqN7Z52is="
|
||||
"integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A=="
|
||||
},
|
||||
"moment": {
|
||||
"version": "2.24.0",
|
||||
|
@ -29465,6 +29498,25 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"react-dnd": {
|
||||
"version": "11.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-11.1.3.tgz",
|
||||
"integrity": "sha512-8rtzzT8iwHgdSC89VktwhqdKKtfXaAyC4wiqp0SywpHG12TTLvfOoL6xNEIUWXwIEWu+CFfDn4GZJyynCEuHIQ==",
|
||||
"requires": {
|
||||
"@react-dnd/shallowequal": "^2.0.0",
|
||||
"@types/hoist-non-react-statics": "^3.3.1",
|
||||
"dnd-core": "^11.1.3",
|
||||
"hoist-non-react-statics": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"react-dnd-html5-backend": {
|
||||
"version": "11.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-11.1.3.tgz",
|
||||
"integrity": "sha512-/1FjNlJbW/ivkUxlxQd7o3trA5DE33QiRZgxent3zKme8DwF4Nbw3OFVhTRFGaYhHFNL1rZt6Rdj1D78BjnNLw==",
|
||||
"requires": {
|
||||
"dnd-core": "^11.1.3"
|
||||
}
|
||||
},
|
||||
"react-docgen": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.3.0.tgz",
|
||||
|
@ -30354,6 +30406,30 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"redux": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz",
|
||||
"integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==",
|
||||
"requires": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"symbol-observable": "^1.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"requires": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"symbol-observable": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
|
||||
"integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"referrer-policy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/referrer-policy/-/referrer-policy-1.2.0.tgz",
|
||||
|
@ -32669,6 +32745,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"socket.io-mock": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-mock/-/socket.io-mock-1.3.1.tgz",
|
||||
"integrity": "sha512-nU3dDOQrYF7tk30jZwR/Unvwh61r/LINKd5cosFSb4Y9E2rcYtNe/uje73hP0xWP5hxosc6BWXb/Mwnvt8wLSg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"component-emitter": "^1.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"component-emitter": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
|
||||
"integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"socket.io-parser": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.2.0.tgz",
|
||||
|
@ -34335,7 +34428,7 @@
|
|||
"tdigest": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz",
|
||||
"integrity": "sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=",
|
||||
"integrity": "sha512-CXcDY/NIgIbKZPx5H4JJNpq6JwJhU5Z4+yWj4ZghDc7/9nVajiRlPPyMXRePPPlBfcayUqtoCXjo7/Hm82ecUA==",
|
||||
"requires": {
|
||||
"bintrees": "1.0.1"
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
"test:unit:run_dir": "mocha --recursive --timeout 25000 --exit --grep=$MOCHA_GREP --file test/unit/bootstrap.js",
|
||||
"test:unit:app": "npm run test:unit:run_dir -- test/unit/src",
|
||||
"test:unit:app:parallel": "parallel --plain --keep-order --halt now,fail=1 npm run test:unit:run_dir -- {} ::: test/unit/src/*",
|
||||
"test:frontend": "NODE_ENV=test TZ=GMT mocha --recursive --exit --grep=$MOCHA_GREP --require test/frontend/bootstrap.js test/frontend modules/*/test/frontend",
|
||||
"test:frontend": "NODE_ENV=test TZ=GMT mocha --recursive --timeout 5000 --exit --grep=$MOCHA_GREP --require test/frontend/bootstrap.js test/frontend modules/*/test/frontend",
|
||||
"test:frontend:coverage": "c8 --all --include 'frontend/js' --include 'modules/*/frontend/js' --exclude 'frontend/js/vendor' --reporter=lcov --reporter=text-summary npm run test:frontend",
|
||||
"test:karma": "karma start",
|
||||
"test:karma:single": "karma start --single-run",
|
||||
|
@ -125,6 +125,8 @@
|
|||
"qrcode": "^1.4.4",
|
||||
"react": "^16.13.1",
|
||||
"react-bootstrap": "^0.33.1",
|
||||
"react-dnd": "^11.1.3",
|
||||
"react-dnd-html5-backend": "^11.1.3",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-error-boundary": "^2.3.1",
|
||||
"react-i18next": "^11.7.1",
|
||||
|
@ -217,6 +219,7 @@
|
|||
"sinon": "^7.5.0",
|
||||
"sinon-chai": "^3.5.0",
|
||||
"sinon-mongoose": "^2.3.0",
|
||||
"socket.io-mock": "^1.3.1",
|
||||
"terser-webpack-plugin": "^2.3.6",
|
||||
"timekeeper": "^2.2.0",
|
||||
"val-loader": "^1.1.1",
|
||||
|
|
|
@ -29,13 +29,11 @@ export function tearDownMathJaxStubs() {
|
|||
|
||||
export function stubChatStore({ user }) {
|
||||
window._ide = { socket: { on: sinon.stub(), off: sinon.stub() } }
|
||||
window.dispatchEvent = sinon.stub()
|
||||
window.user = user
|
||||
resetChatStore()
|
||||
}
|
||||
|
||||
export function tearDownChatStore() {
|
||||
delete window._ide
|
||||
delete window.dispatchEvent
|
||||
delete window.user
|
||||
}
|
||||
|
|
|
@ -30,8 +30,6 @@ describe('ChatStore', function() {
|
|||
window._ide = { socket }
|
||||
mockSocketMessage = message => socket.on.getCall(0).args[1](message)
|
||||
|
||||
window.dispatchEvent = sinon.stub()
|
||||
|
||||
store = new ChatStore()
|
||||
})
|
||||
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import { expect } from 'chai'
|
||||
import React from 'react'
|
||||
import { screen, fireEvent } from '@testing-library/react'
|
||||
import renderWithContext from '../helpers/render-with-context'
|
||||
|
||||
import FileTreeDoc from '../../../../../frontend/js/features/file-tree/components/file-tree-doc'
|
||||
|
||||
describe('<FileTreeDoc/>', function() {
|
||||
it('renders unselected', function() {
|
||||
const { container } = renderWithContext(
|
||||
<FileTreeDoc name="foo.tex" id="123abc" isLinkedFile={false} />
|
||||
)
|
||||
|
||||
screen.getByRole('treeitem', { selected: false })
|
||||
expect(container.querySelector('i.linked-file-highlight')).to.not.exist
|
||||
})
|
||||
|
||||
it('renders selected', function() {
|
||||
renderWithContext(
|
||||
<FileTreeDoc name="foo.tex" id="123abc" isLinkedFile={false} />
|
||||
)
|
||||
|
||||
const treeitem = screen.getByRole('treeitem', { selected: false })
|
||||
fireEvent.click(treeitem)
|
||||
|
||||
screen.getByRole('treeitem', { selected: true })
|
||||
screen.getByRole('menuitem', { name: 'Rename' })
|
||||
screen.getByRole('menuitem', { name: 'Delete' })
|
||||
screen.getByRole('menuitem', { name: 'New File' })
|
||||
screen.getByRole('menuitem', { name: 'New Folder' })
|
||||
screen.getByRole('menuitem', { name: 'Upload' })
|
||||
})
|
||||
|
||||
it('renders as linked file', function() {
|
||||
const { container } = renderWithContext(
|
||||
<FileTreeDoc name="foo.tex" id="123abc" isLinkedFile />
|
||||
)
|
||||
|
||||
screen.getByRole('treeitem')
|
||||
expect(container.querySelector('i.linked-file-highlight')).to.exist
|
||||
})
|
||||
|
||||
it('selects', function() {
|
||||
renderWithContext(<FileTreeDoc name="foo.tex" id="123abc" expanded />)
|
||||
|
||||
const treeitem = screen.getByRole('treeitem', { selected: false })
|
||||
fireEvent.click(treeitem)
|
||||
|
||||
screen.getByRole('treeitem', { selected: true })
|
||||
})
|
||||
|
||||
it('multi-selects', function() {
|
||||
renderWithContext(<FileTreeDoc name="foo.tex" id="123abc" expanded />)
|
||||
|
||||
const treeitem = screen.getByRole('treeitem')
|
||||
|
||||
fireEvent.click(treeitem, { ctrlKey: true })
|
||||
screen.getByRole('treeitem', { selected: true })
|
||||
})
|
||||
})
|
|
@ -0,0 +1,127 @@
|
|||
import { expect } from 'chai'
|
||||
import React from 'react'
|
||||
import { screen, fireEvent } from '@testing-library/react'
|
||||
import renderWithContext from '../helpers/render-with-context'
|
||||
|
||||
import FileTreeFolderList from '../../../../../frontend/js/features/file-tree/components/file-tree-folder-list'
|
||||
|
||||
describe('<FileTreeFolderList/>', function() {
|
||||
it('renders empty', function() {
|
||||
renderWithContext(<FileTreeFolderList folders={[]} docs={[]} files={[]} />)
|
||||
|
||||
screen.queryByRole('tree')
|
||||
expect(screen.queryByRole('treeitem')).to.not.exist
|
||||
})
|
||||
|
||||
it('renders docs, files and folders', function() {
|
||||
const aFolder = {
|
||||
_id: '456def',
|
||||
name: 'A Folder',
|
||||
folders: [],
|
||||
docs: [],
|
||||
fileRefs: []
|
||||
}
|
||||
const aDoc = { _id: '789ghi', name: 'doc.tex', linkedFileData: {} }
|
||||
const aFile = { _id: '987jkl', name: 'file.bib', linkedFileData: {} }
|
||||
renderWithContext(
|
||||
<FileTreeFolderList folders={[aFolder]} docs={[aDoc]} files={[aFile]} />
|
||||
)
|
||||
|
||||
screen.queryByRole('tree')
|
||||
screen.queryByRole('treeitem', { name: 'A Folder' })
|
||||
screen.queryByRole('treeitem', { name: 'doc.tex' })
|
||||
screen.queryByRole('treeitem', { name: 'file.bib' })
|
||||
})
|
||||
|
||||
describe('selection and multi-selection', function() {
|
||||
it('without write permissions', function() {
|
||||
const docs = [{ _id: '1', name: '1.tex' }, { _id: '2', name: '2.tex' }]
|
||||
renderWithContext(
|
||||
<FileTreeFolderList folders={[]} docs={docs} files={[]} />,
|
||||
{ contextProps: { hasWritePermissions: false } }
|
||||
)
|
||||
|
||||
const treeitem1 = screen.getByRole('treeitem', { name: '1.tex' })
|
||||
const treeitem2 = screen.getByRole('treeitem', { name: '2.tex' })
|
||||
|
||||
// click on item 1: it gets selected
|
||||
fireEvent.click(treeitem1)
|
||||
screen.getByRole('treeitem', { name: '1.tex', selected: true })
|
||||
screen.getByRole('treeitem', { name: '2.tex', selected: false })
|
||||
|
||||
// meta-click on item 2: no changes
|
||||
fireEvent.click(treeitem2, { ctrlKey: true })
|
||||
screen.getByRole('treeitem', { name: '1.tex', selected: true })
|
||||
screen.getByRole('treeitem', { name: '2.tex', selected: false })
|
||||
})
|
||||
|
||||
it('with write permissions', function() {
|
||||
const docs = [
|
||||
{ _id: '1', name: '1.tex' },
|
||||
{ _id: '2', name: '2.tex' },
|
||||
{ _id: '3', name: '3.tex' }
|
||||
]
|
||||
renderWithContext(
|
||||
<FileTreeFolderList folders={[]} docs={docs} files={[]} />,
|
||||
{ contextProps: { initialSelectedEntityId: '1' } }
|
||||
)
|
||||
|
||||
const treeitem1 = screen.getByRole('treeitem', { name: '1.tex' })
|
||||
const treeitem2 = screen.getByRole('treeitem', { name: '2.tex' })
|
||||
const treeitem3 = screen.getByRole('treeitem', { name: '3.tex' })
|
||||
|
||||
// item 1 i selected by default
|
||||
screen.getByRole('treeitem', { name: '1.tex', selected: true })
|
||||
screen.getByRole('treeitem', { name: '2.tex', selected: false })
|
||||
screen.getByRole('treeitem', { name: '3.tex', selected: false })
|
||||
|
||||
// click on item 2: it gets selected and item 1 is not selected anymore
|
||||
fireEvent.click(treeitem2)
|
||||
screen.getByRole('treeitem', { name: '1.tex', selected: false })
|
||||
screen.getByRole('treeitem', { name: '2.tex', selected: true })
|
||||
screen.getByRole('treeitem', { name: '3.tex', selected: false })
|
||||
|
||||
// meta-click on item 3: it gets selected and item 2 as well
|
||||
fireEvent.click(treeitem3, { ctrlKey: true })
|
||||
screen.getByRole('treeitem', { name: '1.tex', selected: false })
|
||||
screen.getByRole('treeitem', { name: '2.tex', selected: true })
|
||||
screen.getByRole('treeitem', { name: '3.tex', selected: true })
|
||||
|
||||
// meta-click on item 1: add to selection
|
||||
fireEvent.click(treeitem1, { ctrlKey: true })
|
||||
screen.getByRole('treeitem', { name: '1.tex', selected: true })
|
||||
screen.getByRole('treeitem', { name: '2.tex', selected: true })
|
||||
screen.getByRole('treeitem', { name: '3.tex', selected: true })
|
||||
|
||||
// meta-click on item 1: remove from selection
|
||||
fireEvent.click(treeitem1, { ctrlKey: true })
|
||||
screen.getByRole('treeitem', { name: '1.tex', selected: false })
|
||||
screen.getByRole('treeitem', { name: '2.tex', selected: true })
|
||||
screen.getByRole('treeitem', { name: '3.tex', selected: true })
|
||||
|
||||
// meta-click on item 3: remove from selection
|
||||
fireEvent.click(treeitem3, { ctrlKey: true })
|
||||
screen.getByRole('treeitem', { name: '1.tex', selected: false })
|
||||
screen.getByRole('treeitem', { name: '2.tex', selected: true })
|
||||
screen.getByRole('treeitem', { name: '3.tex', selected: false })
|
||||
|
||||
// meta-click on item 2: cannot unselect
|
||||
fireEvent.click(treeitem2, { ctrlKey: true })
|
||||
screen.getByRole('treeitem', { name: '1.tex', selected: false })
|
||||
screen.getByRole('treeitem', { name: '2.tex', selected: true })
|
||||
screen.getByRole('treeitem', { name: '3.tex', selected: false })
|
||||
|
||||
// meta-click on item 3: add back to selection
|
||||
fireEvent.click(treeitem3, { ctrlKey: true })
|
||||
screen.getByRole('treeitem', { name: '1.tex', selected: false })
|
||||
screen.getByRole('treeitem', { name: '2.tex', selected: true })
|
||||
screen.getByRole('treeitem', { name: '3.tex', selected: true })
|
||||
|
||||
// click on item 3: unselect other items
|
||||
fireEvent.click(treeitem3)
|
||||
screen.getByRole('treeitem', { name: '1.tex', selected: false })
|
||||
screen.getByRole('treeitem', { name: '2.tex', selected: false })
|
||||
screen.getByRole('treeitem', { name: '3.tex', selected: true })
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,66 @@
|
|||
import { expect } from 'chai'
|
||||
import React from 'react'
|
||||
import { screen, fireEvent } from '@testing-library/react'
|
||||
import renderWithContext from '../helpers/render-with-context'
|
||||
|
||||
import FileTreeFolder from '../../../../../frontend/js/features/file-tree/components/file-tree-folder'
|
||||
|
||||
describe('<FileTreeFolder/>', function() {
|
||||
it('renders unselected', function() {
|
||||
renderWithContext(
|
||||
<FileTreeFolder
|
||||
name="foo"
|
||||
id="123abc"
|
||||
folders={[]}
|
||||
docs={[]}
|
||||
files={[]}
|
||||
/>
|
||||
)
|
||||
|
||||
screen.getByRole('treeitem', { selected: false })
|
||||
expect(screen.queryByRole('tree')).to.not.exist
|
||||
})
|
||||
|
||||
it('renders selected', function() {
|
||||
renderWithContext(
|
||||
<FileTreeFolder
|
||||
name="foo"
|
||||
id="123abc"
|
||||
folders={[]}
|
||||
docs={[]}
|
||||
files={[]}
|
||||
/>
|
||||
)
|
||||
|
||||
const treeitem = screen.getByRole('treeitem', { selected: false })
|
||||
fireEvent.click(treeitem)
|
||||
|
||||
screen.getByRole('treeitem', { selected: true })
|
||||
screen.getByRole('menuitem', { name: 'Rename' })
|
||||
screen.getByRole('menuitem', { name: 'Delete' })
|
||||
screen.getByRole('menuitem', { name: 'New File' })
|
||||
screen.getByRole('menuitem', { name: 'New Folder' })
|
||||
screen.getByRole('menuitem', { name: 'Upload' })
|
||||
|
||||
screen.getByRole('treeitem', { selected: true })
|
||||
expect(screen.queryByRole('tree')).to.not.exist
|
||||
})
|
||||
|
||||
it('expands', function() {
|
||||
renderWithContext(
|
||||
<FileTreeFolder
|
||||
name="foo"
|
||||
id="123abc"
|
||||
folders={[]}
|
||||
docs={[]}
|
||||
files={[]}
|
||||
/>
|
||||
)
|
||||
|
||||
screen.getByRole('treeitem')
|
||||
const expandButton = screen.getByRole('button', { name: 'Expand' })
|
||||
|
||||
fireEvent.click(expandButton)
|
||||
screen.getByRole('tree')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,81 @@
|
|||
import { expect } from 'chai'
|
||||
import React from 'react'
|
||||
import sinon from 'sinon'
|
||||
import { screen, fireEvent } from '@testing-library/react'
|
||||
import renderWithContext from '../../helpers/render-with-context'
|
||||
|
||||
import FileTreeitemInner from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner'
|
||||
|
||||
describe('<FileTreeitemInner />', function() {
|
||||
const setContextMenuCoords = sinon.stub()
|
||||
|
||||
beforeEach(function() {
|
||||
global.requestAnimationFrame = sinon.stub()
|
||||
})
|
||||
|
||||
afterEach(function() {
|
||||
setContextMenuCoords.reset()
|
||||
delete global.requestAnimationFrame
|
||||
})
|
||||
|
||||
describe('menu', function() {
|
||||
it('does not display if file is not selected', function() {
|
||||
renderWithContext(
|
||||
<FileTreeitemInner id="123abc" name="bar.tex" isSelected={false} />,
|
||||
{}
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('menu', { visible: false })).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('context menu', function() {
|
||||
it('does not display without write permissions', function() {
|
||||
renderWithContext(
|
||||
<FileTreeitemInner id="123abc" name="bar.tex" isSelected />,
|
||||
{ contextProps: { hasWritePermissions: false } }
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('menu', { visible: false })).to.not.exist
|
||||
})
|
||||
|
||||
it('open / close', function() {
|
||||
const { container } = renderWithContext(
|
||||
<FileTreeitemInner id="123abc" name="bar.tex" isSelected />
|
||||
)
|
||||
|
||||
const entityElement = container.querySelector('div.entity')
|
||||
|
||||
screen.getByRole('menu', { visible: false })
|
||||
|
||||
fireEvent.contextMenu(entityElement)
|
||||
screen.getByRole('menu', { visible: true })
|
||||
|
||||
fireEvent.contextMenu(entityElement)
|
||||
screen.getByRole('menu', { visible: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('name', function() {
|
||||
it('renders name', function() {
|
||||
renderWithContext(
|
||||
<FileTreeitemInner id="123abc" name="bar.tex" isSelected />
|
||||
)
|
||||
|
||||
screen.getByRole('button', { name: 'bar.tex' })
|
||||
expect(screen.queryByRole('textbox')).to.not.exist
|
||||
})
|
||||
|
||||
it('starts rename on menu item click', function() {
|
||||
renderWithContext(
|
||||
<FileTreeitemInner id="123abc" name="bar.tex" isSelected />,
|
||||
{ contextProps: { initialSelectedEntityId: '123abc' } }
|
||||
)
|
||||
|
||||
const renameButton = screen.getByRole('menuitem', { name: 'Rename' })
|
||||
fireEvent.click(renameButton)
|
||||
expect(screen.queryByRole('button', { name: 'bar.tex' })).to.not.exist
|
||||
screen.getByRole('textbox')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,45 @@
|
|||
import React from 'react'
|
||||
import sinon from 'sinon'
|
||||
import { screen, fireEvent } from '@testing-library/react'
|
||||
import renderWithContext from '../../helpers/render-with-context'
|
||||
|
||||
import FileTreeitemMenu from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu'
|
||||
|
||||
describe('<FileTreeitemMenu />', function() {
|
||||
const setContextMenuCoords = sinon.stub()
|
||||
|
||||
afterEach(function() {
|
||||
setContextMenuCoords.reset()
|
||||
})
|
||||
|
||||
it('renders dropdown', function() {
|
||||
renderWithContext(
|
||||
<FileTreeitemMenu
|
||||
id="123abc"
|
||||
setContextMenuCoords={setContextMenuCoords}
|
||||
/>
|
||||
)
|
||||
|
||||
screen.getByRole('button', { name: 'Menu' })
|
||||
screen.getByRole('menu')
|
||||
})
|
||||
|
||||
it('open / close', function() {
|
||||
renderWithContext(
|
||||
<FileTreeitemMenu
|
||||
id="123abc"
|
||||
setContextMenuCoords={setContextMenuCoords}
|
||||
/>
|
||||
)
|
||||
|
||||
const toggleButton = screen.getByRole('button', { name: 'Menu' })
|
||||
|
||||
screen.getByRole('menu', { visible: false })
|
||||
|
||||
fireEvent.click(toggleButton)
|
||||
screen.getByRole('menu', { visible: true })
|
||||
|
||||
fireEvent.click(toggleButton)
|
||||
screen.getByRole('menu', { visible: false })
|
||||
})
|
||||
})
|
|
@ -0,0 +1,80 @@
|
|||
import { expect } from 'chai'
|
||||
import React from 'react'
|
||||
import sinon from 'sinon'
|
||||
import { screen, fireEvent } from '@testing-library/react'
|
||||
import renderWithContext from '../../helpers/render-with-context'
|
||||
|
||||
import FileTreeItemName from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name'
|
||||
|
||||
describe('<FileTreeItemName />', function() {
|
||||
beforeEach(function() {
|
||||
global.requestAnimationFrame = sinon.stub()
|
||||
})
|
||||
|
||||
afterEach(function() {
|
||||
delete global.requestAnimationFrame
|
||||
})
|
||||
|
||||
it('renders name as button', function() {
|
||||
renderWithContext(<FileTreeItemName name="foo.tex" isSelected />)
|
||||
|
||||
screen.getByRole('button', { name: 'foo.tex' })
|
||||
expect(screen.queryByRole('textbox')).to.not.exist
|
||||
})
|
||||
|
||||
it("doesn't start renaming on unselected component", function() {
|
||||
renderWithContext(<FileTreeItemName name="foo.tex" isSelected={false} />)
|
||||
|
||||
const button = screen.queryByRole('button')
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
fireEvent.doubleClick(button)
|
||||
expect(screen.queryByRole('textbox')).to.not.exist
|
||||
})
|
||||
|
||||
it('start renaming on double-click', function() {
|
||||
renderWithContext(<FileTreeItemName name="foo.tex" isSelected />)
|
||||
|
||||
const button = screen.queryByRole('button')
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
fireEvent.doubleClick(button)
|
||||
screen.getByRole('textbox')
|
||||
expect(screen.queryByRole('button')).to.not.exist
|
||||
expect(global.requestAnimationFrame).to.be.calledOnce
|
||||
})
|
||||
|
||||
it('cannot start renaming in read-only', function() {
|
||||
renderWithContext(<FileTreeItemName name="foo.tex" isSelected />, {
|
||||
contextProps: { hasWritePermissions: false }
|
||||
})
|
||||
|
||||
const button = screen.queryByRole('button')
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
fireEvent.doubleClick(button)
|
||||
|
||||
expect(screen.queryByRole('textbox')).to.not.exist
|
||||
})
|
||||
|
||||
describe('stop renaming', function() {
|
||||
beforeEach(function() {
|
||||
renderWithContext(<FileTreeItemName name="foo.tex" isSelected />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
fireEvent.doubleClick(button)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'bar.tex' } })
|
||||
})
|
||||
|
||||
it('on Escape', function() {
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
|
||||
screen.getByRole('button', { name: 'foo.tex' })
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,123 @@
|
|||
import React from 'react'
|
||||
import sinon from 'sinon'
|
||||
import { screen, render, fireEvent } from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
|
||||
describe('<FileTreeRoot/>', function() {
|
||||
const onSelect = sinon.stub()
|
||||
const onInit = sinon.stub()
|
||||
|
||||
beforeEach(function() {
|
||||
global.requestAnimationFrame = sinon.stub()
|
||||
})
|
||||
|
||||
afterEach(function() {
|
||||
delete global.requestAnimationFrame
|
||||
fetchMock.restore()
|
||||
onSelect.reset()
|
||||
onInit.reset()
|
||||
})
|
||||
|
||||
it('renders', function() {
|
||||
const rootFolder = [
|
||||
{
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: []
|
||||
}
|
||||
]
|
||||
render(
|
||||
<FileTreeRoot
|
||||
rootFolder={rootFolder}
|
||||
projectId={'123abc'}
|
||||
hasWritePermissions={false}
|
||||
rootDocId="456def"
|
||||
onSelect={onSelect}
|
||||
onInit={onInit}
|
||||
/>
|
||||
)
|
||||
|
||||
screen.queryByRole('tree')
|
||||
screen.getByRole('treeitem')
|
||||
screen.getByRole('treeitem', { name: 'main.tex', selected: true })
|
||||
})
|
||||
|
||||
it('fire onSelect', function() {
|
||||
const rootFolder = [
|
||||
{
|
||||
docs: [
|
||||
{ _id: '456def', name: 'main.tex' },
|
||||
{ _id: '789ghi', name: 'other.tex' }
|
||||
],
|
||||
folders: [],
|
||||
fileRefs: []
|
||||
}
|
||||
]
|
||||
render(
|
||||
<FileTreeRoot
|
||||
rootFolder={rootFolder}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
hasWritePermissions={false}
|
||||
onSelect={onSelect}
|
||||
onInit={onInit}
|
||||
/>
|
||||
)
|
||||
sinon.assert.calledOnce(onSelect)
|
||||
sinon.assert.calledWithMatch(onSelect, [
|
||||
sinon.match({
|
||||
entity: {
|
||||
_id: '456def',
|
||||
name: 'main.tex'
|
||||
}
|
||||
})
|
||||
])
|
||||
onSelect.reset()
|
||||
|
||||
screen.queryByRole('tree')
|
||||
const treeitem = screen.getByRole('treeitem', { name: 'other.tex' })
|
||||
fireEvent.click(treeitem)
|
||||
sinon.assert.calledOnce(onSelect)
|
||||
sinon.assert.calledWithMatch(onSelect, [
|
||||
sinon.match({
|
||||
entity: {
|
||||
_id: '789ghi',
|
||||
name: 'other.tex'
|
||||
}
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('listen to editor.openDoc', function() {
|
||||
const rootFolder = [
|
||||
{
|
||||
docs: [
|
||||
{ _id: '456def', name: 'main.tex' },
|
||||
{ _id: '789ghi', name: 'other.tex' }
|
||||
],
|
||||
folders: [],
|
||||
fileRefs: []
|
||||
}
|
||||
]
|
||||
render(
|
||||
<FileTreeRoot
|
||||
rootFolder={rootFolder}
|
||||
projectId="123abc"
|
||||
rootDocId="456def"
|
||||
hasWritePermissions={false}
|
||||
onSelect={onSelect}
|
||||
onInit={onInit}
|
||||
/>
|
||||
)
|
||||
|
||||
screen.getByRole('treeitem', { name: 'main.tex', selected: true })
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('editor.openDoc', { detail: '789ghi' })
|
||||
)
|
||||
screen.getByRole('treeitem', { name: 'main.tex', selected: false })
|
||||
screen.getByRole('treeitem', { name: 'other.tex', selected: true })
|
||||
})
|
||||
})
|
|
@ -0,0 +1,35 @@
|
|||
import { expect } from 'chai'
|
||||
import React from 'react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import renderWithContext from '../helpers/render-with-context'
|
||||
|
||||
import FileTreeToolbar from '../../../../../frontend/js/features/file-tree/components/file-tree-toolbar'
|
||||
|
||||
describe('<FileTreeToolbar/>', function() {
|
||||
it('without selected files', function() {
|
||||
renderWithContext(<FileTreeToolbar />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'New File' })).to.not.exist
|
||||
expect(screen.queryByRole('button', { name: 'New Folder' })).to.not.exist
|
||||
expect(screen.queryByRole('button', { name: 'Upload' })).to.not.exist
|
||||
expect(screen.queryByRole('button', { name: 'Rename' })).to.not.exist
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).to.not.exist
|
||||
})
|
||||
|
||||
it('read-only', function() {
|
||||
renderWithContext(<FileTreeToolbar />, {
|
||||
contextProps: { hasWritePermissions: false }
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('button')).to.not.exist
|
||||
})
|
||||
|
||||
it('with one selected file', function() {
|
||||
renderWithContext(<FileTreeToolbar />, {
|
||||
contextProps: { initialSelectedEntityId: '123abc' }
|
||||
})
|
||||
|
||||
screen.getByRole('button', { name: 'Rename' })
|
||||
screen.getByRole('button', { name: 'Delete' })
|
||||
})
|
||||
})
|
|
@ -0,0 +1,65 @@
|
|||
import { expect } from 'chai'
|
||||
import React from 'react'
|
||||
import sinon from 'sinon'
|
||||
import { screen, render, fireEvent } from '@testing-library/react'
|
||||
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
|
||||
describe('FileTree Context Menu Flow', function() {
|
||||
const onSelect = sinon.stub()
|
||||
const onInit = sinon.stub()
|
||||
|
||||
it('opens on contextMenu event', async function() {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: []
|
||||
}
|
||||
]
|
||||
render(
|
||||
<FileTreeRoot
|
||||
rootFolder={rootFolder}
|
||||
projectId="123abc"
|
||||
hasWritePermissions
|
||||
rootDocId="456def"
|
||||
onSelect={onSelect}
|
||||
onInit={onInit}
|
||||
/>
|
||||
)
|
||||
const treeitem = screen.getByRole('button', { name: 'main.tex' })
|
||||
|
||||
expect(screen.getAllByRole('menu').length).to.equal(1) // toolbar
|
||||
|
||||
fireEvent.contextMenu(treeitem)
|
||||
|
||||
expect(screen.getAllByRole('menu').length).to.equal(2) // toolbar + menu
|
||||
})
|
||||
|
||||
it("doesn't open in read only mode", async function() {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: []
|
||||
}
|
||||
]
|
||||
render(
|
||||
<FileTreeRoot
|
||||
rootFolder={rootFolder}
|
||||
projectId="123abc"
|
||||
hasWritePermissions={false}
|
||||
rootDocId="456def"
|
||||
onSelect={onSelect}
|
||||
onInit={onInit}
|
||||
/>
|
||||
)
|
||||
const treeitem = screen.getByRole('button', { name: 'main.tex' })
|
||||
|
||||
fireEvent.contextMenu(treeitem)
|
||||
|
||||
expect(screen.queryByRole('menu')).to.not.exist
|
||||
})
|
||||
})
|
|
@ -0,0 +1,231 @@
|
|||
import { expect } from 'chai'
|
||||
import React from 'react'
|
||||
import sinon from 'sinon'
|
||||
import { screen, render, fireEvent } from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import MockedSocket from 'socket.io-mock'
|
||||
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
|
||||
describe('FileTree Create Folder Flow', function() {
|
||||
const onSelect = sinon.stub()
|
||||
const onInit = sinon.stub()
|
||||
|
||||
beforeEach(function() {
|
||||
global.requestAnimationFrame = sinon.stub()
|
||||
window._ide = {
|
||||
socket: new MockedSocket()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(function() {
|
||||
delete global.requestAnimationFrame
|
||||
fetchMock.restore()
|
||||
onSelect.reset()
|
||||
onInit.reset()
|
||||
delete window._ide
|
||||
})
|
||||
|
||||
it('add to root', async function() {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: []
|
||||
}
|
||||
]
|
||||
render(
|
||||
<FileTreeRoot
|
||||
rootFolder={rootFolder}
|
||||
projectId="123abc"
|
||||
hasWritePermissions
|
||||
rootDocId="456def"
|
||||
onSelect={onSelect}
|
||||
onInit={onInit}
|
||||
/>
|
||||
)
|
||||
|
||||
const newFolderName = 'Foo Bar In Root'
|
||||
const matcher = /\/project\/\w+\/folder/
|
||||
const response = {
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
docs: [],
|
||||
_id: fakeId(),
|
||||
name: newFolderName
|
||||
}
|
||||
fetchMock.post(matcher, response)
|
||||
|
||||
fireCreateFolder(newFolderName)
|
||||
|
||||
const lastCallBody = JSON.parse(fetchMock.lastCall(matcher)[1].body)
|
||||
expect(lastCallBody.name).to.equal(newFolderName)
|
||||
expect(lastCallBody.parent_folder_id).to.equal('root-folder-id')
|
||||
|
||||
window._ide.socket.socketClient.emit('reciveNewFolder', 'root-folder-id', {
|
||||
_id: fakeId(),
|
||||
name: newFolderName,
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
folders: []
|
||||
})
|
||||
await screen.findByRole('treeitem', { name: newFolderName })
|
||||
})
|
||||
|
||||
it('add to folder from folder', async function() {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
docs: [],
|
||||
folders: [
|
||||
{
|
||||
_id: '789ghi',
|
||||
name: 'thefolder',
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
folders: []
|
||||
}
|
||||
],
|
||||
fileRefs: []
|
||||
}
|
||||
]
|
||||
render(
|
||||
<FileTreeRoot
|
||||
rootFolder={rootFolder}
|
||||
projectId="123abc"
|
||||
hasWritePermissions
|
||||
rootDocId="789ghi"
|
||||
onSelect={onSelect}
|
||||
onInit={onInit}
|
||||
/>
|
||||
)
|
||||
|
||||
const expandButton = screen.getByRole('button', { name: 'Expand' })
|
||||
fireEvent.click(expandButton)
|
||||
|
||||
const newFolderName = 'Foo Bar In thefolder'
|
||||
const matcher = /\/project\/\w+\/folder/
|
||||
const response = {
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
docs: [],
|
||||
_id: fakeId(),
|
||||
name: newFolderName
|
||||
}
|
||||
fetchMock.post(matcher, response)
|
||||
|
||||
fireCreateFolder(newFolderName)
|
||||
|
||||
const lastCallBody = JSON.parse(fetchMock.lastCall(matcher)[1].body)
|
||||
expect(lastCallBody.name).to.equal(newFolderName)
|
||||
expect(lastCallBody.parent_folder_id).to.equal('789ghi')
|
||||
|
||||
window._ide.socket.socketClient.emit('reciveNewFolder', '789ghi', {
|
||||
_id: fakeId(),
|
||||
name: newFolderName,
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
folders: []
|
||||
})
|
||||
|
||||
// find the created folder
|
||||
await screen.findByRole('treeitem', { name: newFolderName })
|
||||
|
||||
// collapse the parent folder; created folder should not be rendered anymore
|
||||
fireEvent.click(expandButton)
|
||||
expect(screen.queryByRole('treeitem', { name: newFolderName })).to.not.exist
|
||||
})
|
||||
|
||||
it('add to folder from child', async function() {
|
||||
const rootFolder = [
|
||||
{
|
||||
_id: 'root-folder-id',
|
||||
docs: [],
|
||||
folders: [
|
||||
{
|
||||
_id: '789ghi',
|
||||
name: 'thefolder',
|
||||
docs: [],
|
||||
fileRefs: [{ _id: '456def', name: 'sub.tex' }],
|
||||
folders: []
|
||||
}
|
||||
],
|
||||
fileRefs: []
|
||||
}
|
||||
]
|
||||
render(
|
||||
<FileTreeRoot
|
||||
rootFolder={rootFolder}
|
||||
projectId="123abc"
|
||||
hasWritePermissions
|
||||
rootDocId="456def"
|
||||
onSelect={onSelect}
|
||||
onInit={onInit}
|
||||
/>
|
||||
)
|
||||
|
||||
const expandButton = screen.getByRole('button', { name: 'Expand' })
|
||||
fireEvent.click(expandButton)
|
||||
|
||||
const newFolderName = 'Foo Bar In thefolder'
|
||||
const matcher = /\/project\/\w+\/folder/
|
||||
const response = {
|
||||
folders: [],
|
||||
fileRefs: [],
|
||||
docs: [],
|
||||
_id: fakeId(),
|
||||
name: newFolderName
|
||||
}
|
||||
fetchMock.post(matcher, response)
|
||||
|
||||
fireCreateFolder(newFolderName)
|
||||
|
||||
const lastCallBody = JSON.parse(fetchMock.lastCall(matcher)[1].body)
|
||||
expect(lastCallBody.name).to.equal(newFolderName)
|
||||
expect(lastCallBody.parent_folder_id).to.equal('789ghi')
|
||||
|
||||
window._ide.socket.socketClient.emit('reciveNewFolder', '789ghi', {
|
||||
_id: fakeId(),
|
||||
name: newFolderName,
|
||||
docs: [],
|
||||
fileRefs: [],
|
||||
folders: []
|
||||
})
|
||||
|
||||
// find the created folder
|
||||
await screen.findByRole('treeitem', { name: newFolderName })
|
||||
|
||||
// collapse the parent folder; created folder should not be rendered anymore
|
||||
fireEvent.click(expandButton)
|
||||
expect(screen.queryByRole('treeitem', { name: newFolderName })).to.not.exist
|
||||
})
|
||||
|
||||
function fireCreateFolder(name) {
|
||||
const createFolderButton = screen.getByRole('button', {
|
||||
name: 'New Folder'
|
||||
})
|
||||
fireEvent.click(createFolderButton)
|
||||
|
||||
const input = screen.getByRole('textbox', {
|
||||
hidden: true // FIXME: modal should not be hidden but it has the aria-hidden label due to a react-bootstrap bug
|
||||
})
|
||||
fireEvent.change(input, { target: { value: name } })
|
||||
|
||||
const modalCreateButton = getModalCreateButton()
|
||||
fireEvent.click(modalCreateButton)
|
||||
}
|
||||
|
||||
function fakeId() {
|
||||
return Math.random()
|
||||
.toString(16)
|
||||
.replace(/0\./, 'random-test-id-')
|
||||
}
|
||||
|
||||
function getModalCreateButton() {
|
||||
return screen.getAllByRole('button', {
|
||||
name: 'Create',
|
||||
hidden: true // FIXME: modal should not be hidden but it has the aria-hidden label due to a react-bootstrap bug
|
||||
})[0] // the first matched button is the toolbar button
|
||||
}
|
||||
})
|
|
@ -0,0 +1,188 @@
|
|||
import { expect } from 'chai'
|
||||
import React from 'react'
|
||||
import sinon from 'sinon'
|
||||
import { screen, render, fireEvent, waitFor } from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import MockedSocket from 'socket.io-mock'
|
||||
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
|
||||
describe('FileTree Delete Entity Flow', function() {
|
||||
const onSelect = sinon.stub()
|
||||
const onInit = sinon.stub()
|
||||
|
||||
beforeEach(function() {
|
||||
window._ide = {
|
||||
socket: new MockedSocket()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(function() {
|
||||
fetchMock.restore()
|
||||
onSelect.reset()
|
||||
onInit.reset()
|
||||
delete window._ide
|
||||
})
|
||||
|
||||
describe('single entity', function() {
|
||||
beforeEach(function() {
|
||||
const rootFolder = [
|
||||
{
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: []
|
||||
}
|
||||
]
|
||||
render(
|
||||
<FileTreeRoot
|
||||
rootFolder={rootFolder}
|
||||
projectId={'123abc'}
|
||||
hasWritePermissions
|
||||
onSelect={onSelect}
|
||||
onInit={onInit}
|
||||
/>
|
||||
)
|
||||
|
||||
const treeitem = screen.getByRole('treeitem', { name: 'main.tex' })
|
||||
fireEvent.click(treeitem)
|
||||
|
||||
const deleteButton = screen.getByRole('menuitem', { name: 'Delete' })
|
||||
fireEvent.click(deleteButton)
|
||||
})
|
||||
|
||||
it('removes item', async function() {
|
||||
const fetchMatcher = /\/project\/\w+\/doc\/\w+/
|
||||
fetchMock.delete(fetchMatcher, 204)
|
||||
|
||||
const modalDeleteButton = getModalDeleteButton()
|
||||
fireEvent.click(modalDeleteButton)
|
||||
|
||||
window._ide.socket.socketClient.emit('removeEntity', '456def')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('treeitem', {
|
||||
name: 'main.tex',
|
||||
hidden: true // treeitem might be hidden behind the modal
|
||||
})
|
||||
).to.not.exist
|
||||
|
||||
expect(
|
||||
screen.queryByRole('treeitem', {
|
||||
name: 'main.tex'
|
||||
})
|
||||
).to.not.exist
|
||||
})
|
||||
|
||||
const [lastFetchPath] = fetchMock.lastCall(fetchMatcher)
|
||||
expect(lastFetchPath).to.equal('/project/123abc/doc/456def')
|
||||
})
|
||||
|
||||
it('continues delete on 404s', async function() {
|
||||
fetchMock.delete(/\/project\/\w+\/doc\/\w+/, 404)
|
||||
|
||||
const modalDeleteButton = getModalDeleteButton()
|
||||
fireEvent.click(modalDeleteButton)
|
||||
|
||||
window._ide.socket.socketClient.emit('removeEntity', '456def')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('treeitem', {
|
||||
name: 'main.tex',
|
||||
hidden: true // treeitem might be hidden behind the modal
|
||||
})
|
||||
).to.not.exist
|
||||
|
||||
expect(
|
||||
screen.queryByRole('treeitem', {
|
||||
name: 'main.tex'
|
||||
})
|
||||
).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
it('aborts delete on error', async function() {
|
||||
const fetchMatcher = /\/project\/\w+\/doc\/\w+/
|
||||
fetchMock.delete(fetchMatcher, 500)
|
||||
|
||||
const modalDeleteButton = getModalDeleteButton()
|
||||
fireEvent.click(modalDeleteButton)
|
||||
|
||||
// The modal should still be open, but the file should not be deleted
|
||||
await screen.findByRole('treeitem', { name: 'main.tex', hidden: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple entities', function() {
|
||||
beforeEach(function() {
|
||||
const rootFolder = [
|
||||
{
|
||||
docs: [{ _id: '456def', name: 'main.tex' }],
|
||||
folders: [],
|
||||
fileRefs: [{ _id: '789ghi', name: 'my.bib' }]
|
||||
}
|
||||
]
|
||||
render(
|
||||
<FileTreeRoot
|
||||
rootFolder={rootFolder}
|
||||
projectId={'123abc'}
|
||||
hasWritePermissions
|
||||
onSelect={onSelect}
|
||||
onInit={onInit}
|
||||
/>
|
||||
)
|
||||
|
||||
const treeitemDoc = screen.getByRole('treeitem', { name: 'main.tex' })
|
||||
fireEvent.click(treeitemDoc)
|
||||
const treeitemFile = screen.getByRole('treeitem', { name: 'my.bib' })
|
||||
fireEvent.click(treeitemFile, { ctrlKey: true })
|
||||
|
||||
const deleteButton = screen.getAllByRole('menuitem', {
|
||||
name: 'Delete'
|
||||
})[0]
|
||||
fireEvent.click(deleteButton)
|
||||
})
|
||||
|
||||
it('removes all items', async function() {
|
||||
const fetchMatcher = /\/project\/\w+\/(doc|file)\/\w+/
|
||||
fetchMock.delete(fetchMatcher, 204)
|
||||
|
||||
const modalDeleteButton = getModalDeleteButton()
|
||||
fireEvent.click(modalDeleteButton)
|
||||
|
||||
window._ide.socket.socketClient.emit('removeEntity', '456def')
|
||||
window._ide.socket.socketClient.emit('removeEntity', '789ghi')
|
||||
|
||||
await waitFor(() => {
|
||||
for (const name of ['main.tex', 'my.bib']) {
|
||||
expect(
|
||||
screen.queryByRole('treeitem', {
|
||||
name,
|
||||
hidden: true // treeitem might be hidden behind the modal
|
||||
})
|
||||
).to.not.exist
|
||||
|
||||
expect(
|
||||
screen.queryByRole('treeitem', {
|
||||
name
|
||||
})
|
||||
).to.not.exist
|
||||
}
|
||||
})
|
||||
|
||||
const [firstFetchPath, secondFetchPath] = fetchMock
|
||||
.calls()
|
||||
.map(([url]) => url)
|
||||
expect(firstFetchPath).to.equal('/project/123abc/doc/456def')
|
||||
expect(secondFetchPath).to.equal('/project/123abc/file/789ghi')
|
||||
})
|
||||
})
|
||||
|
||||
function getModalDeleteButton() {
|
||||
return screen.getAllByRole('button', {
|
||||
name: 'Delete',
|
||||
hidden: true // FIXME: modal should not be hidden but it has the aria-hidden label due to a react-bootstrap bug
|
||||
})[1] // the first matched button is the toolbar button
|
||||
}
|
||||
})
|
|
@ -0,0 +1,140 @@
|
|||
import { expect } from 'chai'
|
||||
import React from 'react'
|
||||
import sinon from 'sinon'
|
||||
import { screen, render, fireEvent } from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import MockedSocket from 'socket.io-mock'
|
||||
|
||||
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
|
||||
|
||||
describe('FileTree Rename Entity Flow', function() {
|
||||
const onSelect = sinon.stub()
|
||||
const onInit = sinon.stub()
|
||||
|
||||
beforeEach(function() {
|
||||
window._ide = {
|
||||
socket: new MockedSocket()
|
||||
}
|
||||
global.requestAnimationFrame = sinon.stub()
|
||||
})
|
||||
|
||||
afterEach(function() {
|
||||
delete global.requestAnimationFrame
|
||||
fetchMock.restore()
|
||||
onSelect.reset()
|
||||
onInit.reset()
|
||||
delete window._ide
|
||||
})
|
||||
|
||||
beforeEach(function() {
|
||||
const rootFolder = [
|
||||
{
|
||||
docs: [{ _id: '456def', name: 'a.tex' }],
|
||||
folders: [
|
||||
{
|
||||
_id: '987jkl',
|
||||
name: 'folder',
|
||||
docs: [],
|
||||
fileRefs: [{ _id: '789ghi', name: 'c.tex' }],
|
||||
folders: []
|
||||
}
|
||||
],
|
||||
fileRefs: []
|
||||
}
|
||||
]
|
||||
render(
|
||||
<FileTreeRoot
|
||||
rootFolder={rootFolder}
|
||||
projectId={'123abc'}
|
||||
hasWritePermissions
|
||||
onSelect={onSelect}
|
||||
onInit={onInit}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
it('renames doc', function() {
|
||||
const fetchMatcher = /\/project\/\w+\/doc\/\w+\/rename/
|
||||
fetchMock.post(fetchMatcher, 204)
|
||||
|
||||
const input = initItemRename('a.tex')
|
||||
fireEvent.change(input, { target: { value: 'b.tex' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
screen.getByRole('treeitem', { name: 'b.tex' })
|
||||
|
||||
const lastFetchBody = getLastFetchBody(fetchMatcher)
|
||||
expect(lastFetchBody.name).to.equal('b.tex')
|
||||
})
|
||||
|
||||
it('renames folder', function() {
|
||||
const fetchMatcher = /\/project\/\w+\/folder\/\w+\/rename/
|
||||
fetchMock.post(fetchMatcher, 204)
|
||||
|
||||
const input = initItemRename('folder')
|
||||
fireEvent.change(input, { target: { value: 'new folder name' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
screen.getByRole('treeitem', { name: 'new folder name' })
|
||||
|
||||
const lastFetchBody = getLastFetchBody(fetchMatcher)
|
||||
expect(lastFetchBody.name).to.equal('new folder name')
|
||||
})
|
||||
|
||||
it('renames file in subfolder', function() {
|
||||
const fetchMatcher = /\/project\/\w+\/file\/\w+\/rename/
|
||||
fetchMock.post(fetchMatcher, 204)
|
||||
|
||||
const expandButton = screen.getByRole('button', { name: 'Expand' })
|
||||
fireEvent.click(expandButton)
|
||||
|
||||
const input = initItemRename('c.tex')
|
||||
fireEvent.change(input, { target: { value: 'd.tex' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
screen.getByRole('treeitem', { name: 'folder' })
|
||||
screen.getByRole('treeitem', { name: 'd.tex' })
|
||||
|
||||
const lastFetchBody = getLastFetchBody(fetchMatcher)
|
||||
expect(lastFetchBody.name).to.equal('d.tex')
|
||||
})
|
||||
|
||||
it('reverts rename on error', async function() {
|
||||
const fetchMatcher = /\/project\/\w+\/doc\/\w+\/rename/
|
||||
fetchMock.post(fetchMatcher, 500)
|
||||
|
||||
const input = initItemRename('a.tex')
|
||||
fireEvent.change(input, { target: { value: 'b.tex' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
screen.getByRole('treeitem', { name: 'b.tex' })
|
||||
})
|
||||
|
||||
describe('via socket event', function() {
|
||||
it('renames doc', function() {
|
||||
screen.getByRole('treeitem', { name: 'a.tex' })
|
||||
|
||||
window._ide.socket.socketClient.emit(
|
||||
'reciveEntityRename',
|
||||
'456def',
|
||||
'socket.tex'
|
||||
)
|
||||
|
||||
screen.getByRole('treeitem', { name: 'socket.tex' })
|
||||
})
|
||||
})
|
||||
|
||||
function initItemRename(treeitemName) {
|
||||
const treeitem = screen.getByRole('treeitem', { name: treeitemName })
|
||||
fireEvent.click(treeitem)
|
||||
|
||||
const renameButton = screen.getByRole('menuitem', { name: 'Rename' })
|
||||
fireEvent.click(renameButton)
|
||||
|
||||
return screen.getByRole('textbox')
|
||||
}
|
||||
function getLastFetchBody(matcher) {
|
||||
const [, { body }] = fetchMock.lastCall(matcher)
|
||||
return JSON.parse(body)
|
||||
}
|
||||
})
|
|
@ -0,0 +1,18 @@
|
|||
import React from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import FileTreeContext from '../../../../../frontend/js/features/file-tree/components/file-tree-context'
|
||||
|
||||
export default (children, options = {}) => {
|
||||
let { contextProps = {}, ...renderOptions } = options
|
||||
contextProps = {
|
||||
projectId: '123abc',
|
||||
rootFolder: [{}],
|
||||
hasWritePermissions: true,
|
||||
onSelect: () => {},
|
||||
...contextProps
|
||||
}
|
||||
return render(
|
||||
<FileTreeContext {...contextProps}>{children}</FileTreeContext>,
|
||||
renderOptions
|
||||
)
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { expect } from 'chai'
|
||||
|
||||
import iconTypeFromName from '../../../../../frontend/js/features/file-tree/util/icon-type-from-name'
|
||||
|
||||
describe('iconTypeFromName', function() {
|
||||
it('returns correct icon type', function() {
|
||||
expect(iconTypeFromName('main.tex')).to.equal('file')
|
||||
expect(iconTypeFromName('main.png')).to.equal('image')
|
||||
expect(iconTypeFromName('main.csv')).to.equal('table')
|
||||
expect(iconTypeFromName('main.py')).to.equal('file-text')
|
||||
expect(iconTypeFromName('main.bib')).to.equal('book')
|
||||
})
|
||||
|
||||
it('handles missing extensions', function() {
|
||||
expect(iconTypeFromName('main')).to.equal('file')
|
||||
})
|
||||
|
||||
it('lowercases extension', function() {
|
||||
expect(iconTypeFromName('ZOTERO.BIB')).to.equal('book')
|
||||
})
|
||||
})
|
|
@ -43,4 +43,16 @@ describe('<Icon />', function() {
|
|||
)
|
||||
expect(element).to.exist
|
||||
})
|
||||
|
||||
it('renders children', function() {
|
||||
const { container } = render(
|
||||
<Icon type="angle-down">
|
||||
<Icon type="angle-up" />
|
||||
</Icon>
|
||||
)
|
||||
const element = container.querySelector(
|
||||
'i.fa.fa-angle-down > i.fa.fa-angle-up'
|
||||
)
|
||||
expect(element).to.exist
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue