diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js
index 8ceb8fbff7..77bbb1dd38 100644
--- a/services/web/app/src/Features/Project/ProjectController.js
+++ b/services/web/app/src/Features/Project/ProjectController.js
@@ -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()
}
diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug
index 9fe51ed2ac..473825b426 100644
--- a/services/web/app/views/project/editor.pug
+++ b/services/web/app/views/project/editor.pug
@@ -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').
diff --git a/services/web/app/views/project/editor/file-tree-react.pug b/services/web/app/views/project/editor/file-tree-react.pug
new file mode 100644
index 0000000000..feb7b04bcc
--- /dev/null
+++ b/services/web/app/views/project/editor/file-tree-react.pug
@@ -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"
+ )
diff --git a/services/web/app/views/project/editor/header.pug b/services/web/app/views/project/editor/header.pug
index f43e6b3ec2..8c0a8f8523 100644
--- a/services/web/app/views/project/editor/header.pug
+++ b/services/web/app/views/project/editor/header.pug
@@ -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",
diff --git a/services/web/frontend/extracted-translation-keys.json b/services/web/frontend/extracted-translation-keys.json
index c6eced6c9c..037760b973 100644
--- a/services/web/frontend/extracted-translation-keys.json
+++ b/services/web/frontend/extracted-translation-keys.json
@@ -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"
]
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.js b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.js
new file mode 100644
index 0000000000..2987579420
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.js
@@ -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(
+ ,
+ 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
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context.js b/services/web/frontend/js/features/file-tree/components/file-tree-context.js
new file mode 100644
index 0000000000..4f0e46bfc6
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-context.js
@@ -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 (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+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
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-doc.js b/services/web/frontend/js/features/file-tree/components/file-tree-doc.js
new file mode 100644
index 0000000000..78a88b1a47
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-doc.js
@@ -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 = (
+ <>
+
+ {isLinkedFile ? (
+
+ ) : null}
+
+ >
+ )
+ return (
+
+
+
+ )
+}
+
+FileTreeDoc.propTypes = {
+ name: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
+ isLinkedFile: PropTypes.bool
+}
+
+export default FileTreeDoc
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-draggable-preview-layer.js b/services/web/frontend/js/features/file-tree/components/file-tree-draggable-preview-layer.js
new file mode 100644
index 0000000000..7b403c19a4
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-draggable-preview-layer.js
@@ -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 (
+
+ )
+}
+
+FileTreeDraggablePreviewLayer.propTypes = {
+ isOver: PropTypes.bool.isRequired
+}
+
+function DraggablePreviewItem({ title }) {
+ return {title}
+}
+
+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
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-error.js b/services/web/frontend/js/features/file-tree/components/file-tree-error.js
new file mode 100644
index 0000000000..9a1022a314
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-error.js
@@ -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 (
+
+
{t('generic_something_went_wrong')}
+
{t('please_refresh')}
+
+
+ )
+}
+
+export default FileTreeError
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-folder-list.js b/services/web/frontend/js/features/file-tree/components/file-tree-folder-list.js
new file mode 100644
index 0000000000..8ca90b9064
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-folder-list.js
@@ -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 (
+
+ {folders.map(folder => {
+ return (
+
+ )
+ })}
+ {docsAndFiles.map(doc => {
+ return (
+
+ )
+ })}
+
+ )
+}
+
+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
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-folder.js b/services/web/frontend/js/features/file-tree/components/file-tree-folder.js
new file mode 100644
index 0000000000..84f430cb4a
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-folder.js
@@ -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 = (
+ <>
+
+
+ >
+ )
+
+ return (
+ <>
+
+
+
+ {expanded ? (
+
+ ) : 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
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.js b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.js
new file mode 100644
index 0000000000..226a8cff2c
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.js
@@ -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 (
+
+
+ {icons}
+
+ {hasMenu ? : null}
+
+
+ )
+}
+
+FileTreeItemInner.propTypes = {
+ id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ icons: PropTypes.node
+}
+
+export default FileTreeItemInner
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.js b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.js
new file mode 100644
index 0000000000..bf22dc2525
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.js
@@ -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 ? (
+
+ ) : null}
+ {canDelete ? (
+
+ ) : null}
+ {canCreate ? (
+ <>
+
+
+
+
+ >
+ ) : null}
+ >
+ )
+}
+
+export default FileTreeItemMenuItems
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu.js b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu.js
new file mode 100644
index 0000000000..312e6ec800
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu.js
@@ -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 (
+
+
+
+
+
+
+
+
+ )
+}
+
+FileTreeItemMenu.propTypes = {
+ id: PropTypes.string.isRequired
+}
+
+export default FileTreeItemMenu
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name.js b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name.js
new file mode 100644
index 0000000000..76669d4f1e
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name.js
@@ -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 (
+
+ )
+ }
+ return (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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 (
+
+
+
+ )
+}
+
+InputName.propTypes = {
+ initialValue: PropTypes.string.isRequired,
+ finishRenaming: PropTypes.func.isRequired,
+ cancel: PropTypes.func.isRequired
+}
+
+export default FileTreeItemName
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-root.js b/services/web/frontend/js/features/file-tree/components/file-tree-root.js
new file mode 100644
index 0000000000..fc590caa59
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-root.js
@@ -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 (
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function FileTreeRootFolder() {
+ useFileTreeSocketListener()
+ useFileTreeAngularListener()
+ const { fileTreeData } = useFileTreeMutable()
+
+ const { isOver, dropRef } = useDroppable(fileTreeData._id)
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+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)
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.js b/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.js
new file mode 100644
index 0000000000..b6969a6d5d
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.js
@@ -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 (
+
+
+
+
+ )
+}
+
+function FileTreeToolbarLeft() {
+ const { t } = useTranslation()
+ const {
+ canCreate,
+ startCreatingFolder,
+ startCreatingDocOrFile,
+ startUploadingDocOrFile
+ } = useFileTreeActionable()
+
+ if (!canCreate) return null
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+function FileTreeToolbarRight() {
+ const { t } = useTranslation()
+ const {
+ canRename,
+ canDelete,
+ startRenaming,
+ startDeleting
+ } = useFileTreeActionable()
+
+ if (!canRename && !canDelete) return null
+
+ return (
+
+ {canRename ? (
+
+
+
+ ) : null}
+ {canDelete ? (
+
+
+
+ ) : null}
+
+ )
+}
+
+export default FileTreeToolbar
diff --git a/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.js b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.js
new file mode 100644
index 0000000000..20444d123d
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.js
@@ -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 (
+
+
+ {t('new_folder')}
+
+
+
+
+ {error && (
+
+ {t('generic_something_went_wrong')}
+
+ )}
+
+
+
+ {inFlight ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ )
+}
+
+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 (
+
+ )
+}
+
+InputName.propTypes = {
+ name: PropTypes.string.isRequired,
+ setName: PropTypes.func.isRequired,
+ handleCreateFolder: PropTypes.func.isRequired
+}
+
+export default FileTreeModalCreateFolder
diff --git a/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-delete.js b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-delete.js
new file mode 100644
index 0000000000..6d86af7028
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-delete.js
@@ -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 (
+
+
+ {t('delete')}
+
+
+
+ {t('sure_you_want_to_delete')}
+
+ {actionedEntities.map(entity => (
+ - {entity.name}
+ ))}
+
+ {error && (
+
+ {t('generic_something_went_wrong')}
+
+ )}
+
+
+
+ {inFlight ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ )
+}
+
+export default FileTreeModalDelete
diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.js
new file mode 100644
index 0000000000..238e0aa970
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.js
@@ -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 (
+
+ {children}
+
+ )
+}
+
+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
+ }
+}
diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.js
new file mode 100644
index 0000000000..90e4d6081a
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.js
@@ -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 (
+
+ {children}
+
+ )
+}
+
+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)
+}
diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-main.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-main.js
new file mode 100644
index 0000000000..ce0a16315a
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-main.js
@@ -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 (
+
+ {children}
+
+ )
+}
+
+FileTreeMainProvider.propTypes = {
+ projectId: PropTypes.string.isRequired,
+ hasWritePermissions: PropTypes.bool.isRequired,
+ children: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.node),
+ PropTypes.node
+ ]).isRequired
+}
diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-mutable.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-mutable.js
new file mode 100644
index 0000000000..929c8e2600
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-mutable.js
@@ -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 (
+
+ {children}
+
+ )
+}
+
+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
+ }
+}
diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js
new file mode 100644
index 0000000000..620eda095c
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js
@@ -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 (
+
+ {children}
+
+ )
+}
+
+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
+ }
+}
diff --git a/services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js b/services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js
new file mode 100644
index 0000000000..e4122a5824
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js
@@ -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))
diff --git a/services/web/frontend/js/features/file-tree/hooks/file-tree-angular-listener.js b/services/web/frontend/js/features/file-tree/hooks/file-tree-angular-listener.js
new file mode 100644
index 0000000000..c0d4462d46
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/hooks/file-tree-angular-listener.js
@@ -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]
+ )
+}
diff --git a/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js b/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js
new file mode 100644
index 0000000000..1eb2babd2b
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js
@@ -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]
+ )
+}
diff --git a/services/web/frontend/js/features/file-tree/util/find-in-tree.js b/services/web/frontend/js/features/file-tree/util/find-in-tree.js
new file mode 100644
index 0000000000..d3e73b9852
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/util/find-in-tree.js
@@ -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
+}
diff --git a/services/web/frontend/js/features/file-tree/util/icon-type-from-name.js b/services/web/frontend/js/features/file-tree/util/icon-type-from-name.js
new file mode 100644
index 0000000000..7482ea03bb
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/util/icon-type-from-name.js
@@ -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'
+ }
+}
diff --git a/services/web/frontend/js/features/file-tree/util/mutate-in-tree.js b/services/web/frontend/js/features/file-tree/util/mutate-in-tree.js
new file mode 100644
index 0000000000..886ba65578
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/util/mutate-in-tree.js
@@ -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 }
+}
diff --git a/services/web/frontend/js/features/file-tree/util/sync-mutation.js b/services/web/frontend/js/features/file-tree/util/sync-mutation.js
new file mode 100644
index 0000000000..a7e110b090
--- /dev/null
+++ b/services/web/frontend/js/features/file-tree/util/sync-mutation.js
@@ -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
+}
diff --git a/services/web/frontend/js/ide/directives/verticalResizablePanes.js b/services/web/frontend/js/ide/directives/verticalResizablePanes.js
index d11ae3badf..3844ea878c 100644
--- a/services/web/frontend/js/ide/directives/verticalResizablePanes.js
+++ b/services/web/frontend/js/ide/directives/verticalResizablePanes.js
@@ -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
}
diff --git a/services/web/frontend/js/ide/file-tree/FileTreeManager.js b/services/web/frontend/js/ide/file-tree/FileTreeManager.js
index c70bfed1fb..b15fbd1242 100644
--- a/services/web/frontend/js/ide/file-tree/FileTreeManager.js
+++ b/services/web/frontend/js/ide/file-tree/FileTreeManager.js
@@ -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()
diff --git a/services/web/frontend/js/ide/file-tree/controllers/FileTreeController.js b/services/web/frontend/js/ide/file-tree/controllers/FileTreeController.js
index 76d6051fc2..feb0e06cfc 100644
--- a/services/web/frontend/js/ide/file-tree/controllers/FileTreeController.js
+++ b/services/web/frontend/js/ide/file-tree/controllers/FileTreeController.js
@@ -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()
+ }
+ })
})
})
diff --git a/services/web/frontend/js/infrastructure/auto-focus.js b/services/web/frontend/js/infrastructure/auto-focus.js
new file mode 100644
index 0000000000..309b23ad0e
--- /dev/null
+++ b/services/web/frontend/js/infrastructure/auto-focus.js
@@ -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 }
+}
diff --git a/services/web/frontend/js/infrastructure/promise.js b/services/web/frontend/js/infrastructure/promise.js
new file mode 100644
index 0000000000..c900b7da6c
--- /dev/null
+++ b/services/web/frontend/js/infrastructure/promise.js
@@ -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([]))
+}
diff --git a/services/web/frontend/js/infrastructure/without-propagation.js b/services/web/frontend/js/infrastructure/without-propagation.js
new file mode 100644
index 0000000000..5b03c82f01
--- /dev/null
+++ b/services/web/frontend/js/infrastructure/without-propagation.js
@@ -0,0 +1,6 @@
+export default function withoutPropagation(callback) {
+ return ev => {
+ ev.stopPropagation()
+ if (callback) callback(ev)
+ }
+}
diff --git a/services/web/frontend/js/shared/components/icon.js b/services/web/frontend/js/shared/components/icon.js
index 40a5c5646d..51aaf0d4fa 100644
--- a/services/web/frontend/js/shared/components/icon.js
+++ b/services/web/frontend/js/shared/components/icon.js
@@ -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 (
<>
-
+
+ {children}
+
{accessibilityLabel ? (
{accessibilityLabel}
) : 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
diff --git a/services/web/frontend/js/shared/components/tooltip-button.js b/services/web/frontend/js/shared/components/tooltip-button.js
new file mode 100644
index 0000000000..381c6e30e7
--- /dev/null
+++ b/services/web/frontend/js/shared/components/tooltip-button.js
@@ -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 = {description}
+
+ return (
+
+
+
+ )
+}
+
+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
diff --git a/services/web/frontend/stories/file-tree.stories.js b/services/web/frontend/stories/file-tree.stories.js
new file mode 100644
index 0000000000..facc2fc619
--- /dev/null
+++ b/services/web/frontend/stories/file-tree.stories.js
@@ -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 =>
+FullTree.parameters = { setupMocks: defaultSetupMocks }
+
+export const ReadOnly = args =>
+ReadOnly.args = { hasWritePermissions: false }
+
+export const NetworkErrors = 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 =>
+
+export const FilesLimit = 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 => (
+ <>
+
+
+ >
+ )
+ ]
+}
diff --git a/services/web/frontend/stories/fixtures/file-tree-base.js b/services/web/frontend/stories/fixtures/file-tree-base.js
new file mode 100644
index 0000000000..f771ca00a3
--- /dev/null
+++ b/services/web/frontend/stories/fixtures/file-tree-base.js
@@ -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' }
+ }
+ ]
+ }
+]
diff --git a/services/web/frontend/stories/fixtures/file-tree-limit.js b/services/web/frontend/stories/fixtures/file-tree-limit.js
new file mode 100644
index 0000000000..8c10e82a38
--- /dev/null
+++ b/services/web/frontend/stories/fixtures/file-tree-limit.js
@@ -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')
+ }
+]
diff --git a/services/web/frontend/stylesheets/app/editor/file-tree.less b/services/web/frontend/stylesheets/app/editor/file-tree.less
index fbf5933b9d..b2f19f4386 100644
--- a/services/web/frontend/stylesheets/app/editor/file-tree.less
+++ b/services/web/frontend/stylesheets/app/editor/file-tree.less
@@ -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);
diff --git a/services/web/frontend/stylesheets/app/editor/toolbar.less b/services/web/frontend/stylesheets/app/editor/toolbar.less
index ebe6547fd1..9f0e809253 100644
--- a/services/web/frontend/stylesheets/app/editor/toolbar.less
+++ b/services/web/frontend/stylesheets/app/editor/toolbar.less
@@ -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,
diff --git a/services/web/frontend/stylesheets/core/variables.less b/services/web/frontend/stylesheets/core/variables.less
index c843830ce4..2d8fe2f0b9 100644
--- a/services/web/frontend/stylesheets/core/variables.less
+++ b/services/web/frontend/stylesheets/core/variables.less
@@ -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;
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 89ef2c4509..478ca4eb1e 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -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"
}
diff --git a/services/web/package-lock.json b/services/web/package-lock.json
index 938ec159be..623be31026 100644
--- a/services/web/package-lock.json
+++ b/services/web/package-lock.json
@@ -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"
}
diff --git a/services/web/package.json b/services/web/package.json
index 8122f58f26..af71ca4a3d 100644
--- a/services/web/package.json
+++ b/services/web/package.json
@@ -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",
diff --git a/services/web/test/frontend/features/chat/components/stubs.js b/services/web/test/frontend/features/chat/components/stubs.js
index 04e94c5683..4c7d5f30db 100644
--- a/services/web/test/frontend/features/chat/components/stubs.js
+++ b/services/web/test/frontend/features/chat/components/stubs.js
@@ -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
}
diff --git a/services/web/test/frontend/features/chat/store/chat-store.test.js b/services/web/test/frontend/features/chat/store/chat-store.test.js
index 192c1f2fc6..a505c98ddc 100644
--- a/services/web/test/frontend/features/chat/store/chat-store.test.js
+++ b/services/web/test/frontend/features/chat/store/chat-store.test.js
@@ -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()
})
diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-doc.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-doc.test.js
new file mode 100644
index 0000000000..14e4fdb217
--- /dev/null
+++ b/services/web/test/frontend/features/file-tree/components/file-tree-doc.test.js
@@ -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('', function() {
+ it('renders unselected', function() {
+ const { container } = renderWithContext(
+
+ )
+
+ screen.getByRole('treeitem', { selected: false })
+ expect(container.querySelector('i.linked-file-highlight')).to.not.exist
+ })
+
+ it('renders selected', function() {
+ renderWithContext(
+
+ )
+
+ 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(
+
+ )
+
+ screen.getByRole('treeitem')
+ expect(container.querySelector('i.linked-file-highlight')).to.exist
+ })
+
+ it('selects', function() {
+ renderWithContext()
+
+ const treeitem = screen.getByRole('treeitem', { selected: false })
+ fireEvent.click(treeitem)
+
+ screen.getByRole('treeitem', { selected: true })
+ })
+
+ it('multi-selects', function() {
+ renderWithContext()
+
+ const treeitem = screen.getByRole('treeitem')
+
+ fireEvent.click(treeitem, { ctrlKey: true })
+ screen.getByRole('treeitem', { selected: true })
+ })
+})
diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-folder-list.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-folder-list.test.js
new file mode 100644
index 0000000000..9a4a3ee09f
--- /dev/null
+++ b/services/web/test/frontend/features/file-tree/components/file-tree-folder-list.test.js
@@ -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('', function() {
+ it('renders empty', function() {
+ renderWithContext()
+
+ 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(
+
+ )
+
+ 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(
+ ,
+ { 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(
+ ,
+ { 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 })
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-folder.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-folder.test.js
new file mode 100644
index 0000000000..9b7c510ad7
--- /dev/null
+++ b/services/web/test/frontend/features/file-tree/components/file-tree-folder.test.js
@@ -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('', function() {
+ it('renders unselected', function() {
+ renderWithContext(
+
+ )
+
+ screen.getByRole('treeitem', { selected: false })
+ expect(screen.queryByRole('tree')).to.not.exist
+ })
+
+ it('renders selected', function() {
+ renderWithContext(
+
+ )
+
+ 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(
+
+ )
+
+ screen.getByRole('treeitem')
+ const expandButton = screen.getByRole('button', { name: 'Expand' })
+
+ fireEvent.click(expandButton)
+ screen.getByRole('tree')
+ })
+})
diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-inner.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-inner.test.js
new file mode 100644
index 0000000000..6961845c5d
--- /dev/null
+++ b/services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-inner.test.js
@@ -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('', 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(
+ ,
+ {}
+ )
+
+ expect(screen.queryByRole('menu', { visible: false })).to.not.exist
+ })
+ })
+
+ describe('context menu', function() {
+ it('does not display without write permissions', function() {
+ renderWithContext(
+ ,
+ { contextProps: { hasWritePermissions: false } }
+ )
+
+ expect(screen.queryByRole('menu', { visible: false })).to.not.exist
+ })
+
+ it('open / close', function() {
+ const { container } = renderWithContext(
+
+ )
+
+ 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(
+
+ )
+
+ screen.getByRole('button', { name: 'bar.tex' })
+ expect(screen.queryByRole('textbox')).to.not.exist
+ })
+
+ it('starts rename on menu item click', function() {
+ renderWithContext(
+ ,
+ { 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')
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-menu.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-menu.test.js
new file mode 100644
index 0000000000..7cb99bc9a7
--- /dev/null
+++ b/services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-menu.test.js
@@ -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('', function() {
+ const setContextMenuCoords = sinon.stub()
+
+ afterEach(function() {
+ setContextMenuCoords.reset()
+ })
+
+ it('renders dropdown', function() {
+ renderWithContext(
+
+ )
+
+ screen.getByRole('button', { name: 'Menu' })
+ screen.getByRole('menu')
+ })
+
+ it('open / close', function() {
+ renderWithContext(
+
+ )
+
+ 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 })
+ })
+})
diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-name.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-name.test.js
new file mode 100644
index 0000000000..a4f53b16bf
--- /dev/null
+++ b/services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-name.test.js
@@ -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('', function() {
+ beforeEach(function() {
+ global.requestAnimationFrame = sinon.stub()
+ })
+
+ afterEach(function() {
+ delete global.requestAnimationFrame
+ })
+
+ it('renders name as button', function() {
+ renderWithContext()
+
+ screen.getByRole('button', { name: 'foo.tex' })
+ expect(screen.queryByRole('textbox')).to.not.exist
+ })
+
+ it("doesn't start renaming on unselected component", function() {
+ renderWithContext()
+
+ 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()
+
+ 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(, {
+ 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()
+
+ 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' })
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js
new file mode 100644
index 0000000000..a9022533f0
--- /dev/null
+++ b/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js
@@ -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('', 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(
+
+ )
+
+ 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(
+
+ )
+ 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(
+
+ )
+
+ 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 })
+ })
+})
diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-toolbar.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-toolbar.test.js
new file mode 100644
index 0000000000..a28bb69ce1
--- /dev/null
+++ b/services/web/test/frontend/features/file-tree/components/file-tree-toolbar.test.js
@@ -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('', function() {
+ it('without selected files', function() {
+ renderWithContext()
+
+ 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(, {
+ contextProps: { hasWritePermissions: false }
+ })
+
+ expect(screen.queryByRole('button')).to.not.exist
+ })
+
+ it('with one selected file', function() {
+ renderWithContext(, {
+ contextProps: { initialSelectedEntityId: '123abc' }
+ })
+
+ screen.getByRole('button', { name: 'Rename' })
+ screen.getByRole('button', { name: 'Delete' })
+ })
+})
diff --git a/services/web/test/frontend/features/file-tree/flows/context-menu.test.js b/services/web/test/frontend/features/file-tree/flows/context-menu.test.js
new file mode 100644
index 0000000000..5a2b85a45a
--- /dev/null
+++ b/services/web/test/frontend/features/file-tree/flows/context-menu.test.js
@@ -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(
+
+ )
+ 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(
+
+ )
+ const treeitem = screen.getByRole('button', { name: 'main.tex' })
+
+ fireEvent.contextMenu(treeitem)
+
+ expect(screen.queryByRole('menu')).to.not.exist
+ })
+})
diff --git a/services/web/test/frontend/features/file-tree/flows/create-folder.test.js b/services/web/test/frontend/features/file-tree/flows/create-folder.test.js
new file mode 100644
index 0000000000..e92f405db7
--- /dev/null
+++ b/services/web/test/frontend/features/file-tree/flows/create-folder.test.js
@@ -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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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
+ }
+})
diff --git a/services/web/test/frontend/features/file-tree/flows/delete-entity.test.js b/services/web/test/frontend/features/file-tree/flows/delete-entity.test.js
new file mode 100644
index 0000000000..6cf96ed7d1
--- /dev/null
+++ b/services/web/test/frontend/features/file-tree/flows/delete-entity.test.js
@@ -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(
+
+ )
+
+ 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(
+
+ )
+
+ 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
+ }
+})
diff --git a/services/web/test/frontend/features/file-tree/flows/rename-entity.test.js b/services/web/test/frontend/features/file-tree/flows/rename-entity.test.js
new file mode 100644
index 0000000000..09427b1620
--- /dev/null
+++ b/services/web/test/frontend/features/file-tree/flows/rename-entity.test.js
@@ -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(
+
+ )
+ })
+
+ 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)
+ }
+})
diff --git a/services/web/test/frontend/features/file-tree/helpers/render-with-context.js b/services/web/test/frontend/features/file-tree/helpers/render-with-context.js
new file mode 100644
index 0000000000..42c9e030b9
--- /dev/null
+++ b/services/web/test/frontend/features/file-tree/helpers/render-with-context.js
@@ -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(
+ {children},
+ renderOptions
+ )
+}
diff --git a/services/web/test/frontend/features/file-tree/util/icon-type-from-name.test.js b/services/web/test/frontend/features/file-tree/util/icon-type-from-name.test.js
new file mode 100644
index 0000000000..2adab9bfad
--- /dev/null
+++ b/services/web/test/frontend/features/file-tree/util/icon-type-from-name.test.js
@@ -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')
+ })
+})
diff --git a/services/web/test/frontend/shared/components/icon.test.js b/services/web/test/frontend/shared/components/icon.test.js
index 47d84ebe5f..f05d3fd784 100644
--- a/services/web/test/frontend/shared/components/icon.test.js
+++ b/services/web/test/frontend/shared/components/icon.test.js
@@ -43,4 +43,16 @@ describe('', function() {
)
expect(element).to.exist
})
+
+ it('renders children', function() {
+ const { container } = render(
+
+
+
+ )
+ const element = container.querySelector(
+ 'i.fa.fa-angle-down > i.fa.fa-angle-up'
+ )
+ expect(element).to.exist
+ })
})