Merge pull request #3232 from overleaf/ta-file-tree-react

React File Tree

GitOrigin-RevId: fb3141ba8cd9ca0d68e87edb74764a360144c8fe
This commit is contained in:
Timothée Alby 2020-11-26 15:22:30 +01:00 committed by Copybot
parent d5544f0626
commit 420aa4a657
66 changed files with 4210 additions and 24 deletions

View file

@ -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()
}

View file

@ -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').

View file

@ -0,0 +1,38 @@
aside.editor-sidebar.full-size(
ng-show="ui.view != 'history' || !history.isV2"
vertical-resizable-panes="outline-resizer"
vertical-resizable-panes-toggled-externally-on="outline-toggled"
vertical-resizable-panes-min-size="32"
vertical-resizable-panes-max-size="75%"
vertical-resizable-panes-resize-on="left-pane-resize-all"
)
.file-tree(
ng-controller="ReactFileTreeController"
vertical-resizable-top
)
file-tree-root(
project-id="projectId"
root-folder="rootFolder"
root-doc-id="rootDocId"
has-write-permissions="hasWritePermissions"
on-select="onSelect"
on-init="onInit"
)
div(ng-controller="FileTreeController")
include ./new-file-modal
.outline-container(
vertical-resizable-bottom
ng-controller="OutlineController"
)
outline-pane(
is-tex-file="isTexFile"
outline="outline"
project-id="project_id"
jump-to-line="jumpToLine"
on-toggle="onToggle"
event-tracking="eventTracking"
highlighted-line="highlightedLine"
)

View file

@ -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",

View file

@ -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"
]

View file

@ -0,0 +1,53 @@
import React, { useContext } from 'react'
import ReactDOM from 'react-dom'
import { Dropdown } from 'react-bootstrap'
import { FileTreeMainContext } from '../contexts/file-tree-main'
import FileTreeItemMenuItems from './file-tree-item/file-tree-item-menu-items'
function FileTreeContextMenu() {
const {
hasWritePermissions,
contextMenuCoords,
setContextMenuCoords
} = useContext(FileTreeMainContext)
if (!hasWritePermissions || !contextMenuCoords) return null
function close() {
// reset context menu
setContextMenuCoords(null)
}
function handleToggle(wantOpen) {
if (!wantOpen) close()
}
function handleClick() {
handleToggle(false)
}
return ReactDOM.createPortal(
<Dropdown
onClick={handleClick}
open
id={'dropdown-file-tree-context-menu'}
onToggle={handleToggle}
>
<FakeDropDownToggle bsRole="toggle" />
<Dropdown.Menu className="context-menu" style={contextMenuCoords}>
<FileTreeItemMenuItems />
</Dropdown.Menu>
</Dropdown>,
document.querySelector('body')
)
}
// fake component required as Dropdowns require a Toggle, even tho we don't want
// one for the context menu
const FakeDropDownToggle = React.forwardRef((props, ref) => {
return null
})
export default FileTreeContextMenu

View file

@ -0,0 +1,55 @@
import React from 'react'
import PropTypes from 'prop-types'
import { FileTreeMainProvider } from '../contexts/file-tree-main'
import { FileTreeActionableProvider } from '../contexts/file-tree-actionable'
import { FileTreeMutableProvider } from '../contexts/file-tree-mutable'
import { FileTreeSelectableProvider } from '../contexts/file-tree-selectable'
import { FileTreeDraggableProvider } from '../contexts/file-tree-draggable'
// renders all the contexts needed for the file tree:
// FileTreeMain: generic store
// FileTreeActionable: global UI state for actions (rename, delete, etc.)
// FileTreeMutable: provides entities mutation operations
// FileTreeSelectable: handles selection and multi-selection
function FileTreeContext({
projectId,
rootFolder,
hasWritePermissions,
initialSelectedEntityId,
onSelect,
children
}) {
return (
<FileTreeMainProvider
projectId={projectId}
hasWritePermissions={hasWritePermissions}
>
<FileTreeActionableProvider hasWritePermissions={hasWritePermissions}>
<FileTreeMutableProvider rootFolder={rootFolder}>
<FileTreeSelectableProvider
hasWritePermissions={hasWritePermissions}
initialSelectedEntityId={initialSelectedEntityId}
onSelect={onSelect}
>
<FileTreeDraggableProvider>{children}</FileTreeDraggableProvider>
</FileTreeSelectableProvider>
</FileTreeMutableProvider>
</FileTreeActionableProvider>
</FileTreeMainProvider>
)
}
FileTreeContext.propTypes = {
projectId: PropTypes.string.isRequired,
rootFolder: PropTypes.array.isRequired,
hasWritePermissions: PropTypes.bool.isRequired,
initialSelectedEntityId: PropTypes.string,
onSelect: PropTypes.func.isRequired,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired
}
export default FileTreeContext

View file

@ -0,0 +1,58 @@
import React from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon'
import iconTypeFromName from '../util/icon-type-from-name'
import { useSelectableEntity } from '../contexts/file-tree-selectable'
import FileTreeItemInner from './file-tree-item/file-tree-item-inner'
function FileTreeDoc({ name, id, isLinkedFile }) {
const { t } = useTranslation()
const { isSelected, props: selectableEntityProps } = useSelectableEntity(id)
const icons = (
<>
<Icon
type={iconTypeFromName(name)}
modifier="fw"
classes={{ icon: 'spaced' }}
>
{isLinkedFile ? (
<Icon
type="external-link-square"
modifier="rotate-180"
classes={{ icon: 'linked-file-highlight' }}
accessibilityLabel={t('linked_file')}
/>
) : null}
</Icon>
</>
)
return (
<li
role="treeitem"
{...selectableEntityProps}
aria-label={name}
tabIndex="0"
>
<FileTreeItemInner
id={id}
name={name}
isSelected={isSelected}
icons={icons}
/>
</li>
)
}
FileTreeDoc.propTypes = {
name: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
isLinkedFile: PropTypes.bool
}
export default FileTreeDoc

View file

@ -0,0 +1,72 @@
import React, { useRef } from 'react'
import PropTypes from 'prop-types'
import { useDragLayer } from 'react-dnd'
import classNames from 'classnames'
// a custom component rendered on top of a draggable area that renders the
// dragged item. See
// https://react-dnd.github.io/react-dnd/examples/drag-around/custom-drag-layer
// for more details.
// Also used to display a container border when hovered.
function FileTreeDraggablePreviewLayer({ isOver }) {
const { isDragging, item, clientOffset } = useDragLayer(monitor => ({
isDragging: monitor.isDragging(),
item: monitor.getItem(),
clientOffset: monitor.getClientOffset()
}))
const ref = useRef()
const containerOffset = ref.current
? ref.current.getBoundingClientRect()
: null
if (!isDragging) {
return null
}
return (
<div
ref={ref}
className={classNames('dnd-draggable-preview-layer', {
'dnd-droppable-hover': isOver
})}
>
<div style={getItemStyle(clientOffset, containerOffset)}>
<DraggablePreviewItem title={item.title} />
</div>
</div>
)
}
FileTreeDraggablePreviewLayer.propTypes = {
isOver: PropTypes.bool.isRequired
}
function DraggablePreviewItem({ title }) {
return <div className="dnd-draggable-preview-item">{title}</div>
}
DraggablePreviewItem.propTypes = {
title: PropTypes.string.isRequired
}
// makes the preview item follow the cursor.
// See https://react-dnd.github.io/react-dnd/docs/api/drag-layer-monitor
function getItemStyle(clientOffset, containerOffset) {
if (!containerOffset || !clientOffset) {
return {
display: 'none'
}
}
const { x: containerX, y: containerY } = containerOffset
const { x: clientX, y: clientY } = clientOffset
const posX = clientX - containerX - 15
const posY = clientY - containerY - 15
const transform = `translate(${posX}px, ${posY}px)`
return {
transform,
WebkitTransform: transform
}
}
export default FileTreeDraggablePreviewLayer

View file

@ -0,0 +1,23 @@
import React from 'react'
import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
function FileTreeError() {
const { t } = useTranslation()
function reload() {
location.reload()
}
return (
<div className="file-tree-error">
<p>{t('generic_something_went_wrong')}</p>
<p>{t('please_refresh')}</p>
<Button bsStyle="primary" onClick={reload}>
{t('refresh')}
</Button>
</div>
)
}
export default FileTreeError

View file

@ -0,0 +1,61 @@
import React from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import FileTreeDoc from './file-tree-doc'
import FileTreeFolder from './file-tree-folder'
function FileTreeFolderList({
folders,
docs,
files,
classes = {},
dropRef = null
}) {
const docsAndFiles = [...docs, ...files].sort(
(one, two) => one.name.toLowerCase() > two.name.toLowerCase()
)
return (
<ul
className={classNames('list-unstyled', classes.root)}
role="tree"
ref={dropRef}
>
{folders.map(folder => {
return (
<FileTreeFolder
key={folder._id}
name={folder.name}
id={folder._id}
folders={folder.folders}
docs={folder.docs}
files={folder.fileRefs}
/>
)
})}
{docsAndFiles.map(doc => {
return (
<FileTreeDoc
key={doc._id}
name={doc.name}
id={doc._id}
isLinkedFile={doc.linkedFileData && !!doc.linkedFileData.provider}
/>
)
})}
</ul>
)
}
FileTreeFolderList.propTypes = {
folders: PropTypes.array.isRequired,
docs: PropTypes.array.isRequired,
files: PropTypes.array.isRequired,
classes: PropTypes.exact({
root: PropTypes.string
}),
dropRef: PropTypes.func
}
export default FileTreeFolderList

View file

@ -0,0 +1,78 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import Icon from '../../../shared/components/icon'
import { useSelectableEntity } from '../contexts/file-tree-selectable'
import { useDroppable } from '../contexts/file-tree-draggable'
import FileTreeItemInner from './file-tree-item/file-tree-item-inner'
import FileTreeFolderList from './file-tree-folder-list'
function FileTreeFolder({ name, id, folders, docs, files }) {
const { t } = useTranslation()
const { isSelected, props: selectableEntityProps } = useSelectableEntity(id)
const [expanded, setExpanded] = useState(false)
function handleExpandCollapseClick() {
setExpanded(!expanded)
}
const { isOver: isOverRoot, dropRef: dropRefRoot } = useDroppable(id)
const { isOver: isOverList, dropRef: dropRefList } = useDroppable(id)
const icons = (
<>
<button
onClick={handleExpandCollapseClick}
aria-label={expanded ? t('collapse') : t('expand')}
>
<Icon type={expanded ? 'angle-down' : 'angle-right'} modifier="fw" />
</button>
<Icon type={expanded ? 'folder-open' : 'folder'} modifier="fw" />
</>
)
return (
<>
<li
role="treeitem"
{...selectableEntityProps}
aria-expanded={expanded}
aria-label={name}
tabIndex="0"
ref={dropRefRoot}
className={classNames(selectableEntityProps.className, {
'dnd-droppable-hover': isOverRoot || isOverList
})}
>
<FileTreeItemInner
id={id}
name={name}
isSelected={isSelected}
icons={icons}
/>
</li>
{expanded ? (
<FileTreeFolderList
folders={folders}
docs={docs}
files={files}
dropRef={dropRefList}
/>
) : null}
</>
)
}
FileTreeFolder.propTypes = {
name: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
folders: PropTypes.array.isRequired,
docs: PropTypes.array.isRequired,
files: PropTypes.array.isRequired
}
export default FileTreeFolder

