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