View file

@ -0,0 +1,71 @@
import React, { useContext, useEffect, createRef } from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed'
import { FileTreeMainContext } from '../../contexts/file-tree-main'
import { useDraggable } from '../../contexts/file-tree-draggable'
import FileTreeItemName from './file-tree-item-name'
import FileTreeItemMenu from './file-tree-item-menu'
function FileTreeItemInner({ id, name, isSelected, icons }) {
const { hasWritePermissions, setContextMenuCoords } = useContext(
FileTreeMainContext
)
const hasMenu = hasWritePermissions && isSelected
const { isDragging, dragRef } = useDraggable(id)
const itemRef = createRef()
useEffect(
() => {
if (isSelected && itemRef.current) {
scrollIntoViewIfNeeded(itemRef.current, {
scrollMode: 'if-needed'
})
}
},
[isSelected, itemRef]
)
function handleContextMenu(ev) {
ev.preventDefault()
setContextMenuCoords({
top: ev.pageY,
left: ev.pageX
})
}
return (
<div
className={classNames('entity', {
'dnd-draggable-dragging': isDragging
})}
role="presentation"
ref={dragRef}
onContextMenu={handleContextMenu}
>
<div
className="entity-name entity-name-react"
role="presentation"
ref={itemRef}
>
{icons}
<FileTreeItemName name={name} isSelected={isSelected} />
{hasMenu ? <FileTreeItemMenu id={id} /> : null}
</div>
</div>
)
}
FileTreeItemInner.propTypes = {
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired,
icons: PropTypes.node
}
export default FileTreeItemInner

View file

@ -0,0 +1,41 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { MenuItem } from 'react-bootstrap'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
function FileTreeItemMenuItems() {
const { t } = useTranslation()
const {
canRename,
canDelete,
canCreate,
startRenaming,
startDeleting,
startCreatingFolder,
startCreatingDocOrFile,
startUploadingDocOrFile
} = useFileTreeActionable()
return (
<>
{canRename ? (
<MenuItem onClick={startRenaming}>{t('rename')}</MenuItem>
) : null}
{canDelete ? (
<MenuItem onClick={startDeleting}>{t('delete')}</MenuItem>
) : null}
{canCreate ? (
<>
<MenuItem divider />
<MenuItem onClick={startCreatingDocOrFile}>{t('new_file')}</MenuItem>
<MenuItem onClick={startCreatingFolder}>{t('new_folder')}</MenuItem>
<MenuItem onClick={startUploadingDocOrFile}>{t('upload')}</MenuItem>
</>
) : null}
</>
)
}
export default FileTreeItemMenuItems

View file

@ -0,0 +1,50 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import withoutPropagation from '../../../../infrastructure/without-propagation'
import { Dropdown } from 'react-bootstrap'
import Icon from '../../../../shared/components/icon'
import FileTreeItemMenuItems from './file-tree-item-menu-items'
function FileTreeItemMenu({ id }) {
const { t } = useTranslation()
const [dropdownOpen, setDropdownOpen] = useState(false)
function handleToggle(wantOpen) {
setDropdownOpen(wantOpen)
}
function handleClick() {
handleToggle(false)
}
return (
<Dropdown
onClick={withoutPropagation(handleClick)}
pullRight
open={dropdownOpen}
id={`dropdown-${id}`}
onToggle={handleToggle}
>
<Dropdown.Toggle
noCaret
className="dropdown-toggle-no-background entity-menu-toggle"
onClick={withoutPropagation()}
>
<Icon type="ellipsis-v" accessibilityLabel={t('menu')} />
</Dropdown.Toggle>
<Dropdown.Menu>
<FileTreeItemMenuItems />
</Dropdown.Menu>
</Dropdown>
)
}
FileTreeItemMenu.propTypes = {
id: PropTypes.string.isRequired
}
export default FileTreeItemMenu

View file

@ -0,0 +1,129 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { useRefWithAutoFocus } from '../../../../infrastructure/auto-focus'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
function FileTreeItemName({ name, isSelected }) {
const {
isRenaming,
startRenaming,
finishRenaming,
cancel
} = useFileTreeActionable()
const isRenamingEntity = isRenaming && isSelected
if (isRenamingEntity) {
return (
<InputName
initialValue={name}
finishRenaming={finishRenaming}
cancel={cancel}
/>
)
}
return (
<DisplayName
name={name}
isSelected={isSelected}
startRenaming={startRenaming}
/>
)
}
FileTreeItemName.propTypes = {
name: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired
}
function DisplayName({ name, isSelected, startRenaming }) {
const [clicksInSelectedCount, setClicksInSelectedCount] = useState(0)
function onClick() {
setClicksInSelectedCount(clicksInSelectedCount + 1)
if (!isSelected) setClicksInSelectedCount(0)
}
function onDoubleClick() {
// only start renaming if the button got two or more clicks while the item
// was selected. This is to prevent starting a rename on an unselected item.
// When the item is being selected it can trigger a loss of focus which
// causes UI problems.
if (clicksInSelectedCount < 2) return
startRenaming()
}
return (
<button
className="item-name-button"
onClick={onClick}
onDoubleClick={onDoubleClick}
>
<span>{name}</span>
</button>
)
}
DisplayName.propTypes = {
name: PropTypes.string.isRequired,
startRenaming: PropTypes.func.isRequired,
isSelected: PropTypes.bool.isRequired
}
function InputName({ initialValue, finishRenaming, cancel }) {
const [value, setValue] = useState(initialValue)
// The react-bootstrap Dropdown re-focuses on the Dropdown.Toggle
// after a menu item is clicked, following the ARIA authoring practices:
// https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-links.html
// To improve UX, we want to auto-focus to the input when renaming. We use
// requestAnimationFrame to immediately move focus to the input after it is
// shown
const { autoFocusedRef } = useRefWithAutoFocus()
function handleFocus(ev) {
const lastDotIndex = ev.target.value.lastIndexOf('.')
ev.target.setSelectionRange(0, lastDotIndex)
}
function handleChange(ev) {
setValue(ev.target.value)
}
function handleKeyDown(ev) {
if (ev.key === 'Enter') {
finishRenaming(value)
}
if (ev.key === 'Escape') {
cancel()
}
}
function handleBlur() {
finishRenaming(value)
}
return (
<span className="rename-input">
<input
type="text"
value={value}
onKeyDown={handleKeyDown}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
ref={autoFocusedRef}
/>
</span>
)
}
InputName.propTypes = {
initialValue: PropTypes.string.isRequired,
finishRenaming: PropTypes.func.isRequired,
cancel: PropTypes.func.isRequired
}
export default FileTreeItemName

View file

@ -0,0 +1,88 @@
import React, { useEffect } from 'react'
import PropTypes from 'prop-types'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import FileTreeContext from './file-tree-context'
import FileTreeDraggablePreviewLayer from './file-tree-draggable-preview-layer'
import FileTreeFolderList from './file-tree-folder-list'
import FileTreeToolbar from './file-tree-toolbar'
import FileTreeModalDelete from './modals/file-tree-modal-delete'
import FileTreeModalCreateFolder from './modals/file-tree-modal-create-folder'
import FileTreeContextMenu from './file-tree-context-menu'
import FileTreeError from './file-tree-error'
import { useFileTreeMutable } from '../contexts/file-tree-mutable'
import { useDroppable } from '../contexts/file-tree-draggable'
import { useFileTreeAngularListener } from '../hooks/file-tree-angular-listener'
import { useFileTreeSocketListener } from '../hooks/file-tree-socket-listener'
function FileTreeRoot({
projectId,
rootFolder,
rootDocId,
hasWritePermissions,
onSelect,
onInit
}) {
const isReady = projectId && rootFolder
useEffect(
() => {
if (isReady) onInit()
},
[isReady]
)
if (!isReady) return null
return (
<FileTreeContext
projectId={projectId}
hasWritePermissions={hasWritePermissions}
rootFolder={rootFolder}
initialSelectedEntityId={rootDocId}
onSelect={onSelect}
>
<FileTreeToolbar />
<FileTreeContextMenu />
<div className="file-tree-inner">
<FileTreeRootFolder />
</div>
<FileTreeModalDelete />
<FileTreeModalCreateFolder />
</FileTreeContext>
)
}
function FileTreeRootFolder() {
useFileTreeSocketListener()
useFileTreeAngularListener()
const { fileTreeData } = useFileTreeMutable()
const { isOver, dropRef } = useDroppable(fileTreeData._id)
return (
<>
<FileTreeDraggablePreviewLayer isOver={isOver} />
<FileTreeFolderList
folders={fileTreeData.folders}
docs={fileTreeData.docs}
files={fileTreeData.fileRefs}
classes={{ root: 'file-tree-list' }}
dropRef={dropRef}
isOver={isOver}
/>
</>
)
}
FileTreeRoot.propTypes = {
projectId: PropTypes.string,
rootFolder: PropTypes.array,
rootDocId: PropTypes.string,
hasWritePermissions: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
onInit: PropTypes.func.isRequired
}
export default withErrorBoundary(FileTreeRoot, FileTreeError)

View file

@ -0,0 +1,100 @@
import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon'
import TooltipButton from '../../../shared/components/tooltip-button'
import { FileTreeMainContext } from '../contexts/file-tree-main'
import { useFileTreeActionable } from '../contexts/file-tree-actionable'
function FileTreeToolbar() {
const { hasWritePermissions } = useContext(FileTreeMainContext)
if (!hasWritePermissions) return null
return (
<div className="toolbar toolbar-filetree">
<FileTreeToolbarLeft />
<FileTreeToolbarRight />
</div>
)
}
function FileTreeToolbarLeft() {
const { t } = useTranslation()
const {
canCreate,
startCreatingFolder,
startCreatingDocOrFile,
startUploadingDocOrFile
} = useFileTreeActionable()
if (!canCreate) return null
return (
<>
<TooltipButton
id="new_file"
description={t('new_file')}
onClick={startCreatingDocOrFile}
>
<Icon type="file" modifier="fw" accessibilityLabel={t('new_file')} />
</TooltipButton>
<TooltipButton
id="new_folder"
description={t('new_folder')}
onClick={startCreatingFolder}
>
<Icon
type="folder"
modifier="fw"
accessibilityLabel={t('new_folder')}
/>
</TooltipButton>
<TooltipButton
id="upload"
description={t('upload')}
onClick={startUploadingDocOrFile}
>
<Icon type="upload" modifier="fw" accessibilityLabel={t('upload')} />
</TooltipButton>
</>
)
}
function FileTreeToolbarRight() {
const { t } = useTranslation()
const {
canRename,
canDelete,
startRenaming,
startDeleting
} = useFileTreeActionable()
if (!canRename && !canDelete) return null
return (
<div className="toolbar-right">
{canRename ? (
<TooltipButton
id="rename"
description={t('rename')}
onClick={startRenaming}
>
<Icon type="pencil" modifier="fw" accessibilityLabel={t('rename')} />
</TooltipButton>
) : null}
{canDelete ? (
<TooltipButton
id="delete"
description={t('delete')}
onClick={startDeleting}
>
<Icon type="trash-o" modifier="fw" accessibilityLabel={t('delete')} />
</TooltipButton>
) : null}
</div>
)
}
export default FileTreeToolbar

View file

@ -0,0 +1,105 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { Button, Modal } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useRefWithAutoFocus } from '../../../../infrastructure/auto-focus'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
function FileTreeModalCreateFolder() {
const { t } = useTranslation()
const [name, setName] = useState('')
const {
isCreatingFolder,
inFlight,
finishCreatingFolder,
cancel,
error
} = useFileTreeActionable()
if (!isCreatingFolder) return null // the modal will not be rendered; return early
function handleHide() {
cancel()
}
function handleCreateFolder() {
finishCreatingFolder(name)
}
return (
<Modal show={isCreatingFolder} onHide={handleHide}>
<Modal.Header>
<Modal.Title>{t('new_folder')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<InputName
name={name}
setName={setName}
handleCreateFolder={handleCreateFolder}
/>
{error && (
<div className="alert alert-danger file-tree-modal-alert">
{t('generic_something_went_wrong')}
</div>
)}
</Modal.Body>
<Modal.Footer>
{inFlight ? (
<Button bsStyle="primary" disabled>
{t('creating')}
</Button>
) : (
<>
<Button onClick={handleHide}>{t('cancel')}</Button>
<Button bsStyle="primary" onClick={handleCreateFolder}>
{t('create')}
</Button>
</>
)}
</Modal.Footer>
</Modal>
)
}
function InputName({ name, setName, handleCreateFolder }) {
const { autoFocusedRef } = useRefWithAutoFocus()
function handleFocus(ev) {
ev.target.setSelectionRange(0, -1)
}
function handleChange(ev) {
setName(ev.target.value)
}
function handleKeyDown(ev) {
if (ev.key === 'Enter') {
handleCreateFolder()
}
}
return (
<input
ref={autoFocusedRef}
className="form-control"
type="text"
value={name}
onKeyDown={handleKeyDown}
onChange={handleChange}
onFocus={handleFocus}
/>
)
}
InputName.propTypes = {
name: PropTypes.string.isRequired,
setName: PropTypes.func.isRequired,
handleCreateFolder: PropTypes.func.isRequired
}
export default FileTreeModalCreateFolder

View file

@ -0,0 +1,67 @@
import React from 'react'
import { Button, Modal } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
function FileTreeModalDelete() {
const { t } = useTranslation()
const {
isDeleting,
inFlight,
finishDeleting,
actionedEntities,
cancel,
error
} = useFileTreeActionable()
if (!isDeleting) return null // the modal will not be rendered; return early
function handleHide() {
cancel()
}
function handleDelete() {
finishDeleting()
}
return (
<Modal show={isDeleting} onHide={handleHide}>
<Modal.Header>
<Modal.Title>{t('delete')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>{t('sure_you_want_to_delete')}</p>
<ul>
{actionedEntities.map(entity => (
<li key={entity._id}>{entity.name}</li>
))}
</ul>
{error && (
<div className="alert alert-danger file-tree-modal-alert">
{t('generic_something_went_wrong')}
</div>
)}
</Modal.Body>
<Modal.Footer>
{inFlight ? (
<Button bsStyle="danger" disabled>
{t('deleting')}
</Button>
) : (
<>
<Button onClick={handleHide}>{t('cancel')}</Button>
<Button bsStyle="danger" onClick={handleDelete}>
{t('delete')}
</Button>
</>
)}
</Modal.Footer>
</Modal>
)
}
export default FileTreeModalDelete

View file

@ -0,0 +1,301 @@
import React, { createContext, useReducer, useContext } from 'react'
import PropTypes from 'prop-types'
import { mapSeries } from '../../../infrastructure/promise'
import {
syncRename,
syncDelete,
syncMove,
syncCreateEntity
} from '../util/sync-mutation'
import { findInTreeOrThrow } from '../util/find-in-tree'
import { FileTreeMainContext } from './file-tree-main'
import { useFileTreeMutable } from './file-tree-mutable'
import { useFileTreeSelectable } from './file-tree-selectable'
const FileTreeActionableContext = createContext()
const ACTION_TYPES = {
START_RENAME: 'START_RENAME',
START_DELETE: 'START_DELETE',
DELETING: 'DELETING',
START_CREATE_FOLDER: 'START_CREATE_FOLDER',
CREATING_FOLDER: 'CREATING_FOLDER',
CANCEL: 'CANCEL',
CLEAR: 'CLEAR',
ERROR: 'ERROR'
}
const defaultState = {
isDeleting: false,
isRenaming: false,
isCreatingFolder: false,
inFlight: false,
actionedEntities: null,
error: null
}
function fileTreeActionableReadOnlyReducer(state) {
return state
}
function fileTreeActionableReducer(state, action) {
switch (action.type) {
case ACTION_TYPES.START_RENAME:
return { ...defaultState, isRenaming: true }
case ACTION_TYPES.START_DELETE:
return {
...defaultState,
isDeleting: true,
actionedEntities: action.actionedEntities
}
case ACTION_TYPES.START_CREATE_FOLDER:
return { ...defaultState, isCreatingFolder: true }
case ACTION_TYPES.CREATING_FOLDER:
return { ...defaultState, isCreatingFolder: true, inFlight: true }
case ACTION_TYPES.DELETING:
// keep `actionedEntities` so the entities list remains displayed in the
// delete modal
return {
...defaultState,
isDeleting: true,
inFlight: true,
actionedEntities: state.actionedEntities
}
case ACTION_TYPES.CLEAR:
return { ...defaultState }
case ACTION_TYPES.CANCEL:
if (state.inFlight) return state
return { ...defaultState }
case ACTION_TYPES.ERROR:
return { ...state, inFlight: false, error: action.error }
default:
throw new Error(`Unknown user action type: ${action.type}`)
}
}
export function FileTreeActionableProvider({ hasWritePermissions, children }) {
const [state, dispatch] = useReducer(
hasWritePermissions
? fileTreeActionableReducer
: fileTreeActionableReadOnlyReducer,
defaultState
)
return (
<FileTreeActionableContext.Provider value={{ ...state, dispatch }}>
{children}
</FileTreeActionableContext.Provider>
)
}
FileTreeActionableProvider.propTypes = {
hasWritePermissions: PropTypes.bool.isRequired,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired
}
export function useFileTreeActionable() {
const {
isDeleting,
isRenaming,
isCreatingFolder,
inFlight,
error,
actionedEntities,
dispatch
} = useContext(FileTreeActionableContext)
const { projectId } = useContext(FileTreeMainContext)
const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeMutable()
const { selectedEntityIds } = useFileTreeSelectable()
function startRenaming() {
dispatch({ type: ACTION_TYPES.START_RENAME })
}
// update the entity with the new name immediately in the tree, but revert to
// the old name if the sync fails
function finishRenaming(newName) {
dispatch({ type: ACTION_TYPES.CLEAR })
const selectedEntityId = Array.from(selectedEntityIds)[0]
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
const oldName = found.entity.name
dispatchRename(selectedEntityId, newName)
return syncRename(projectId, found.type, found.entity._id, newName).catch(
error => {
dispatch({ type: ACTION_TYPES.ERROR, error })
dispatchRename(selectedEntityId, oldName)
}
)
}
// init deletion flow (this will open the delete modal).
// A copy of the selected entities is set as `actionedEntities` so it is kept
// unchanged as the entities are deleted and the selection is updated
function startDeleting() {
const actionedEntities = Array.from(selectedEntityIds).map(
entityId => findInTreeOrThrow(fileTreeData, entityId).entity
)
dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities })
}
// deletes entities in serie. Tree will be updated via the socket event
function finishDeleting() {
dispatch({ type: ACTION_TYPES.DELETING })
return mapSeries(Array.from(selectedEntityIds), id => {
const found = findInTreeOrThrow(fileTreeData, id)
return syncDelete(projectId, found.type, found.entity._id).catch(
error => {
// throw unless 404
if (error.message !== '404') {
throw error
}
}
)
})
.then(() => {
dispatch({ type: ACTION_TYPES.CLEAR })
})
.catch(error => {
// set an error and allow user to retry
dispatch({ type: ACTION_TYPES.ERROR, error })
})
}
// moves entities. Tree is updated immediately and data are sync'd after.
function finishMoving(toFolderId, draggedEntityIds) {
draggedEntityIds.forEach(selectedEntityId => {
dispatchMove(selectedEntityId, toFolderId)
})
return mapSeries(Array.from(draggedEntityIds), id => {
const found = findInTreeOrThrow(fileTreeData, id)
return syncMove(projectId, found.type, found.entity._id, toFolderId)
})
}
function startCreatingFolder() {
dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER })
}
function finishCreatingEntity(entity) {
const selectedEntityId = Array.from(selectedEntityIds)[0]
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
const parentFolderId =
found.type === 'folder' ? found.entity._id : found.parentFolderId
return syncCreateEntity(projectId, parentFolderId, entity)
}
function finishCreatingFolder(name) {
dispatch({ type: ACTION_TYPES.CREATING_FOLDER })
return finishCreatingEntity({ endpoint: 'folder', name })
.then(() => {
dispatch({ type: ACTION_TYPES.CLEAR })
})
.catch(error => {
dispatch({ type: ACTION_TYPES.ERROR, error })
})
}
// bypass React file tree entirely; requesting the Angular new doc or file
// modal instead
function startCreatingDocOrFile() {
const selectedEntityId = Array.from(selectedEntityIds)[0]
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
const parentFolderId =
found.type === 'folder' ? found.entity._id : found.parentFolderId
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewDocModal', {
detail: {
mode: 'doc',
parentFolderId
}
})
)
}
function startUploadingDocOrFile() {
const selectedEntityId = Array.from(selectedEntityIds)[0]
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
const parentFolderId =
found.type === 'folder' ? found.entity._id : found.parentFolderId
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewDocModal', {
detail: {
mode: 'upload',
parentFolderId
}
})
)
}
function finishCreatingDocOrFile(entity) {
return finishCreatingEntity(entity)
.then(() => {
// dispatch FileTreeReactBridge event to update the Angular modal
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewFileModal', {
detail: {
done: true
}
})
)
})
.catch(error => {
// dispatch FileTreeReactBridge event to update the Angular modal with
// an error
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewFileModal', {
detail: {
error: true,
data: error.message
}
})
)
})
}
function finishCreatingDoc(entity) {
entity.endpoint = 'doc'
return finishCreatingDocOrFile(entity)
}
function finishCreatingLinkedFile(entity) {
entity.endpoint = 'linked_file'
return finishCreatingDocOrFile(entity)
}
function cancel() {
dispatch({ type: ACTION_TYPES.CANCEL })
}
return {
canDelete: selectedEntityIds.size > 0,
canRename: selectedEntityIds.size === 1,
canCreate: selectedEntityIds.size === 1,
isDeleting,
isRenaming,
isCreatingFolder,
inFlight,
actionedEntities,
error,
startRenaming,
finishRenaming,
startDeleting,
finishDeleting,
finishMoving,
startCreatingFolder,
finishCreatingFolder,
startCreatingDocOrFile,
startUploadingDocOrFile,
finishCreatingDoc,
finishCreatingLinkedFile,
cancel
}
}

View file

@ -0,0 +1,136 @@
import React, { useRef, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { DndProvider, createDndContext, useDrag, useDrop } from 'react-dnd'
import { HTML5Backend, getEmptyImage } from 'react-dnd-html5-backend'
import {
findAllInTreeOrThrow,
findAllFolderIdsInFolders
} from '../util/find-in-tree'
import { useFileTreeActionable } from './file-tree-actionable'
import { useFileTreeMutable } from './file-tree-mutable'
import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
const DndContext = createDndContext(HTML5Backend)
const DRAGGABLE_TYPE = 'ENTITY'
export function FileTreeDraggableProvider({ children }) {
const DndManager = useRef(DndContext)
return (
<DndProvider manager={DndManager.current.dragDropManager}>
{children}
</DndProvider>
)
}
FileTreeDraggableProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired
}
export function useDraggable(draggedEntityId) {
const { t } = useTranslation()
const { fileTreeData } = useFileTreeMutable()
const { selectedEntityIds } = useFileTreeSelectable()
const item = { type: DRAGGABLE_TYPE }
const [{ isDragging }, dragRef, preview] = useDrag({
item, // required, but overwritten by the return value of `begin`
begin: () => {
const draggedEntityIds = getDraggedEntityIds(
selectedEntityIds,
draggedEntityId
)
const draggedItems = findAllInTreeOrThrow(fileTreeData, draggedEntityIds)
const title = getDraggedTitle(draggedItems, t)
const forbiddenFolderIds = getForbiddenFolderIds(draggedItems)
return { ...item, title, forbiddenFolderIds, draggedEntityIds }
},
collect: monitor => ({
isDragging: !!monitor.isDragging()
})
})
// remove the automatic preview as we're using a custom preview via
// FileTreeDraggablePreviewLayer
useEffect(
() => {
preview(getEmptyImage())
},
[preview]
)
return {
dragRef,
isDragging
}
}
export function useDroppable(droppedEntityId) {
const { finishMoving } = useFileTreeActionable()
const [{ isOver }, dropRef] = useDrop({
accept: DRAGGABLE_TYPE,
canDrop: (item, monitor) => {
const isOver = monitor.isOver({ shallow: true })
if (!isOver) return false
if (item.forbiddenFolderIds.has(droppedEntityId)) return false
return true
},
drop: (item, monitor) => {
const didDropInChild = monitor.didDrop()
if (didDropInChild) return
finishMoving(droppedEntityId, item.draggedEntityIds)
},
collect: monitor => ({
isOver: monitor.canDrop()
})
})
return {
dropRef,
isOver
}
}
// Get the list of dragged entity ids. If the dragged entity is one of the
// selected entities then all the selected entites are dragged entities,
// otherwise it's the dragged entity only.
function getDraggedEntityIds(selectedEntityIds, draggedEntityId) {
if (selectedEntityIds.size > 1 && selectedEntityIds.has(draggedEntityId)) {
// dragging the multi-selected entities
return new Set(selectedEntityIds)
} else {
// not dragging the selection; only the current item
return new Set([draggedEntityId])
}
}
// Get the draggable title. This is the name of the dragged entities if there's
// only one, otherwise it's the number of dragged entities.
function getDraggedTitle(draggedItems, t) {
if (draggedItems.size === 1) {
const draggedItem = Array.from(draggedItems)[0]
return draggedItem.entity.name
}
return t('n_items', { count: draggedItems.size })
}
// Get all children folder ids of any of the dragged items.
function getForbiddenFolderIds(draggedItems) {
const draggedFoldersArray = Array.from(draggedItems)
.filter(draggedItem => {
return draggedItem.type === 'folder'
})
.map(draggedItem => draggedItem.entity)
const draggedFolders = new Set(draggedFoldersArray)
return findAllFolderIdsInFolders(draggedFolders)
}

View file

@ -0,0 +1,34 @@
import React, { createContext, useState } from 'react'
import PropTypes from 'prop-types'
export const FileTreeMainContext = createContext({})
export const FileTreeMainProvider = function({
projectId,
hasWritePermissions,
children
}) {
const [contextMenuCoords, setContextMenuCoords] = useState()
return (
<FileTreeMainContext.Provider
value={{
projectId,
hasWritePermissions,
contextMenuCoords,
setContextMenuCoords
}}
>
{children}
</FileTreeMainContext.Provider>
)
}
FileTreeMainProvider.propTypes = {
projectId: PropTypes.string.isRequired,
hasWritePermissions: PropTypes.bool.isRequired,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired
}

View file

@ -0,0 +1,128 @@
import React, { createContext, useReducer, useContext } from 'react'
import PropTypes from 'prop-types'
import {
renameInTree,
deleteInTree,
moveInTree,
createEntityInTree
} from '../util/mutate-in-tree'
const FileTreeMutableContext = createContext()
const ACTION_TYPES = {
RENAME: 'RENAME',
DELETE: 'DELETE',
MOVE: 'MOVE',
CREATE_ENTITY: 'CREATE_ENTITY'
}
function fileTreeMutableReducer({ fileTreeData }, action) {
switch (action.type) {
case ACTION_TYPES.RENAME:
return {
fileTreeData: renameInTree(fileTreeData, action.id, {
newName: action.newName
})
}
case ACTION_TYPES.DELETE:
return {
fileTreeData: deleteInTree(fileTreeData, action.id)
}
case ACTION_TYPES.MOVE:
return {
fileTreeData: moveInTree(
fileTreeData,
action.entityId,
action.toFolderId
)
}
case ACTION_TYPES.CREATE_ENTITY:
return {
fileTreeData: createEntityInTree(
fileTreeData,
action.parentFolderId,
action.entity
)
}
default:
throw new Error(`Unknown mutable file tree action type: ${action.type}`)
}
}
export const FileTreeMutableProvider = function({ rootFolder, children }) {
const [{ fileTreeData }, dispatch] = useReducer(fileTreeMutableReducer, {
fileTreeData: rootFolder[0]
})
return (
<FileTreeMutableContext.Provider value={{ fileTreeData, dispatch }}>
{children}
</FileTreeMutableContext.Provider>
)
}
FileTreeMutableProvider.propTypes = {
rootFolder: PropTypes.array.isRequired,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired
}
export function useFileTreeMutable() {
const { fileTreeData, dispatch } = useContext(FileTreeMutableContext)
function dispatchCreateFolder(parentFolderId, entity) {
entity.type = 'folder'
dispatch({
type: ACTION_TYPES.CREATE_ENTITY,
parentFolderId,
entity
})
}
function dispatchCreateDoc(parentFolderId, entity) {
entity.type = 'doc'
dispatch({
type: ACTION_TYPES.CREATE_ENTITY,
parentFolderId,
entity
})
}
function dispatchCreateFile(parentFolderId, entity) {
entity.type = 'fileRef'
dispatch({
type: ACTION_TYPES.CREATE_ENTITY,
parentFolderId,
entity
})
}
function dispatchRename(id, newName) {
dispatch({
type: ACTION_TYPES.RENAME,
newName,
id
})
}
function dispatchDelete(id) {
dispatch({ type: ACTION_TYPES.DELETE, id })
}
function dispatchMove(entityId, toFolderId) {
dispatch({ type: ACTION_TYPES.MOVE, entityId, toFolderId })
}
return {
fileTreeData,
dispatchRename,
dispatchDelete,
dispatchMove,
dispatchCreateFolder,
dispatchCreateDoc,
dispatchCreateFile
}
}

View file

@ -0,0 +1,167 @@
import React, { createContext, useContext, useReducer, useEffect } from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import { findInTree } from '../util/find-in-tree'
import { useFileTreeMutable } from './file-tree-mutable'
const FileTreeSelectableContext = createContext(new Set())
const ACTION_TYPES = {
SELECT: 'SELECT',
MULTI_SELECT: 'MULTI_SELECT',
UNSELECT: 'UNSELECT'
}
function fileTreeSelectableReadWriteReducer(selectedEntityIds, action) {
switch (action.type) {
case ACTION_TYPES.SELECT: {
// reset selection
return new Set([action.id])
}
case ACTION_TYPES.MULTI_SELECT: {
const selectedEntityIdsCopy = new Set(selectedEntityIds)
if (selectedEntityIdsCopy.has(action.id)) {
// entity already selected
if (selectedEntityIdsCopy.size > 1) {
// entity already multi-selected; remove from set
selectedEntityIdsCopy.delete(action.id)
}
} else {
// entity not selected: add to set
selectedEntityIdsCopy.add(action.id)
}
return selectedEntityIdsCopy
}
case ACTION_TYPES.UNSELECT: {
const selectedEntityIdsCopy = new Set(selectedEntityIds)
selectedEntityIdsCopy.delete(action.id)
return selectedEntityIdsCopy
}
default:
throw new Error(`Unknown selectable action type: ${action.type}`)
}
}
function fileTreeSelectableReadOnlyReducer(selectedEntityIds, action) {
switch (action.type) {
case ACTION_TYPES.SELECT:
return new Set([action.id])
case ACTION_TYPES.MULTI_SELECT:
case ACTION_TYPES.UNSELECT:
return selectedEntityIds
default:
throw new Error(`Unknown selectable action type: ${action.type}`)
}
}
export function FileTreeSelectableProvider({
hasWritePermissions,
initialSelectedEntityId = null,
onSelect,
children
}) {
const [selectedEntityIds, dispatch] = useReducer(
hasWritePermissions
? fileTreeSelectableReadWriteReducer
: fileTreeSelectableReadOnlyReducer,
initialSelectedEntityId ? new Set([initialSelectedEntityId]) : new Set()
)
const { fileTreeData } = useFileTreeMutable()
// calls `onSelect` on entities selection
useEffect(
() => {
const selectedEntities = Array.from(selectedEntityIds).map(id =>
findInTree(fileTreeData, id)
)
onSelect(selectedEntities)
},
[fileTreeData, selectedEntityIds]
)
useEffect(() => {
// listen for `editor.openDoc` and selected that doc
function handleOpenDoc(ev) {
dispatch({ type: ACTION_TYPES.SELECT, id: ev.detail })
}
window.addEventListener('editor.openDoc', handleOpenDoc)
return () => window.removeEventListener('editor.openDoc', handleOpenDoc)
}, [])
return (
<FileTreeSelectableContext.Provider value={{ selectedEntityIds, dispatch }}>
{children}
</FileTreeSelectableContext.Provider>
)
}
FileTreeSelectableProvider.propTypes = {
hasWritePermissions: PropTypes.bool.isRequired,
initialSelectedEntityId: PropTypes.string,
onSelect: PropTypes.func.isRequired,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired
}
export function useSelectableEntity(id) {
const { selectedEntityIds, dispatch } = useContext(FileTreeSelectableContext)
const isSelected = selectedEntityIds.has(id)
function selectOrMultiSelectEntity(ev) {
const isMultiSelect = ev.ctrlKey || ev.metaKey
const actionType = isMultiSelect
? ACTION_TYPES.MULTI_SELECT
: ACTION_TYPES.SELECT
dispatch({ type: actionType, id })
}
function handleClick(ev) {
selectOrMultiSelectEntity(ev)
}
function handleKeyPress(ev) {
if (ev.key === 'Enter' || ev.key === ' ') {
selectOrMultiSelectEntity(ev)
}
}
function handleContextMenu(ev) {
// make sure the right-clicked entity gets selected
if (!selectedEntityIds.has(id)) selectOrMultiSelectEntity(ev)
}
return {
isSelected,
props: {
className: classNames({ selected: isSelected }),
'aria-selected': isSelected,
onClick: handleClick,
onContextMenu: handleContextMenu,
onKeyPress: handleKeyPress
}
}
}
export function useFileTreeSelectable() {
const { selectedEntityIds, dispatch } = useContext(FileTreeSelectableContext)
function unselect(id) {
dispatch({ type: ACTION_TYPES.UNSELECT, id })
}
return {
selectedEntityIds,
unselect
}
}

View file

@ -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))

View file

@ -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]
)
}

View file

@ -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]
)
}

View file

@ -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
}

View file

@ -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'
}
}

View file

@ -0,0 +1,74 @@
import { findInTreeOrThrow } from './find-in-tree'
export function renameInTree(tree, id, { newName }) {
return mutateInTree(tree, id, (parent, entity, index) => {
const newParent = Object.assign([], parent)
const newEntity = {
...entity,
name: newName
}
newParent[index] = newEntity
return newParent
})
}
export function deleteInTree(tree, id) {
return mutateInTree(tree, id, (parent, entity, index) => {
return [...parent.slice(0, index), ...parent.slice(index + 1)]
})
}
export function moveInTree(tree, entityId, toFolderId) {
const found = findInTreeOrThrow(tree, entityId)
if (found.parentFolderId === toFolderId) {
// nothing to do (the entity was probably already moved)
return tree
}
const newFileTreeData = deleteInTree(tree, entityId)
return createEntityInTree(newFileTreeData, toFolderId, {
...found.entity,
type: found.type
})
}
export function createEntityInTree(tree, parentFolderId, newEntityData) {
const { type, ...newEntity } = newEntityData
if (!type) throw new Error('Entity has no type')
const entityType = `${type}s`
return mutateInTree(tree, parentFolderId, (parent, folder, index) => {
parent[index] = {
...folder,
[entityType]: [...folder[entityType], newEntity]
}
return parent
})
}
function mutateInTree(tree, id, mutationFunction) {
if (tree._id === id) {
// covers the root folder case: it has no parent so in order to use
// mutationFunction we pass an empty array as the parent and return the
// mutated tree directly
const [newTree] = mutationFunction([], tree, 0)
return newTree
}
for (const entityType of ['docs', 'fileRefs', 'folders']) {
for (let index = 0; index < tree[entityType].length; index++) {
const entity = tree[entityType][index]
if (entity._id === id) {
return {
...tree,
[entityType]: mutationFunction(tree[entityType], entity, index)
}
}
}
}
const newFolders = tree.folders.map(folder =>
mutateInTree(folder, id, mutationFunction)
)
return { ...tree, folders: newFolders }
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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()

View file

@ -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()
}
})
})
})

View file

@ -0,0 +1,18 @@
import { createRef, useEffect } from 'react'
export function useRefWithAutoFocus() {
const autoFocusedRef = createRef()
useEffect(
() => {
if (autoFocusedRef.current) {
requestAnimationFrame(() => {
if (autoFocusedRef.current) autoFocusedRef.current.focus()
})
}
},
[autoFocusedRef]
)
return { autoFocusedRef }
}

View file

@ -0,0 +1,9 @@
// run `fn` in serie for all values, and resolve with an array of the resultss
// inspired by https://stackoverflow.com/a/50506360/1314820
export function mapSeries(values, fn) {
return values.reduce((promiseChain, value) => {
return promiseChain.then(chainResults =>
fn(value).then(currentResult => [...chainResults, currentResult])
)
}, Promise.resolve([]))
}

View file

@ -0,0 +1,6 @@
export default function withoutPropagation(callback) {
return ev => {
ev.stopPropagation()
if (callback) callback(ev)
}
}

View file

@ -2,7 +2,14 @@ import React from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
function Icon({ type, spin, modifier, classes = {}, accessibilityLabel }) {
function Icon({
type,
spin,
modifier,
classes = {},
accessibilityLabel,
children
}) {
const iconClassName = classNames(
'fa',
`fa-${type}`,
@ -15,7 +22,9 @@ function Icon({ type, spin, modifier, classes = {}, accessibilityLabel }) {
return (
<>
<i className={iconClassName} aria-hidden="true" />
<i className={iconClassName} aria-hidden="true">
{children}
</i>
{accessibilityLabel ? (
<span className="sr-only">{accessibilityLabel}</span>
) : null}
@ -30,7 +39,11 @@ Icon.propTypes = {
classes: PropTypes.exact({
icon: PropTypes.string
}),
accessibilityLabel: PropTypes.string
accessibilityLabel: PropTypes.string,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
])
}
export default Icon

View file

@ -0,0 +1,25 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Button, Tooltip, OverlayTrigger } from 'react-bootstrap'
function TooltipButton({ id, description, onClick, children }) {
const tooltip = <Tooltip id={`${id}_tooltip`}>{description}</Tooltip>
return (
<OverlayTrigger placement="bottom" overlay={tooltip}>
<Button onClick={onClick}>{children}</Button>
</OverlayTrigger>
)
}
TooltipButton.propTypes = {
id: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
onClick: PropTypes.func,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
])
}
export default TooltipButton

View file

@ -0,0 +1,140 @@
import React from 'react'
import fetchMock from 'fetch-mock'
import MockedSocket from 'socket.io-mock'
import { rootFolderBase } from './fixtures/file-tree-base'
import { rootFolderLimit } from './fixtures/file-tree-limit'
import FileTreeRoot from '../js/features/file-tree/components/file-tree-root'
import FileTreeError from '../js/features/file-tree/components/file-tree-error'
const MOCK_DELAY = 2000
window._ide = {
socket: new MockedSocket()
}
function defaultSetupMocks() {
fetchMock
.restore()
.post(
/\/project\/\w+\/(file|doc|folder)\/\w+\/rename/,
(path, req) => {
const body = JSON.parse(req.body)
const entityId = path.match(/([^/]+)\/rename$/)[1]
window._ide.socket.socketClient.emit(
'reciveEntityRename',
entityId,
body.name
)
return 204
},
{
delay: MOCK_DELAY
}
)
.post(
/\/project\/\w+\/folder/,
(_path, req) => {
const body = JSON.parse(req.body)
const newFolder = {
folders: [],
fileRefs: [],
docs: [],
_id: Math.random()
.toString(16)
.replace(/0\./, 'random-test-id-'),
name: body.name
}
window._ide.socket.socketClient.emit(
'reciveNewFolder',
body.parent_folder_id,
newFolder
)
return newFolder
},
{
delay: MOCK_DELAY
}
)
.delete(
/\/project\/\w+\/(file|doc|folder)\/\w+/,
path => {
const entityId = path.match(/[^/]+$/)[0]
window._ide.socket.socketClient.emit('removeEntity', entityId)
return 204
},
{
delay: MOCK_DELAY
}
)
.post(/\/project\/\w+\/(file|doc|folder)\/\w+\/move/, (path, req) => {
const body = JSON.parse(req.body)
const entityId = path.match(/([^/]+)\/move/)[1]
window._ide.socket.socketClient.emit(
'reciveEntityMove',
entityId,
body.folder_id
)
return 204
})
}
export const FullTree = args => <FileTreeRoot {...args} />
FullTree.parameters = { setupMocks: defaultSetupMocks }
export const ReadOnly = args => <FileTreeRoot {...args} />
ReadOnly.args = { hasWritePermissions: false }
export const NetworkErrors = args => <FileTreeRoot {...args} />
NetworkErrors.parameters = {
setupMocks: () => {
fetchMock
.restore()
.post(/\/project\/\w+\/folder/, 500, {
delay: MOCK_DELAY
})
.post(/\/project\/\w+\/(file|doc|folder)\/\w+\/rename/, 500, {
delay: MOCK_DELAY
})
.delete(/\/project\/\w+\/(file|doc|folder)\/\w+/, 500, {
delay: MOCK_DELAY
})
}
}
export const FallbackError = args => <FileTreeError {...args} />
export const FilesLimit = args => <FileTreeRoot {...args} />
FilesLimit.args = { rootFolder: rootFolderLimit }
FilesLimit.parameters = { setupMocks: defaultSetupMocks }
export default {
title: 'File Tree',
component: FileTreeRoot,
args: {
rootFolder: rootFolderBase,
hasWritePermissions: true,
projectId: '123abc',
rootDocId: '5e74f1a7ce17ae0041dfd056'
},
argTypes: {
onSelect: { action: 'onSelect' }
},
decorators: [
(Story, { parameters: { setupMocks } }) => {
if (setupMocks) setupMocks()
return <Story />
},
Story => (
<>
<style>{'html, body, .file-tree { height: 100%; width: 100%; }'}</style>
<div className="editor-sidebar full-size">
<div className="file-tree">
<Story />
</div>
</div>
</>
)
]
}

View file

@ -0,0 +1,42 @@
export const rootFolderBase = [
{
_id: '5e74f1a7ce17ae0041dfd054',
name: 'rootFolder',
folders: [
{
_id: '5f638e58b652df0026c5c8f5',
name: 'a folder',
folders: [
{
_id: '5f956f62700e19000177daa0',
name: 'sub folder',
folders: [],
fileRefs: [],
docs: []
}
],
fileRefs: [
{ _id: '5cffb9d93da45d3995d05362', name: 'file-in-a-folder.pdf' }
],
docs: [{ _id: '5f46786322d556004e72a555', name: 'doc-in-a-folder.tex' }]
},
{
_id: '5f638e68b652df0026c5c8f6',
name: 'another folder',
folders: [],
fileRefs: [],
docs: []
}
],
fileRefs: [{ _id: '5f11c78e0924770027412a67', name: 'univers.jpg' }],
docs: [
{ _id: '5e74f1a7ce17ae0041dfd056', name: 'main.tex' },
{ _id: '5f46789522d556004e72a556', name: 'perso.bib' },
{
_id: '5da532e29019e800015321c6',
name: 'zotero.bib',
linkedFileData: { provider: 'zotero' }
}
]
}
]

View file

@ -0,0 +1,54 @@
const FILE_PER_FOLDER = 2
const DOC_PER_FOLDER = 3
const FOLDER_PER_FOLDER = 2
const MAX_DEPTH = 7
function fakeId() {
return Math.random()
.toString(16)
.replace(/0\./, 'random-test-id-')
}
function makeFileRefs(path) {
const fileRefs = []
for (let index = 0; index < FILE_PER_FOLDER; index++) {
fileRefs.push({ _id: fakeId(), name: `${path}-file-${index}.jpg` })
}
return fileRefs
}
function makeDocs(path) {
const docs = []
for (let index = 0; index < DOC_PER_FOLDER; index++) {
docs.push({ _id: fakeId(), name: `${path}-doc-${index}.tex` })
}
return docs
}
function makeFolders(path, depth = 0) {
const folders = []
for (let index = 0; index < FOLDER_PER_FOLDER; index++) {
const folderPath = `${path}-folder-${index}`
folders.push({
_id: fakeId(),
name: folderPath,
folders: depth < MAX_DEPTH ? makeFolders(folderPath, depth + 1) : [],
fileRefs: makeFileRefs(folderPath),
docs: makeDocs(folderPath)
})
}
return folders
}
export const rootFolderLimit = [
{
_id: fakeId(),
name: 'rootFolder',
folders: makeFolders('root'),
fileRefs: makeFileRefs('root'),
docs: makeDocs('root')
}
]

View file

@ -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);

View file

@ -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,

View file

@ -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;

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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",

View file

@ -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
}

View file

@ -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()
})

View file

@ -0,0 +1,60 @@
import { expect } from 'chai'
import React from 'react'
import { screen, fireEvent } from '@testing-library/react'
import renderWithContext from '../helpers/render-with-context'
import FileTreeDoc from '../../../../../frontend/js/features/file-tree/components/file-tree-doc'
describe('<FileTreeDoc/>', function() {
it('renders unselected', function() {
const { container } = renderWithContext(
<FileTreeDoc name="foo.tex" id="123abc" isLinkedFile={false} />
)
screen.getByRole('treeitem', { selected: false })
expect(container.querySelector('i.linked-file-highlight')).to.not.exist
})
it('renders selected', function() {
renderWithContext(
<FileTreeDoc name="foo.tex" id="123abc" isLinkedFile={false} />
)
const treeitem = screen.getByRole('treeitem', { selected: false })
fireEvent.click(treeitem)
screen.getByRole('treeitem', { selected: true })
screen.getByRole('menuitem', { name: 'Rename' })
screen.getByRole('menuitem', { name: 'Delete' })
screen.getByRole('menuitem', { name: 'New File' })
screen.getByRole('menuitem', { name: 'New Folder' })
screen.getByRole('menuitem', { name: 'Upload' })
})
it('renders as linked file', function() {
const { container } = renderWithContext(
<FileTreeDoc name="foo.tex" id="123abc" isLinkedFile />
)
screen.getByRole('treeitem')
expect(container.querySelector('i.linked-file-highlight')).to.exist
})
it('selects', function() {
renderWithContext(<FileTreeDoc name="foo.tex" id="123abc" expanded />)
const treeitem = screen.getByRole('treeitem', { selected: false })
fireEvent.click(treeitem)
screen.getByRole('treeitem', { selected: true })
})
it('multi-selects', function() {
renderWithContext(<FileTreeDoc name="foo.tex" id="123abc" expanded />)
const treeitem = screen.getByRole('treeitem')
fireEvent.click(treeitem, { ctrlKey: true })
screen.getByRole('treeitem', { selected: true })
})
})

View file

@ -0,0 +1,127 @@
import { expect } from 'chai'
import React from 'react'
import { screen, fireEvent } from '@testing-library/react'
import renderWithContext from '../helpers/render-with-context'
import FileTreeFolderList from '../../../../../frontend/js/features/file-tree/components/file-tree-folder-list'
describe('<FileTreeFolderList/>', function() {
it('renders empty', function() {
renderWithContext(<FileTreeFolderList folders={[]} docs={[]} files={[]} />)
screen.queryByRole('tree')
expect(screen.queryByRole('treeitem')).to.not.exist
})
it('renders docs, files and folders', function() {
const aFolder = {
_id: '456def',
name: 'A Folder',
folders: [],
docs: [],
fileRefs: []
}
const aDoc = { _id: '789ghi', name: 'doc.tex', linkedFileData: {} }
const aFile = { _id: '987jkl', name: 'file.bib', linkedFileData: {} }
renderWithContext(
<FileTreeFolderList folders={[aFolder]} docs={[aDoc]} files={[aFile]} />
)
screen.queryByRole('tree')
screen.queryByRole('treeitem', { name: 'A Folder' })
screen.queryByRole('treeitem', { name: 'doc.tex' })
screen.queryByRole('treeitem', { name: 'file.bib' })
})
describe('selection and multi-selection', function() {
it('without write permissions', function() {
const docs = [{ _id: '1', name: '1.tex' }, { _id: '2', name: '2.tex' }]
renderWithContext(
<FileTreeFolderList folders={[]} docs={docs} files={[]} />,
{ contextProps: { hasWritePermissions: false } }
)
const treeitem1 = screen.getByRole('treeitem', { name: '1.tex' })
const treeitem2 = screen.getByRole('treeitem', { name: '2.tex' })
// click on item 1: it gets selected
fireEvent.click(treeitem1)
screen.getByRole('treeitem', { name: '1.tex', selected: true })
screen.getByRole('treeitem', { name: '2.tex', selected: false })
// meta-click on item 2: no changes
fireEvent.click(treeitem2, { ctrlKey: true })
screen.getByRole('treeitem', { name: '1.tex', selected: true })
screen.getByRole('treeitem', { name: '2.tex', selected: false })
})
it('with write permissions', function() {
const docs = [
{ _id: '1', name: '1.tex' },
{ _id: '2', name: '2.tex' },
{ _id: '3', name: '3.tex' }
]
renderWithContext(
<FileTreeFolderList folders={[]} docs={docs} files={[]} />,
{ contextProps: { initialSelectedEntityId: '1' } }
)
const treeitem1 = screen.getByRole('treeitem', { name: '1.tex' })
const treeitem2 = screen.getByRole('treeitem', { name: '2.tex' })
const treeitem3 = screen.getByRole('treeitem', { name: '3.tex' })
// item 1 i selected by default
screen.getByRole('treeitem', { name: '1.tex', selected: true })
screen.getByRole('treeitem', { name: '2.tex', selected: false })
screen.getByRole('treeitem', { name: '3.tex', selected: false })
// click on item 2: it gets selected and item 1 is not selected anymore
fireEvent.click(treeitem2)
screen.getByRole('treeitem', { name: '1.tex', selected: false })
screen.getByRole('treeitem', { name: '2.tex', selected: true })
screen.getByRole('treeitem', { name: '3.tex', selected: false })
// meta-click on item 3: it gets selected and item 2 as well
fireEvent.click(treeitem3, { ctrlKey: true })
screen.getByRole('treeitem', { name: '1.tex', selected: false })
screen.getByRole('treeitem', { name: '2.tex', selected: true })
screen.getByRole('treeitem', { name: '3.tex', selected: true })
// meta-click on item 1: add to selection
fireEvent.click(treeitem1, { ctrlKey: true })
screen.getByRole('treeitem', { name: '1.tex', selected: true })
screen.getByRole('treeitem', { name: '2.tex', selected: true })
screen.getByRole('treeitem', { name: '3.tex', selected: true })
// meta-click on item 1: remove from selection
fireEvent.click(treeitem1, { ctrlKey: true })
screen.getByRole('treeitem', { name: '1.tex', selected: false })
screen.getByRole('treeitem', { name: '2.tex', selected: true })
screen.getByRole('treeitem', { name: '3.tex', selected: true })
// meta-click on item 3: remove from selection
fireEvent.click(treeitem3, { ctrlKey: true })
screen.getByRole('treeitem', { name: '1.tex', selected: false })
screen.getByRole('treeitem', { name: '2.tex', selected: true })
screen.getByRole('treeitem', { name: '3.tex', selected: false })
// meta-click on item 2: cannot unselect
fireEvent.click(treeitem2, { ctrlKey: true })
screen.getByRole('treeitem', { name: '1.tex', selected: false })
screen.getByRole('treeitem', { name: '2.tex', selected: true })
screen.getByRole('treeitem', { name: '3.tex', selected: false })
// meta-click on item 3: add back to selection
fireEvent.click(treeitem3, { ctrlKey: true })
screen.getByRole('treeitem', { name: '1.tex', selected: false })
screen.getByRole('treeitem', { name: '2.tex', selected: true })
screen.getByRole('treeitem', { name: '3.tex', selected: true })
// click on item 3: unselect other items
fireEvent.click(treeitem3)
screen.getByRole('treeitem', { name: '1.tex', selected: false })
screen.getByRole('treeitem', { name: '2.tex', selected: false })
screen.getByRole('treeitem', { name: '3.tex', selected: true })
})
})
})

View file

@ -0,0 +1,66 @@
import { expect } from 'chai'
import React from 'react'
import { screen, fireEvent } from '@testing-library/react'
import renderWithContext from '../helpers/render-with-context'
import FileTreeFolder from '../../../../../frontend/js/features/file-tree/components/file-tree-folder'
describe('<FileTreeFolder/>', function() {
it('renders unselected', function() {
renderWithContext(
<FileTreeFolder
name="foo"
id="123abc"
folders={[]}
docs={[]}
files={[]}
/>
)
screen.getByRole('treeitem', { selected: false })
expect(screen.queryByRole('tree')).to.not.exist
})
it('renders selected', function() {
renderWithContext(
<FileTreeFolder
name="foo"
id="123abc"
folders={[]}
docs={[]}
files={[]}
/>
)
const treeitem = screen.getByRole('treeitem', { selected: false })
fireEvent.click(treeitem)
screen.getByRole('treeitem', { selected: true })
screen.getByRole('menuitem', { name: 'Rename' })
screen.getByRole('menuitem', { name: 'Delete' })
screen.getByRole('menuitem', { name: 'New File' })
screen.getByRole('menuitem', { name: 'New Folder' })
screen.getByRole('menuitem', { name: 'Upload' })
screen.getByRole('treeitem', { selected: true })
expect(screen.queryByRole('tree')).to.not.exist
})
it('expands', function() {
renderWithContext(
<FileTreeFolder
name="foo"
id="123abc"
folders={[]}
docs={[]}
files={[]}
/>
)
screen.getByRole('treeitem')
const expandButton = screen.getByRole('button', { name: 'Expand' })
fireEvent.click(expandButton)
screen.getByRole('tree')
})
})

View file

@ -0,0 +1,81 @@
import { expect } from 'chai'
import React from 'react'
import sinon from 'sinon'
import { screen, fireEvent } from '@testing-library/react'
import renderWithContext from '../../helpers/render-with-context'
import FileTreeitemInner from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner'
describe('<FileTreeitemInner />', function() {
const setContextMenuCoords = sinon.stub()
beforeEach(function() {
global.requestAnimationFrame = sinon.stub()
})
afterEach(function() {
setContextMenuCoords.reset()
delete global.requestAnimationFrame
})
describe('menu', function() {
it('does not display if file is not selected', function() {
renderWithContext(
<FileTreeitemInner id="123abc" name="bar.tex" isSelected={false} />,
{}
)
expect(screen.queryByRole('menu', { visible: false })).to.not.exist
})
})
describe('context menu', function() {
it('does not display without write permissions', function() {
renderWithContext(
<FileTreeitemInner id="123abc" name="bar.tex" isSelected />,
{ contextProps: { hasWritePermissions: false } }
)
expect(screen.queryByRole('menu', { visible: false })).to.not.exist
})
it('open / close', function() {
const { container } = renderWithContext(
<FileTreeitemInner id="123abc" name="bar.tex" isSelected />
)
const entityElement = container.querySelector('div.entity')
screen.getByRole('menu', { visible: false })
fireEvent.contextMenu(entityElement)
screen.getByRole('menu', { visible: true })
fireEvent.contextMenu(entityElement)
screen.getByRole('menu', { visible: false })
})
})
describe('name', function() {
it('renders name', function() {
renderWithContext(
<FileTreeitemInner id="123abc" name="bar.tex" isSelected />
)
screen.getByRole('button', { name: 'bar.tex' })
expect(screen.queryByRole('textbox')).to.not.exist
})
it('starts rename on menu item click', function() {
renderWithContext(
<FileTreeitemInner id="123abc" name="bar.tex" isSelected />,
{ contextProps: { initialSelectedEntityId: '123abc' } }
)
const renameButton = screen.getByRole('menuitem', { name: 'Rename' })
fireEvent.click(renameButton)
expect(screen.queryByRole('button', { name: 'bar.tex' })).to.not.exist
screen.getByRole('textbox')
})
})
})

View file

@ -0,0 +1,45 @@
import React from 'react'
import sinon from 'sinon'
import { screen, fireEvent } from '@testing-library/react'
import renderWithContext from '../../helpers/render-with-context'
import FileTreeitemMenu from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu'
describe('<FileTreeitemMenu />', function() {
const setContextMenuCoords = sinon.stub()
afterEach(function() {
setContextMenuCoords.reset()
})
it('renders dropdown', function() {
renderWithContext(
<FileTreeitemMenu
id="123abc"
setContextMenuCoords={setContextMenuCoords}
/>
)
screen.getByRole('button', { name: 'Menu' })
screen.getByRole('menu')
})
it('open / close', function() {
renderWithContext(
<FileTreeitemMenu
id="123abc"
setContextMenuCoords={setContextMenuCoords}
/>
)
const toggleButton = screen.getByRole('button', { name: 'Menu' })
screen.getByRole('menu', { visible: false })
fireEvent.click(toggleButton)
screen.getByRole('menu', { visible: true })
fireEvent.click(toggleButton)
screen.getByRole('menu', { visible: false })
})
})

View file

@ -0,0 +1,80 @@
import { expect } from 'chai'
import React from 'react'
import sinon from 'sinon'
import { screen, fireEvent } from '@testing-library/react'
import renderWithContext from '../../helpers/render-with-context'
import FileTreeItemName from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name'
describe('<FileTreeItemName />', function() {
beforeEach(function() {
global.requestAnimationFrame = sinon.stub()
})
afterEach(function() {
delete global.requestAnimationFrame
})
it('renders name as button', function() {
renderWithContext(<FileTreeItemName name="foo.tex" isSelected />)
screen.getByRole('button', { name: 'foo.tex' })
expect(screen.queryByRole('textbox')).to.not.exist
})
it("doesn't start renaming on unselected component", function() {
renderWithContext(<FileTreeItemName name="foo.tex" isSelected={false} />)
const button = screen.queryByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.doubleClick(button)
expect(screen.queryByRole('textbox')).to.not.exist
})
it('start renaming on double-click', function() {
renderWithContext(<FileTreeItemName name="foo.tex" isSelected />)
const button = screen.queryByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.doubleClick(button)
screen.getByRole('textbox')
expect(screen.queryByRole('button')).to.not.exist
expect(global.requestAnimationFrame).to.be.calledOnce
})
it('cannot start renaming in read-only', function() {
renderWithContext(<FileTreeItemName name="foo.tex" isSelected />, {
contextProps: { hasWritePermissions: false }
})
const button = screen.queryByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.doubleClick(button)
expect(screen.queryByRole('textbox')).to.not.exist
})
describe('stop renaming', function() {
beforeEach(function() {
renderWithContext(<FileTreeItemName name="foo.tex" isSelected />)
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.doubleClick(button)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'bar.tex' } })
})
it('on Escape', function() {
const input = screen.getByRole('textbox')
fireEvent.keyDown(input, { key: 'Escape' })
screen.getByRole('button', { name: 'foo.tex' })
})
})
})

View file

@ -0,0 +1,123 @@
import React from 'react'
import sinon from 'sinon'
import { screen, render, fireEvent } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
describe('<FileTreeRoot/>', function() {
const onSelect = sinon.stub()
const onInit = sinon.stub()
beforeEach(function() {
global.requestAnimationFrame = sinon.stub()
})
afterEach(function() {
delete global.requestAnimationFrame
fetchMock.restore()
onSelect.reset()
onInit.reset()
})
it('renders', function() {
const rootFolder = [
{
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId={'123abc'}
hasWritePermissions={false}
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
/>
)
screen.queryByRole('tree')
screen.getByRole('treeitem')
screen.getByRole('treeitem', { name: 'main.tex', selected: true })
})
it('fire onSelect', function() {
const rootFolder = [
{
docs: [
{ _id: '456def', name: 'main.tex' },
{ _id: '789ghi', name: 'other.tex' }
],
folders: [],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId="123abc"
rootDocId="456def"
hasWritePermissions={false}
onSelect={onSelect}
onInit={onInit}
/>
)
sinon.assert.calledOnce(onSelect)
sinon.assert.calledWithMatch(onSelect, [
sinon.match({
entity: {
_id: '456def',
name: 'main.tex'
}
})
])
onSelect.reset()
screen.queryByRole('tree')
const treeitem = screen.getByRole('treeitem', { name: 'other.tex' })
fireEvent.click(treeitem)
sinon.assert.calledOnce(onSelect)
sinon.assert.calledWithMatch(onSelect, [
sinon.match({
entity: {
_id: '789ghi',
name: 'other.tex'
}
})
])
})
it('listen to editor.openDoc', function() {
const rootFolder = [
{
docs: [
{ _id: '456def', name: 'main.tex' },
{ _id: '789ghi', name: 'other.tex' }
],
folders: [],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId="123abc"
rootDocId="456def"
hasWritePermissions={false}
onSelect={onSelect}
onInit={onInit}
/>
)
screen.getByRole('treeitem', { name: 'main.tex', selected: true })
window.dispatchEvent(
new CustomEvent('editor.openDoc', { detail: '789ghi' })
)
screen.getByRole('treeitem', { name: 'main.tex', selected: false })
screen.getByRole('treeitem', { name: 'other.tex', selected: true })
})
})

View file

@ -0,0 +1,35 @@
import { expect } from 'chai'
import React from 'react'
import { screen } from '@testing-library/react'
import renderWithContext from '../helpers/render-with-context'
import FileTreeToolbar from '../../../../../frontend/js/features/file-tree/components/file-tree-toolbar'
describe('<FileTreeToolbar/>', function() {
it('without selected files', function() {
renderWithContext(<FileTreeToolbar />)
expect(screen.queryByRole('button', { name: 'New File' })).to.not.exist
expect(screen.queryByRole('button', { name: 'New Folder' })).to.not.exist
expect(screen.queryByRole('button', { name: 'Upload' })).to.not.exist
expect(screen.queryByRole('button', { name: 'Rename' })).to.not.exist
expect(screen.queryByRole('button', { name: 'Delete' })).to.not.exist
})
it('read-only', function() {
renderWithContext(<FileTreeToolbar />, {
contextProps: { hasWritePermissions: false }
})
expect(screen.queryByRole('button')).to.not.exist
})
it('with one selected file', function() {
renderWithContext(<FileTreeToolbar />, {
contextProps: { initialSelectedEntityId: '123abc' }
})
screen.getByRole('button', { name: 'Rename' })
screen.getByRole('button', { name: 'Delete' })
})
})

View file

@ -0,0 +1,65 @@
import { expect } from 'chai'
import React from 'react'
import sinon from 'sinon'
import { screen, render, fireEvent } from '@testing-library/react'
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
describe('FileTree Context Menu Flow', function() {
const onSelect = sinon.stub()
const onInit = sinon.stub()
it('opens on contextMenu event', async function() {
const rootFolder = [
{
_id: 'root-folder-id',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
/>
)
const treeitem = screen.getByRole('button', { name: 'main.tex' })
expect(screen.getAllByRole('menu').length).to.equal(1) // toolbar
fireEvent.contextMenu(treeitem)
expect(screen.getAllByRole('menu').length).to.equal(2) // toolbar + menu
})
it("doesn't open in read only mode", async function() {
const rootFolder = [
{
_id: 'root-folder-id',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions={false}
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
/>
)
const treeitem = screen.getByRole('button', { name: 'main.tex' })
fireEvent.contextMenu(treeitem)
expect(screen.queryByRole('menu')).to.not.exist
})
})

View file

@ -0,0 +1,231 @@
import { expect } from 'chai'
import React from 'react'
import sinon from 'sinon'
import { screen, render, fireEvent } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import MockedSocket from 'socket.io-mock'
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
describe('FileTree Create Folder Flow', function() {
const onSelect = sinon.stub()
const onInit = sinon.stub()
beforeEach(function() {
global.requestAnimationFrame = sinon.stub()
window._ide = {
socket: new MockedSocket()
}
})
afterEach(function() {
delete global.requestAnimationFrame
fetchMock.restore()
onSelect.reset()
onInit.reset()
delete window._ide
})
it('add to root', async function() {
const rootFolder = [
{
_id: 'root-folder-id',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
/>
)
const newFolderName = 'Foo Bar In Root'
const matcher = /\/project\/\w+\/folder/
const response = {
folders: [],
fileRefs: [],
docs: [],
_id: fakeId(),
name: newFolderName
}
fetchMock.post(matcher, response)
fireCreateFolder(newFolderName)
const lastCallBody = JSON.parse(fetchMock.lastCall(matcher)[1].body)
expect(lastCallBody.name).to.equal(newFolderName)
expect(lastCallBody.parent_folder_id).to.equal('root-folder-id')
window._ide.socket.socketClient.emit('reciveNewFolder', 'root-folder-id', {
_id: fakeId(),
name: newFolderName,
docs: [],
fileRefs: [],
folders: []
})
await screen.findByRole('treeitem', { name: newFolderName })
})
it('add to folder from folder', async function() {
const rootFolder = [
{
_id: 'root-folder-id',
docs: [],
folders: [
{
_id: '789ghi',
name: 'thefolder',
docs: [],
fileRefs: [],
folders: []
}
],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
rootDocId="789ghi"
onSelect={onSelect}
onInit={onInit}
/>
)
const expandButton = screen.getByRole('button', { name: 'Expand' })
fireEvent.click(expandButton)
const newFolderName = 'Foo Bar In thefolder'
const matcher = /\/project\/\w+\/folder/
const response = {
folders: [],
fileRefs: [],
docs: [],
_id: fakeId(),
name: newFolderName
}
fetchMock.post(matcher, response)
fireCreateFolder(newFolderName)
const lastCallBody = JSON.parse(fetchMock.lastCall(matcher)[1].body)
expect(lastCallBody.name).to.equal(newFolderName)
expect(lastCallBody.parent_folder_id).to.equal('789ghi')
window._ide.socket.socketClient.emit('reciveNewFolder', '789ghi', {
_id: fakeId(),
name: newFolderName,
docs: [],
fileRefs: [],
folders: []
})
// find the created folder
await screen.findByRole('treeitem', { name: newFolderName })
// collapse the parent folder; created folder should not be rendered anymore
fireEvent.click(expandButton)
expect(screen.queryByRole('treeitem', { name: newFolderName })).to.not.exist
})
it('add to folder from child', async function() {
const rootFolder = [
{
_id: 'root-folder-id',
docs: [],
folders: [
{
_id: '789ghi',
name: 'thefolder',
docs: [],
fileRefs: [{ _id: '456def', name: 'sub.tex' }],
folders: []
}
],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
/>
)
const expandButton = screen.getByRole('button', { name: 'Expand' })
fireEvent.click(expandButton)
const newFolderName = 'Foo Bar In thefolder'
const matcher = /\/project\/\w+\/folder/
const response = {
folders: [],
fileRefs: [],
docs: [],
_id: fakeId(),
name: newFolderName
}
fetchMock.post(matcher, response)
fireCreateFolder(newFolderName)
const lastCallBody = JSON.parse(fetchMock.lastCall(matcher)[1].body)
expect(lastCallBody.name).to.equal(newFolderName)
expect(lastCallBody.parent_folder_id).to.equal('789ghi')
window._ide.socket.socketClient.emit('reciveNewFolder', '789ghi', {
_id: fakeId(),
name: newFolderName,
docs: [],
fileRefs: [],
folders: []
})
// find the created folder
await screen.findByRole('treeitem', { name: newFolderName })
// collapse the parent folder; created folder should not be rendered anymore
fireEvent.click(expandButton)
expect(screen.queryByRole('treeitem', { name: newFolderName })).to.not.exist
})
function fireCreateFolder(name) {
const createFolderButton = screen.getByRole('button', {
name: 'New Folder'
})
fireEvent.click(createFolderButton)
const input = screen.getByRole('textbox', {
hidden: true // FIXME: modal should not be hidden but it has the aria-hidden label due to a react-bootstrap bug
})
fireEvent.change(input, { target: { value: name } })
const modalCreateButton = getModalCreateButton()
fireEvent.click(modalCreateButton)
}
function fakeId() {
return Math.random()
.toString(16)
.replace(/0\./, 'random-test-id-')
}
function getModalCreateButton() {
return screen.getAllByRole('button', {
name: 'Create',
hidden: true // FIXME: modal should not be hidden but it has the aria-hidden label due to a react-bootstrap bug
})[0] // the first matched button is the toolbar button
}
})

View file

@ -0,0 +1,188 @@
import { expect } from 'chai'
import React from 'react'
import sinon from 'sinon'
import { screen, render, fireEvent, waitFor } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import MockedSocket from 'socket.io-mock'
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
describe('FileTree Delete Entity Flow', function() {
const onSelect = sinon.stub()
const onInit = sinon.stub()
beforeEach(function() {
window._ide = {
socket: new MockedSocket()
}
})
afterEach(function() {
fetchMock.restore()
onSelect.reset()
onInit.reset()
delete window._ide
})
describe('single entity', function() {
beforeEach(function() {
const rootFolder = [
{
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId={'123abc'}
hasWritePermissions
onSelect={onSelect}
onInit={onInit}
/>
)
const treeitem = screen.getByRole('treeitem', { name: 'main.tex' })
fireEvent.click(treeitem)
const deleteButton = screen.getByRole('menuitem', { name: 'Delete' })
fireEvent.click(deleteButton)
})
it('removes item', async function() {
const fetchMatcher = /\/project\/\w+\/doc\/\w+/
fetchMock.delete(fetchMatcher, 204)
const modalDeleteButton = getModalDeleteButton()
fireEvent.click(modalDeleteButton)
window._ide.socket.socketClient.emit('removeEntity', '456def')
await waitFor(() => {
expect(
screen.queryByRole('treeitem', {
name: 'main.tex',
hidden: true // treeitem might be hidden behind the modal
})
).to.not.exist
expect(
screen.queryByRole('treeitem', {
name: 'main.tex'
})
).to.not.exist
})
const [lastFetchPath] = fetchMock.lastCall(fetchMatcher)
expect(lastFetchPath).to.equal('/project/123abc/doc/456def')
})
it('continues delete on 404s', async function() {
fetchMock.delete(/\/project\/\w+\/doc\/\w+/, 404)
const modalDeleteButton = getModalDeleteButton()
fireEvent.click(modalDeleteButton)
window._ide.socket.socketClient.emit('removeEntity', '456def')
await waitFor(() => {
expect(
screen.queryByRole('treeitem', {
name: 'main.tex',
hidden: true // treeitem might be hidden behind the modal
})
).to.not.exist
expect(
screen.queryByRole('treeitem', {
name: 'main.tex'
})
).to.not.exist
})
})
it('aborts delete on error', async function() {
const fetchMatcher = /\/project\/\w+\/doc\/\w+/
fetchMock.delete(fetchMatcher, 500)
const modalDeleteButton = getModalDeleteButton()
fireEvent.click(modalDeleteButton)
// The modal should still be open, but the file should not be deleted
await screen.findByRole('treeitem', { name: 'main.tex', hidden: true })
})
})
describe('multiple entities', function() {
beforeEach(function() {
const rootFolder = [
{
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: [{ _id: '789ghi', name: 'my.bib' }]
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId={'123abc'}
hasWritePermissions
onSelect={onSelect}
onInit={onInit}
/>
)
const treeitemDoc = screen.getByRole('treeitem', { name: 'main.tex' })
fireEvent.click(treeitemDoc)
const treeitemFile = screen.getByRole('treeitem', { name: 'my.bib' })
fireEvent.click(treeitemFile, { ctrlKey: true })
const deleteButton = screen.getAllByRole('menuitem', {
name: 'Delete'
})[0]
fireEvent.click(deleteButton)
})
it('removes all items', async function() {
const fetchMatcher = /\/project\/\w+\/(doc|file)\/\w+/
fetchMock.delete(fetchMatcher, 204)
const modalDeleteButton = getModalDeleteButton()
fireEvent.click(modalDeleteButton)
window._ide.socket.socketClient.emit('removeEntity', '456def')
window._ide.socket.socketClient.emit('removeEntity', '789ghi')
await waitFor(() => {
for (const name of ['main.tex', 'my.bib']) {
expect(
screen.queryByRole('treeitem', {
name,
hidden: true // treeitem might be hidden behind the modal
})
).to.not.exist
expect(
screen.queryByRole('treeitem', {
name
})
).to.not.exist
}
})
const [firstFetchPath, secondFetchPath] = fetchMock
.calls()
.map(([url]) => url)
expect(firstFetchPath).to.equal('/project/123abc/doc/456def')
expect(secondFetchPath).to.equal('/project/123abc/file/789ghi')
})
})
function getModalDeleteButton() {
return screen.getAllByRole('button', {
name: 'Delete',
hidden: true // FIXME: modal should not be hidden but it has the aria-hidden label due to a react-bootstrap bug
})[1] // the first matched button is the toolbar button
}
})

View file

@ -0,0 +1,140 @@
import { expect } from 'chai'
import React from 'react'
import sinon from 'sinon'
import { screen, render, fireEvent } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import MockedSocket from 'socket.io-mock'
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
describe('FileTree Rename Entity Flow', function() {
const onSelect = sinon.stub()
const onInit = sinon.stub()
beforeEach(function() {
window._ide = {
socket: new MockedSocket()
}
global.requestAnimationFrame = sinon.stub()
})
afterEach(function() {
delete global.requestAnimationFrame
fetchMock.restore()
onSelect.reset()
onInit.reset()
delete window._ide
})
beforeEach(function() {
const rootFolder = [
{
docs: [{ _id: '456def', name: 'a.tex' }],
folders: [
{
_id: '987jkl',
name: 'folder',
docs: [],
fileRefs: [{ _id: '789ghi', name: 'c.tex' }],
folders: []
}
],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId={'123abc'}
hasWritePermissions
onSelect={onSelect}
onInit={onInit}
/>
)
})
it('renames doc', function() {
const fetchMatcher = /\/project\/\w+\/doc\/\w+\/rename/
fetchMock.post(fetchMatcher, 204)
const input = initItemRename('a.tex')
fireEvent.change(input, { target: { value: 'b.tex' } })
fireEvent.keyDown(input, { key: 'Enter' })
screen.getByRole('treeitem', { name: 'b.tex' })
const lastFetchBody = getLastFetchBody(fetchMatcher)
expect(lastFetchBody.name).to.equal('b.tex')
})
it('renames folder', function() {
const fetchMatcher = /\/project\/\w+\/folder\/\w+\/rename/
fetchMock.post(fetchMatcher, 204)
const input = initItemRename('folder')
fireEvent.change(input, { target: { value: 'new folder name' } })
fireEvent.keyDown(input, { key: 'Enter' })
screen.getByRole('treeitem', { name: 'new folder name' })
const lastFetchBody = getLastFetchBody(fetchMatcher)
expect(lastFetchBody.name).to.equal('new folder name')
})
it('renames file in subfolder', function() {
const fetchMatcher = /\/project\/\w+\/file\/\w+\/rename/
fetchMock.post(fetchMatcher, 204)
const expandButton = screen.getByRole('button', { name: 'Expand' })
fireEvent.click(expandButton)
const input = initItemRename('c.tex')
fireEvent.change(input, { target: { value: 'd.tex' } })
fireEvent.keyDown(input, { key: 'Enter' })
screen.getByRole('treeitem', { name: 'folder' })
screen.getByRole('treeitem', { name: 'd.tex' })
const lastFetchBody = getLastFetchBody(fetchMatcher)
expect(lastFetchBody.name).to.equal('d.tex')
})
it('reverts rename on error', async function() {
const fetchMatcher = /\/project\/\w+\/doc\/\w+\/rename/
fetchMock.post(fetchMatcher, 500)
const input = initItemRename('a.tex')
fireEvent.change(input, { target: { value: 'b.tex' } })
fireEvent.keyDown(input, { key: 'Enter' })
screen.getByRole('treeitem', { name: 'b.tex' })
})
describe('via socket event', function() {
it('renames doc', function() {
screen.getByRole('treeitem', { name: 'a.tex' })
window._ide.socket.socketClient.emit(
'reciveEntityRename',
'456def',
'socket.tex'
)
screen.getByRole('treeitem', { name: 'socket.tex' })
})
})
function initItemRename(treeitemName) {
const treeitem = screen.getByRole('treeitem', { name: treeitemName })
fireEvent.click(treeitem)
const renameButton = screen.getByRole('menuitem', { name: 'Rename' })
fireEvent.click(renameButton)
return screen.getByRole('textbox')
}
function getLastFetchBody(matcher) {
const [, { body }] = fetchMock.lastCall(matcher)
return JSON.parse(body)
}
})

View file

@ -0,0 +1,18 @@
import React from 'react'
import { render } from '@testing-library/react'
import FileTreeContext from '../../../../../frontend/js/features/file-tree/components/file-tree-context'
export default (children, options = {}) => {
let { contextProps = {}, ...renderOptions } = options
contextProps = {
projectId: '123abc',
rootFolder: [{}],
hasWritePermissions: true,
onSelect: () => {},
...contextProps
}
return render(
<FileTreeContext {...contextProps}>{children}</FileTreeContext>,
renderOptions
)
}

View file

@ -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')
})
})

View file

@ -43,4 +43,16 @@ describe('<Icon />', function() {
)
expect(element).to.exist
})
it('renders children', function() {
const { container } = render(
<Icon type="angle-down">
<Icon type="angle-up" />
</Icon>
)
const element = container.querySelector(
'i.fa.fa-angle-down > i.fa.fa-angle-up'
)
expect(element).to.exist
})
})