Jelajahi Sumber

Merge pull request #6209 from overleaf/ta-file-tree-rework

File Tree Misc Code Changes

GitOrigin-RevId: dce64a5378ecee5c8a2e25e02502ae631d87f36b
Timothée Alby 4 tahun lalu
induk
melakukan
392410390e
37 mengubah file dengan 509 tambahan dan 429 penghapusan
  1. 0 5
      services/web/app/views/project/editor/file-tree-react.pug
  2. 9 3
      services/web/frontend/js/features/file-tree/components/file-tree-context-menu.js
  3. 3 20
      services/web/frontend/js/features/file-tree/components/file-tree-context.js
  4. 7 5
      services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.js
  5. 6 2
      services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.js
  6. 7 2
      services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.js
  7. 10 2
      services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.js
  8. 2 5
      services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name.js
  9. 9 15
      services/web/frontend/js/features/file-tree/components/file-tree-root.js
  10. 8 3
      services/web/frontend/js/features/file-tree/components/file-tree-toolbar.js
  11. 17 7
      services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.js
  12. 8 4
      services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.js
  13. 0 9
      services/web/frontend/js/features/file-tree/contexts/file-tree-main.js
  14. 21 7
      services/web/frontend/js/features/file-tree/contexts/file-tree-mutable.js
  15. 18 13
      services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js
  16. 0 16
      services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js
  17. 12 0
      services/web/frontend/js/shared/context/project-context.js
  18. 37 17
      services/web/frontend/stories/file-tree.stories.js
  19. 9 1
      services/web/frontend/stories/fixtures/context.js
  20. 35 21
      services/web/frontend/stories/modals/create-file/create-file-modal-decorator.js
  21. 17 16
      services/web/frontend/stories/modals/create-file/create-file-modal.stories.js
  22. 0 26
      services/web/test/frontend/features/file-tree/components/file-tree-create/context-props.js
  23. 21 30
      services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-create-name-input.test.js
  24. 40 80
      services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-modal-create-file.test.js
  25. 9 3
      services/web/test/frontend/features/file-tree/components/file-tree-doc.test.js
  26. 7 3
      services/web/test/frontend/features/file-tree/components/file-tree-folder-list.test.js
  27. 9 3
      services/web/test/frontend/features/file-tree/components/file-tree-folder.test.js
  28. 13 5
      services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-inner.test.js
  29. 1 1
      services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-name.test.js
  30. 54 36
      services/web/test/frontend/features/file-tree/components/file-tree-root.test.js
  31. 3 2
      services/web/test/frontend/features/file-tree/components/file-tree-toolbar.test.js
  32. 15 12
      services/web/test/frontend/features/file-tree/flows/context-menu.test.js
  33. 27 23
      services/web/test/frontend/features/file-tree/flows/create-folder.test.js
  34. 19 15
      services/web/test/frontend/features/file-tree/flows/delete-entity.test.js
  35. 6 5
      services/web/test/frontend/features/file-tree/flows/rename-entity.test.js
  36. 23 6
      services/web/test/frontend/features/file-tree/helpers/render-with-context.js
  37. 27 6
      services/web/test/frontend/helpers/render-with-context.js

+ 0 - 5
services/web/app/views/project/editor/file-tree-react.pug

@@ -12,14 +12,9 @@ aside.editor-sidebar.full-size(
 		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"
 			is-connected="isConnected"
-			user-has-feature="userHasFeature"
 			ref-providers="refProviders"
 			reindex-references="reindexReferences"
 			set-ref-provider-enabled="setRefProviderEnabled"

+ 9 - 3
services/web/frontend/js/features/file-tree/components/file-tree-context-menu.js

@@ -1,16 +1,18 @@
 import React from 'react'
 import ReactDOM from 'react-dom'
+import PropTypes from 'prop-types'
 
 import { Dropdown } from 'react-bootstrap'
+import { useEditorContext } from '../../../shared/context/editor-context'
 import { useFileTreeMainContext } from '../contexts/file-tree-main'
 
 import FileTreeItemMenuItems from './file-tree-item/file-tree-item-menu-items'
 
 function FileTreeContextMenu() {
-  const { hasWritePermissions, contextMenuCoords, setContextMenuCoords } =
-    useFileTreeMainContext()
+  const { permissionsLevel } = useEditorContext(editorContextPropTypes)
+  const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
 
-  if (!hasWritePermissions || !contextMenuCoords) return null
+  if (permissionsLevel === 'readOnly' || !contextMenuCoords) return null
 
   function close() {
     // reset context menu
@@ -41,6 +43,10 @@ function FileTreeContextMenu() {
   )
 }
 
+const editorContextPropTypes = {
+  permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
+}
+
 // 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) => {

+ 3 - 20
services/web/frontend/js/features/file-tree/components/file-tree-context.js

@@ -12,11 +12,6 @@ import { FileTreeDraggableProvider } from '../contexts/file-tree-draggable'
 // FileTreeMutable: provides entities mutation operations
 // FileTreeSelectable: handles selection and multi-selection
 function FileTreeContext({
-  projectId,
-  rootFolder,
-  hasWritePermissions,
-  rootDocId,
-  userHasFeature,
   refProviders,
   reindexReferences,
   setRefProviderEnabled,
@@ -26,21 +21,14 @@ function FileTreeContext({
 }) {
   return (
     <FileTreeMainProvider
-      projectId={projectId}
-      hasWritePermissions={hasWritePermissions}
-      userHasFeature={userHasFeature}
       refProviders={refProviders}
       setRefProviderEnabled={setRefProviderEnabled}
       setStartedFreeTrial={setStartedFreeTrial}
       reindexReferences={reindexReferences}
     >
-      <FileTreeMutableProvider rootFolder={rootFolder}>
-        <FileTreeSelectableProvider
-          hasWritePermissions={hasWritePermissions}
-          rootDocId={rootDocId}
-          onSelect={onSelect}
-        >
-          <FileTreeActionableProvider hasWritePermissions={hasWritePermissions}>
+      <FileTreeMutableProvider>
+        <FileTreeSelectableProvider onSelect={onSelect}>
+          <FileTreeActionableProvider>
             <FileTreeDraggableProvider>{children}</FileTreeDraggableProvider>
           </FileTreeActionableProvider>
         </FileTreeSelectableProvider>
@@ -50,15 +38,10 @@ function FileTreeContext({
 }
 
 FileTreeContext.propTypes = {
-  projectId: PropTypes.string.isRequired,
-  rootFolder: PropTypes.array.isRequired,
-  hasWritePermissions: PropTypes.bool.isRequired,
-  userHasFeature: PropTypes.func.isRequired,
   reindexReferences: PropTypes.func.isRequired,
   refProviders: PropTypes.object.isRequired,
   setRefProviderEnabled: PropTypes.func.isRequired,
   setStartedFreeTrial: PropTypes.func.isRequired,
-  rootDocId: PropTypes.string,
   onSelect: PropTypes.func.isRequired,
   children: PropTypes.oneOfType([
     PropTypes.arrayOf(PropTypes.node),

+ 7 - 5
services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.js

@@ -10,7 +10,7 @@ import { useProjectOutputFiles } from '../../../hooks/use-project-output-files'
 import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
 import { useFileTreeCreateName } from '../../../contexts/file-tree-create-name'
 import { useFileTreeCreateForm } from '../../../contexts/file-tree-create-form'
-import { useFileTreeMainContext } from '../../../contexts/file-tree-main'
+import { useProjectContext } from '../../../../../shared/context/project-context'
 import ErrorMessage from '../error-message'
 
 export default function FileTreeImportFromProject() {
@@ -23,7 +23,6 @@ export default function FileTreeImportFromProject() {
 
   const { name, setName, validName } = useFileTreeCreateName()
   const { setValid } = useFileTreeCreateForm()
-  const { projectId } = useFileTreeMainContext()
   const { error, finishCreatingLinkedFile } = useFileTreeActionable()
 
   const [selectedProject, setSelectedProject] = useState()
@@ -112,7 +111,6 @@ export default function FileTreeImportFromProject() {
   return (
     <form className="form-controls" id="create-file" onSubmit={handleSubmit}>
       <SelectProject
-        projectId={projectId}
         selectedProject={selectedProject}
         setSelectedProject={setSelectedProject}
       />
@@ -162,8 +160,9 @@ export default function FileTreeImportFromProject() {
   )
 }
 
-function SelectProject({ projectId, selectedProject, setSelectedProject }) {
+function SelectProject({ selectedProject, setSelectedProject }) {
   const { t } = useTranslation()
+  const { _id: projectId } = useProjectContext(projectContextPropTypes)
 
   const { data, error, loading } = useUserProjects()
 
@@ -219,11 +218,14 @@ function SelectProject({ projectId, selectedProject, setSelectedProject }) {
   )
 }
 SelectProject.propTypes = {
-  projectId: PropTypes.string.isRequired,
   selectedProject: PropTypes.object,
   setSelectedProject: PropTypes.func.isRequired,
 }
 
+const projectContextPropTypes = {
+  _id: PropTypes.string.isRequired,
+}
+
 function SelectProjectOutputFile({
   selectedProjectId,
   selectedProjectOutputFile,

+ 6 - 2
services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.js

@@ -6,7 +6,7 @@ import Uppy from '@uppy/core'
 import XHRUpload from '@uppy/xhr-upload'
 import { Dashboard, useUppy } from '@uppy/react'
 import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
-import { useFileTreeMainContext } from '../../../contexts/file-tree-main'
+import { useProjectContext } from '../../../../../shared/context/project-context'
 
 import '@uppy/core/dist/style.css'
 import '@uppy/dashboard/dist/style.css'
@@ -15,7 +15,7 @@ import ErrorMessage from '../error-message'
 
 export default function FileTreeUploadDoc() {
   const { parentFolderId, cancel, isDuplicate } = useFileTreeActionable()
-  const { projectId } = useFileTreeMainContext()
+  const { _id: projectId } = useProjectContext(projectContextPropTypes)
 
   const [error, setError] = useState()
 
@@ -162,6 +162,10 @@ export default function FileTreeUploadDoc() {
   )
 }
 
+const projectContextPropTypes = {
+  _id: PropTypes.string.isRequired,
+}
+
 function UploadErrorMessage({ error, maxNumberOfFiles }) {
   switch (error) {
     case 'too-many-files':

+ 7 - 2
services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.js

@@ -1,10 +1,11 @@
 import { useState, useEffect } from 'react'
+import PropTypes from 'prop-types'
 import { Trans } from 'react-i18next'
-import { useFileTreeMainContext } from '../../contexts/file-tree-main'
+import { useProjectContext } from '../../../../shared/context/project-context'
 
 // handle "not-logged-in" errors by redirecting to the login page
 export default function RedirectToLogin() {
-  const { projectId } = useFileTreeMainContext()
+  const { _id: projectId } = useProjectContext(projectContextPropTypes)
 
   const [secondsToRedirect, setSecondsToRedirect] = useState(10)
 
@@ -35,3 +36,7 @@ export default function RedirectToLogin() {
     />
   )
 }
+
+const projectContextPropTypes = {
+  _id: PropTypes.string.isRequired,
+}

+ 10 - 2
services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.js

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types'
 import classNames from 'classnames'
 import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed'
 
+import { useEditorContext } from '../../../../shared/context/editor-context'
 import { useFileTreeMainContext } from '../../contexts/file-tree-main'
 import { useDraggable } from '../../contexts/file-tree-draggable'
 
@@ -11,12 +12,15 @@ import FileTreeItemMenu from './file-tree-item-menu'
 import { useFileTreeSelectable } from '../../contexts/file-tree-selectable'
 
 function FileTreeItemInner({ id, name, isSelected, icons }) {
-  const { hasWritePermissions, setContextMenuCoords } = useFileTreeMainContext()
+  const { permissionsLevel } = useEditorContext(editorContextPropTypes)
+  const { setContextMenuCoords } = useFileTreeMainContext()
 
   const { selectedEntityIds } = useFileTreeSelectable()
 
   const hasMenu =
-    hasWritePermissions && isSelected && selectedEntityIds.size === 1
+    permissionsLevel !== 'readOnly' &&
+    isSelected &&
+    selectedEntityIds.size === 1
 
   const { isDragging, dragRef, setIsDraggable } = useDraggable(id)
 
@@ -82,4 +86,8 @@ FileTreeItemInner.propTypes = {
   icons: PropTypes.node,
 }
 
+const editorContextPropTypes = {
+  permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
+}
+
 export default FileTreeItemInner

+ 2 - 5
services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name.js

@@ -4,19 +4,16 @@ import PropTypes from 'prop-types'
 import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
 
 import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
-import { useFileTreeMainContext } from '../../contexts/file-tree-main'
 
 function FileTreeItemName({ name, isSelected, setIsDraggable }) {
-  const { hasWritePermissions } = useFileTreeMainContext()
-
   const { isRenaming, startRenaming, finishRenaming, error, cancel } =
     useFileTreeActionable()
 
   const isRenamingEntity = isRenaming && isSelected && !error
 
   useEffect(() => {
-    setIsDraggable(hasWritePermissions && !isRenamingEntity)
-  }, [setIsDraggable, hasWritePermissions, isRenamingEntity])
+    setIsDraggable(!isRenamingEntity)
+  }, [setIsDraggable, isRenamingEntity])
 
   if (isRenamingEntity) {
     return (

+ 9 - 15
services/web/frontend/js/features/file-tree/components/file-tree-root.js

@@ -2,6 +2,7 @@ import React, { useEffect } from 'react'
 import PropTypes from 'prop-types'
 
 import withErrorBoundary from '../../../infrastructure/error-boundary'
+import { useProjectContext } from '../../../shared/context/project-context'
 import FileTreeContext from './file-tree-context'
 import FileTreeDraggablePreviewLayer from './file-tree-draggable-preview-layer'
 import FileTreeFolderList from './file-tree-folder-list'
@@ -19,11 +20,6 @@ import { useFileTreeSocketListener } from '../hooks/file-tree-socket-listener'
 import FileTreeModalCreateFile from './modals/file-tree-modal-create-file'
 
 const FileTreeRoot = React.memo(function FileTreeRoot({
-  projectId,
-  rootFolder,
-  rootDocId,
-  hasWritePermissions,
-  userHasFeature,
   refProviders,
   reindexReferences,
   setRefProviderEnabled,
@@ -32,6 +28,9 @@ const FileTreeRoot = React.memo(function FileTreeRoot({
   onInit,
   isConnected,
 }) {
+  const { _id: projectId, rootFolder } = useProjectContext(
+    projectContextPropTypes
+  )
   const isReady = projectId && rootFolder
 
   useEffect(() => {
@@ -41,15 +40,10 @@ const FileTreeRoot = React.memo(function FileTreeRoot({
 
   return (
     <FileTreeContext
-      projectId={projectId}
-      hasWritePermissions={hasWritePermissions}
-      userHasFeature={userHasFeature}
       refProviders={refProviders}
       setRefProviderEnabled={setRefProviderEnabled}
       setStartedFreeTrial={setStartedFreeTrial}
       reindexReferences={reindexReferences}
-      rootFolder={rootFolder}
-      rootDocId={rootDocId}
       onSelect={onSelect}
     >
       {isConnected ? null : <div className="disconnected-overlay" />}
@@ -90,18 +84,18 @@ function FileTreeRootFolder() {
 }
 
 FileTreeRoot.propTypes = {
-  projectId: PropTypes.string,
-  rootFolder: PropTypes.array,
-  rootDocId: PropTypes.string,
-  hasWritePermissions: PropTypes.bool.isRequired,
   onSelect: PropTypes.func.isRequired,
   onInit: PropTypes.func.isRequired,
   isConnected: PropTypes.bool.isRequired,
   setRefProviderEnabled: PropTypes.func.isRequired,
-  userHasFeature: PropTypes.func.isRequired,
   setStartedFreeTrial: PropTypes.func.isRequired,
   reindexReferences: PropTypes.func.isRequired,
   refProviders: PropTypes.object.isRequired,
 }
 
+const projectContextPropTypes = {
+  _id: PropTypes.string.isRequired,
+  rootFolder: PropTypes.array.isRequired,
+}
+
 export default withErrorBoundary(FileTreeRoot, FileTreeError)

+ 8 - 3
services/web/frontend/js/features/file-tree/components/file-tree-toolbar.js

@@ -1,15 +1,16 @@
+import PropTypes from 'prop-types'
 import { useTranslation } from 'react-i18next'
 
 import Icon from '../../../shared/components/icon'
 import TooltipButton from '../../../shared/components/tooltip-button'
 
-import { useFileTreeMainContext } from '../contexts/file-tree-main'
+import { useEditorContext } from '../../../shared/context/editor-context'
 import { useFileTreeActionable } from '../contexts/file-tree-actionable'
 
 function FileTreeToolbar() {
-  const { hasWritePermissions } = useFileTreeMainContext()
+  const { permissionsLevel } = useEditorContext(editorContextPropTypes)
 
-  if (!hasWritePermissions) return null
+  if (permissionsLevel === 'readOnly') return null
 
   return (
     <div className="toolbar toolbar-filetree">
@@ -19,6 +20,10 @@ function FileTreeToolbar() {
   )
 }
 
+const editorContextPropTypes = {
+  permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
+}
+
 function FileTreeToolbarLeft() {
   const { t } = useTranslation()
   const {

+ 17 - 7
services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.js

@@ -20,7 +20,8 @@ import { findInTree, findInTreeOrThrow } from '../util/find-in-tree'
 import { isNameUniqueInFolder } from '../util/is-name-unique-in-folder'
 import { isBlockedFilename, isCleanFilename } from '../util/safe-path'
 
-import { useFileTreeMainContext } from './file-tree-main'
+import { useProjectContext } from '../../../shared/context/project-context'
+import { useEditorContext } from '../../../shared/context/editor-context'
 import { useFileTreeMutable } from './file-tree-mutable'
 import { useFileTreeSelectable } from './file-tree-selectable'
 
@@ -117,15 +118,17 @@ function fileTreeActionableReducer(state, action) {
   }
 }
 
-export function FileTreeActionableProvider({ hasWritePermissions, children }) {
+export function FileTreeActionableProvider({ children }) {
+  const { _id: projectId } = useProjectContext(projectContextPropTypes)
+  const { permissionsLevel } = useEditorContext(editorContextPropTypes)
+
   const [state, dispatch] = useReducer(
-    hasWritePermissions
-      ? fileTreeActionableReducer
-      : fileTreeActionableReadOnlyReducer,
+    permissionsLevel === 'readOnly'
+      ? fileTreeActionableReadOnlyReducer
+      : fileTreeActionableReducer,
     defaultState
   )
 
-  const { projectId } = useFileTreeMainContext()
   const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeMutable()
   const { selectedEntityIds } = useFileTreeSelectable()
 
@@ -370,13 +373,20 @@ export function FileTreeActionableProvider({ hasWritePermissions, children }) {
 }
 
 FileTreeActionableProvider.propTypes = {
-  hasWritePermissions: PropTypes.bool.isRequired,
   children: PropTypes.oneOfType([
     PropTypes.arrayOf(PropTypes.node),
     PropTypes.node,
   ]).isRequired,
 }
 
+const projectContextPropTypes = {
+  _id: PropTypes.string.isRequired,
+}
+
+const editorContextPropTypes = {
+  permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
+}
+
 export function useFileTreeActionable() {
   const context = useContext(FileTreeActionableContext)
 

+ 8 - 4
services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.js

@@ -13,7 +13,7 @@ import {
 import { useFileTreeActionable } from './file-tree-actionable'
 import { useFileTreeMutable } from './file-tree-mutable'
 import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
-import { useFileTreeMainContext } from './file-tree-main'
+import { useEditorContext } from '../../../shared/context/editor-context'
 
 // HACK ALERT
 // DnD binds drag and drop events on window and stop propagation if the dragged
@@ -76,11 +76,11 @@ FileTreeDraggableProvider.propTypes = {
 export function useDraggable(draggedEntityId) {
   const { t } = useTranslation()
 
-  const { hasWritePermissions } = useFileTreeMainContext()
+  const { permissionsLevel } = useEditorContext(editorContextPropTypes)
   const { fileTreeData } = useFileTreeMutable()
   const { selectedEntityIds } = useFileTreeSelectable()
 
-  const [isDraggable, setIsDraggable] = useState(hasWritePermissions)
+  const [isDraggable, setIsDraggable] = useState(true)
 
   const item = { type: DRAGGABLE_TYPE }
   const [{ isDragging }, dragRef, preview] = useDrag({
@@ -98,7 +98,7 @@ export function useDraggable(draggedEntityId) {
     collect: monitor => ({
       isDragging: !!monitor.isDragging(),
     }),
-    canDrag: () => isDraggable,
+    canDrag: () => permissionsLevel !== 'readOnly' && isDraggable,
   })
 
   // remove the automatic preview as we're using a custom preview via
@@ -114,6 +114,10 @@ export function useDraggable(draggedEntityId) {
   }
 }
 
+const editorContextPropTypes = {
+  permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
+}
+
 export function useDroppable(droppedEntityId) {
   const { finishMoving } = useFileTreeActionable()
 

+ 0 - 9
services/web/frontend/js/features/file-tree/contexts/file-tree-main.js

@@ -16,9 +16,6 @@ export function useFileTreeMainContext() {
 }
 
 export const FileTreeMainProvider = function ({
-  projectId,
-  hasWritePermissions,
-  userHasFeature,
   refProviders,
   reindexReferences,
   setRefProviderEnabled,
@@ -30,9 +27,6 @@ export const FileTreeMainProvider = function ({
   return (
     <FileTreeMainContext.Provider
       value={{
-        projectId,
-        hasWritePermissions,
-        userHasFeature,
         refProviders,
         reindexReferences,
         setRefProviderEnabled,
@@ -47,9 +41,6 @@ export const FileTreeMainProvider = function ({
 }
 
 FileTreeMainProvider.propTypes = {
-  projectId: PropTypes.string.isRequired,
-  hasWritePermissions: PropTypes.bool.isRequired,
-  userHasFeature: PropTypes.func.isRequired,
   reindexReferences: PropTypes.func.isRequired,
   refProviders: PropTypes.object.isRequired,
   setRefProviderEnabled: PropTypes.func.isRequired,

+ 21 - 7
services/web/frontend/js/features/file-tree/contexts/file-tree-mutable.js

@@ -13,6 +13,7 @@ import {
   moveInTree,
   createEntityInTree,
 } from '../util/mutate-in-tree'
+import { useProjectContext } from '../../../shared/context/project-context'
 
 const FileTreeMutableContext = createContext()
 
@@ -21,7 +22,7 @@ const ACTION_TYPES = {
   RESET: 'RESET',
   DELETE: 'DELETE',
   MOVE: 'MOVE',
-  CREATE_ENTITY: 'CREATE_ENTITY',
+  CREATE: 'CREATE',
 }
 
 function fileTreeMutableReducer({ fileTreeData }, action) {
@@ -68,7 +69,7 @@ function fileTreeMutableReducer({ fileTreeData }, action) {
       }
     }
 
-    case ACTION_TYPES.CREATE_ENTITY: {
+    case ACTION_TYPES.CREATE: {
       const newFileTreeData = createEntityInTree(
         fileTreeData,
         action.parentFolderId,
@@ -92,7 +93,9 @@ const initialState = rootFolder => ({
   fileCount: countFiles(rootFolder[0]),
 })
 
-export const FileTreeMutableProvider = function ({ rootFolder, children }) {
+export const FileTreeMutableProvider = function ({ children }) {
+  const { rootFolder } = useProjectContext(projectContextPropTypes)
+
   const [{ fileTreeData, fileCount }, dispatch] = useReducer(
     fileTreeMutableReducer,
     rootFolder,
@@ -109,7 +112,7 @@ export const FileTreeMutableProvider = function ({ rootFolder, children }) {
   const dispatchCreateFolder = useCallback((parentFolderId, entity) => {
     entity.type = 'folder'
     dispatch({
-      type: ACTION_TYPES.CREATE_ENTITY,
+      type: ACTION_TYPES.CREATE,
       parentFolderId,
       entity,
     })
@@ -118,7 +121,7 @@ export const FileTreeMutableProvider = function ({ rootFolder, children }) {
   const dispatchCreateDoc = useCallback((parentFolderId, entity) => {
     entity.type = 'doc'
     dispatch({
-      type: ACTION_TYPES.CREATE_ENTITY,
+      type: ACTION_TYPES.CREATE,
       parentFolderId,
       entity,
     })
@@ -127,7 +130,7 @@ export const FileTreeMutableProvider = function ({ rootFolder, children }) {
   const dispatchCreateFile = useCallback((parentFolderId, entity) => {
     entity.type = 'fileRef'
     dispatch({
-      type: ACTION_TYPES.CREATE_ENTITY,
+      type: ACTION_TYPES.CREATE,
       parentFolderId,
       entity,
     })
@@ -168,13 +171,24 @@ export const FileTreeMutableProvider = function ({ rootFolder, children }) {
 }
 
 FileTreeMutableProvider.propTypes = {
-  rootFolder: PropTypes.array.isRequired,
   children: PropTypes.oneOfType([
     PropTypes.arrayOf(PropTypes.node),
     PropTypes.node,
   ]).isRequired,
 }
 
+const projectContextPropTypes = {
+  rootFolder: PropTypes.arrayOf(
+    PropTypes.shape({
+      _id: PropTypes.string.isRequired,
+      name: PropTypes.string.isRequired,
+      docs: PropTypes.array.isRequired,
+      fileRefs: PropTypes.array.isRequired,
+      folders: PropTypes.array.isRequired,
+    })
+  ),
+}
+
 export function useFileTreeMutable() {
   const context = useContext(FileTreeMutableContext)
 

+ 18 - 13
services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js

@@ -13,7 +13,8 @@ import _ from 'lodash'
 
 import { findInTree } from '../util/find-in-tree'
 import { useFileTreeMutable } from './file-tree-mutable'
-import { useFileTreeMainContext } from './file-tree-main'
+import { useProjectContext } from '../../../shared/context/project-context'
+import { useEditorContext } from '../../../shared/context/editor-context'
 import usePersistedState from '../../../shared/hooks/use-persisted-state'
 import usePreviousValue from '../../../shared/hooks/use-previous-value'
 
@@ -73,13 +74,11 @@ function fileTreeSelectableReadOnlyReducer(selectedEntityIds, action) {
   }
 }
 
-export function FileTreeSelectableProvider({
-  hasWritePermissions,
-  rootDocId,
-  onSelect,
-  children,
-}) {
-  const { projectId } = useFileTreeMainContext()
+export function FileTreeSelectableProvider({ onSelect, children }) {
+  const { _id: projectId, rootDoc_id: rootDocId } = useProjectContext(
+    projectContextPropTypes
+  )
+  const { permissionsLevel } = useEditorContext(editorContextPropTypes)
 
   const [initialSelectedEntityId] = usePersistedState(
     `doc.open_id.${projectId}`,
@@ -89,9 +88,9 @@ export function FileTreeSelectableProvider({
   const { fileTreeData } = useFileTreeMutable()
 
   const [selectedEntityIds, dispatch] = useReducer(
-    hasWritePermissions
-      ? fileTreeSelectableReadWriteReducer
-      : fileTreeSelectableReadOnlyReducer,
+    permissionsLevel === 'readOnly'
+      ? fileTreeSelectableReadOnlyReducer
+      : fileTreeSelectableReadWriteReducer,
     null,
     () => {
       if (!initialSelectedEntityId) return new Set()
@@ -179,8 +178,6 @@ export function FileTreeSelectableProvider({
 }
 
 FileTreeSelectableProvider.propTypes = {
-  hasWritePermissions: PropTypes.bool.isRequired,
-  rootDocId: PropTypes.string,
   onSelect: PropTypes.func.isRequired,
   children: PropTypes.oneOfType([
     PropTypes.arrayOf(PropTypes.node),
@@ -188,6 +185,14 @@ FileTreeSelectableProvider.propTypes = {
   ]).isRequired,
 }
 
+const projectContextPropTypes = {
+  _id: PropTypes.string.isRequired,
+  rootDoc_id: PropTypes.string,
+}
+
+const editorContextPropTypes = {
+  permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
+}
 export function useSelectableEntity(id) {
   const { selectedEntityIds, selectOrMultiSelectEntity } = useContext(
     FileTreeSelectableContext

+ 0 - 16
services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js

@@ -13,22 +13,12 @@ App.controller(
     ide
     // eventTracking
   ) {
-    $scope.projectId = ide.project_id
-    $scope.rootFolder = null
-    $scope.rootDocId = null
-    $scope.hasWritePermissions = false
     $scope.isConnected = true
 
     $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 })
@@ -85,12 +75,6 @@ App.controller(
       }
     }
 
-    $scope.userHasFeature = feature => ide.$scope.user.features[feature]
-
-    $scope.$watch('permissions.write', hasWritePermissions => {
-      $scope.hasWritePermissions = hasWritePermissions
-    })
-
     $scope.refProviders = ide.$scope.user.refProviders || {}
 
     ide.$scope.$watch(

+ 12 - 0
services/web/frontend/js/shared/context/project-context.js

@@ -4,6 +4,14 @@ import useScopeValue from '../hooks/use-scope-value'
 
 const ProjectContext = createContext()
 
+const fileTreeDataPropType = PropTypes.shape({
+  _id: PropTypes.string.isRequired,
+  name: PropTypes.string.isRequired,
+  docs: PropTypes.array.isRequired,
+  fileRefs: PropTypes.array.isRequired,
+  folders: PropTypes.array.isRequired,
+})
+
 ProjectContext.Provider.propTypes = {
   value: PropTypes.shape({
     _id: PropTypes.string.isRequired,
@@ -23,6 +31,9 @@ ProjectContext.Provider.propTypes = {
       collaborators: PropTypes.number,
       compileGroup: PropTypes.oneOf(['alpha', 'standard', 'priority']),
       trackChangesVisible: PropTypes.bool,
+      references: PropTypes.bool,
+      mendeley: PropTypes.bool,
+      zotero: PropTypes.bool,
     }),
     publicAccesLevel: PropTypes.string,
     tokens: PropTypes.shape({
@@ -33,6 +44,7 @@ ProjectContext.Provider.propTypes = {
       _id: PropTypes.string.isRequired,
       email: PropTypes.string.isRequired,
     }),
+    rootFolder: PropTypes.arrayOf(fileTreeDataPropType),
   }),
 }
 

+ 37 - 17
services/web/frontend/stories/file-tree.stories.js

@@ -1,6 +1,6 @@
 import MockedSocket from 'socket.io-mock'
 
-import { ContextRoot } from '../js/shared/context/root-context'
+import { withContextRoot } from './utils/with-context-root'
 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'
@@ -12,6 +12,12 @@ const MOCK_DELAY = 2000
 window._ide = {
   socket: new MockedSocket(),
 }
+const DEFAULT_PROJECT = {
+  _id: '123abc',
+  name: 'Some Project',
+  rootDocId: '5e74f1a7ce17ae0041dfd056',
+  rootFolder: rootFolderBase,
+}
 
 function defaultSetupMocks(fetchMock) {
   fetchMock
@@ -80,13 +86,25 @@ function defaultSetupMocks(fetchMock) {
 export const FullTree = args => {
   useFetchMock(defaultSetupMocks)
 
-  return <FileTreeRoot {...args} />
+  return withContextRoot(<FileTreeRoot {...args} />, {
+    project: DEFAULT_PROJECT,
+    permissionsLevel: 'owner',
+  })
 }
 
-export const ReadOnly = args => <FileTreeRoot {...args} />
-ReadOnly.args = { hasWritePermissions: false }
+export const ReadOnly = args => {
+  return withContextRoot(<FileTreeRoot {...args} />, {
+    project: DEFAULT_PROJECT,
+    permissionsLevel: 'readOnly',
+  })
+}
 
-export const Disconnected = args => <FileTreeRoot {...args} />
+export const Disconnected = args => {
+  return withContextRoot(<FileTreeRoot {...args} />, {
+    project: DEFAULT_PROJECT,
+    permissionsLevel: 'owner',
+  })
+}
 Disconnected.args = { isConnected: false }
 
 export const NetworkErrors = args => {
@@ -106,24 +124,31 @@ export const NetworkErrors = args => {
       })
   })
 
-  return <FileTreeRoot {...args} />
+  return withContextRoot(<FileTreeRoot {...args} />, {
+    project: DEFAULT_PROJECT,
+    permissionsLevel: 'owner',
+  })
 }
 
-export const FallbackError = args => <FileTreeError {...args} />
+export const FallbackError = args => {
+  return withContextRoot(<FileTreeError {...args} />, {
+    project: DEFAULT_PROJECT,
+  })
+}
 
 export const FilesLimit = args => {
   useFetchMock(defaultSetupMocks)
 
-  return <FileTreeRoot {...args} />
+  return withContextRoot(<FileTreeRoot {...args} />, {
+    project: { ...DEFAULT_PROJECT, rootFolder: rootFolderLimit },
+    permissionsLevel: 'owner',
+  })
 }
-FilesLimit.args = { rootFolder: rootFolderLimit }
 
 export default {
   title: 'File Tree',
   component: FileTreeRoot,
   args: {
-    rootFolder: rootFolderBase,
-    hasWritePermissions: true,
     setStartedFreeTrial: () => {
       console.log('started free trial')
     },
@@ -131,12 +156,9 @@ export default {
     reindexReferences: () => {
       console.log('reindex references')
     },
-    userHasFeature: () => true,
     setRefProviderEnabled: provider => {
       console.log(`ref provider ${provider} enabled`)
     },
-    projectId: '123abc',
-    rootDocId: '5e74f1a7ce17ae0041dfd056',
     isConnected: true,
   },
   argTypes: {
@@ -149,9 +171,7 @@ export default {
         <style>{'html, body, .file-tree { height: 100%; width: 100%; }'}</style>
         <div className="editor-sidebar full-size">
           <div className="file-tree">
-            <ContextRoot ide={window._ide} settings={{}}>
-              <Story />
-            </ContextRoot>
+            <Story />
           </div>
         </div>
       </>

+ 9 - 1
services/web/frontend/stories/fixtures/context.js

@@ -13,6 +13,15 @@ export function setupContext() {
       user: window.user,
       project: {
         features: {},
+        rootFolder: [
+          {
+            _id: 'root-folder-id',
+            name: 'rootFolder',
+            docs: [],
+            folders: [],
+            fileRefs: [],
+          },
+        ],
       },
       $watch: () => {},
       $applyAsync: () => {},
@@ -25,7 +34,6 @@ export function setupContext() {
         pdfViewer: 'js',
       },
       toggleHistory: () => {},
-      rootFolder: { type: 'folder', children: [] },
     }
   }
   window._ide = {

+ 35 - 21
services/web/frontend/stories/modals/create-file/create-file-modal-decorator.js

@@ -1,15 +1,29 @@
 import { useEffect } from 'react'
+import { withContextRoot } from './../../utils/with-context-root'
 import FileTreeContext from '../../../js/features/file-tree/components/file-tree-context'
 import FileTreeCreateNameProvider from '../../../js/features/file-tree/contexts/file-tree-create-name'
 import FileTreeCreateFormProvider from '../../../js/features/file-tree/contexts/file-tree-create-form'
 import { useFileTreeActionable } from '../../../js/features/file-tree/contexts/file-tree-actionable'
 import PropTypes from 'prop-types'
 
-const defaultContextProps = {
-  projectId: 'project-1',
-  hasWritePermissions: true,
-  userHasFeature: () => true,
-  refProviders: {},
+export const DEFAULT_PROJECT = {
+  _id: '123abc',
+  name: 'Some Project',
+  rootDocId: '5e74f1a7ce17ae0041dfd056',
+  rootFolder: [
+    {
+      _id: 'root-folder-id',
+      name: 'rootFolder',
+      docs: [],
+      folders: [],
+      fileRefs: [],
+    },
+  ],
+  features: { mendeley: true, zotero: true },
+}
+
+const defaultFileTreeContextProps = {
+  refProviders: { mendeley: false, zotero: false },
   reindexReferences: () => {
     console.log('reindex references')
   },
@@ -19,17 +33,6 @@ const defaultContextProps = {
   setStartedFreeTrial: () => {
     console.log('started free trial')
   },
-  rootFolder: [
-    {
-      docs: [
-        {
-          _id: 'entity-1',
-        },
-      ],
-      fileRefs: [],
-      folders: [],
-    },
-  ],
   initialSelectedEntityId: 'entity-1',
   onSelect: () => {
     console.log('selected')
@@ -99,11 +102,18 @@ export const mockCreateFileModalFetch = fetchMock =>
     })
 
 export const createFileModalDecorator =
-  (contextProps = {}, createMode = 'doc') =>
-  // eslint-disable-next-line react/display-name
+  (
+    fileTreeContextProps = {},
+    projectProps = {},
+    createMode = 'doc'
+    // eslint-disable-next-line react/display-name
+  ) =>
   Story => {
-    return (
-      <FileTreeContext {...defaultContextProps} {...contextProps}>
+    return withContextRoot(
+      <FileTreeContext
+        {...defaultFileTreeContextProps}
+        {...fileTreeContextProps}
+      >
         <FileTreeCreateNameProvider>
           <FileTreeCreateFormProvider>
             <OpenCreateFileModal createMode={createMode}>
@@ -111,7 +121,11 @@ export const createFileModalDecorator =
             </OpenCreateFileModal>
           </FileTreeCreateFormProvider>
         </FileTreeCreateNameProvider>
-      </FileTreeContext>
+      </FileTreeContext>,
+      {
+        project: { ...DEFAULT_PROJECT, ...projectProps },
+        permissionsLevel: 'owner',
+      }
     )
   }
 

+ 17 - 16
services/web/frontend/stories/modals/create-file/create-file-modal.stories.js

@@ -11,11 +11,7 @@ export const MinimalFeatures = args => {
 
   return <FileTreeModalCreateFile {...args} />
 }
-MinimalFeatures.decorators = [
-  createFileModalDecorator({
-    userHasFeature: () => false,
-  }),
-]
+MinimalFeatures.decorators = [createFileModalDecorator()]
 
 export const WithExtraFeatures = args => {
   useFetchMock(mockCreateFileModalFetch)
@@ -82,17 +78,22 @@ export const FileLimitReached = args => {
   return <FileTreeModalCreateFile {...args} />
 }
 FileLimitReached.decorators = [
-  createFileModalDecorator({
-    rootFolder: [
-      {
-        docs: Array.from({ length: 10 }, (_, index) => ({
-          _id: `entity-${index}`,
-        })),
-        fileRefs: [],
-        folders: [],
-      },
-    ],
-  }),
+  createFileModalDecorator(
+    {},
+    {
+      rootFolder: [
+        {
+          _id: 'root-folder-id',
+          name: 'rootFolder',
+          docs: Array.from({ length: 10 }, (_, index) => ({
+            _id: `entity-${index}`,
+          })),
+          fileRefs: [],
+          folders: [],
+        },
+      ],
+    }
+  ),
 ]
 
 export default {

+ 0 - 26
services/web/test/frontend/features/file-tree/components/file-tree-create/context-props.js

@@ -1,26 +0,0 @@
-import sinon from 'sinon'
-
-export const contextProps = {
-  projectId: 'test-project',
-  hasWritePermissions: true,
-  userHasFeature: () => true,
-  refProviders: { mendeley: false, zotero: false },
-  reindexReferences: () => {
-    console.log('reindex references')
-  },
-  setRefProviderEnabled: provider => {
-    console.log(`ref provider ${provider} enabled`)
-  },
-  setStartedFreeTrial: () => {
-    console.log('started free trial')
-  },
-  rootFolder: [
-    {
-      docs: [{ _id: 'entity-1' }],
-      fileRefs: [],
-      folders: [],
-    },
-  ],
-  initialSelectedEntityId: 'entity-1',
-  onSelect: sinon.stub(),
-}

+ 21 - 30
services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-create-name-input.test.js

@@ -1,11 +1,10 @@
 import { expect } from 'chai'
-import { screen, render, waitFor, cleanup } from '@testing-library/react'
+import { screen, waitFor, cleanup } from '@testing-library/react'
 import sinon from 'sinon'
 
-import { contextProps } from './context-props'
+import renderWithContext from '../../helpers/render-with-context'
 
 import FileTreeCreateNameInput from '../../../../../../frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input'
-import FileTreeContext from '../../../../../../frontend/js/features/file-tree/components/file-tree-context'
 import FileTreeCreateNameProvider from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-create-name'
 
 describe('<FileTreeCreateNameInput/>', function () {
@@ -21,12 +20,10 @@ describe('<FileTreeCreateNameInput/>', function () {
   })
 
   it('renders an empty input', async function () {
-    render(
-      <FileTreeContext {...contextProps}>
-        <FileTreeCreateNameProvider>
-          <FileTreeCreateNameInput />
-        </FileTreeCreateNameProvider>
-      </FileTreeContext>
+    renderWithContext(
+      <FileTreeCreateNameProvider>
+        <FileTreeCreateNameInput />
+      </FileTreeCreateNameProvider>
     )
 
     await screen.getByLabelText('File Name')
@@ -34,15 +31,13 @@ describe('<FileTreeCreateNameInput/>', function () {
   })
 
   it('renders a custom label and placeholder', async function () {
-    render(
-      <FileTreeContext {...contextProps}>
-        <FileTreeCreateNameProvider>
-          <FileTreeCreateNameInput
-            label="File name in this project"
-            placeholder="Enter a file name…"
-          />
-        </FileTreeCreateNameProvider>
-      </FileTreeContext>
+    renderWithContext(
+      <FileTreeCreateNameProvider>
+        <FileTreeCreateNameInput
+          label="File name in this project"
+          placeholder="Enter a file name…"
+        />
+      </FileTreeCreateNameProvider>
     )
 
     await screen.getByLabelText('File name in this project')
@@ -50,12 +45,10 @@ describe('<FileTreeCreateNameInput/>', function () {
   })
 
   it('uses an initial name', async function () {
-    render(
-      <FileTreeContext {...contextProps}>
-        <FileTreeCreateNameProvider initialName="test.tex">
-          <FileTreeCreateNameInput />
-        </FileTreeCreateNameProvider>
-      </FileTreeContext>
+    renderWithContext(
+      <FileTreeCreateNameProvider initialName="test.tex">
+        <FileTreeCreateNameInput />
+      </FileTreeCreateNameProvider>
     )
 
     const input = await screen.getByLabelText('File Name')
@@ -63,12 +56,10 @@ describe('<FileTreeCreateNameInput/>', function () {
   })
 
   it('focuses the name', async function () {
-    render(
-      <FileTreeContext {...contextProps}>
-        <FileTreeCreateNameProvider initialName="test.tex">
-          <FileTreeCreateNameInput focusName />
-        </FileTreeCreateNameProvider>
-      </FileTreeContext>
+    renderWithContext(
+      <FileTreeCreateNameProvider initialName="test.tex">
+        <FileTreeCreateNameInput focusName />
+      </FileTreeCreateNameProvider>
     )
 
     const input = await screen.getByLabelText('File Name')

+ 40 - 80
services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-modal-create-file.test.js

@@ -1,19 +1,12 @@
 import { expect } from 'chai'
 import * as sinon from 'sinon'
 import { useEffect } from 'react'
-import {
-  screen,
-  render,
-  fireEvent,
-  cleanup,
-  waitFor,
-} from '@testing-library/react'
+import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react'
 import fetchMock from 'fetch-mock'
 import PropTypes from 'prop-types'
 
-import { contextProps } from './context-props'
+import renderWithContext from '../../helpers/render-with-context'
 import FileTreeModalCreateFile from '../../../../../../frontend/js/features/file-tree/components/modals/file-tree-modal-create-file'
-import FileTreeContext from '../../../../../../frontend/js/features/file-tree/components/file-tree-context'
 import { useFileTreeActionable } from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-actionable'
 import { useFileTreeMutable } from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-mutable'
 
@@ -29,11 +22,7 @@ describe('<FileTreeModalCreateFile/>', function () {
   })
 
   it('handles invalid file names', async function () {
-    render(
-      <FileTreeContext {...contextProps}>
-        <OpenWithMode mode="doc" />
-      </FileTreeContext>
-    )
+    renderWithContext(<OpenWithMode mode="doc" />)
 
     const submitButton = screen.getByRole('button', { name: 'Create' })
 
@@ -65,6 +54,8 @@ describe('<FileTreeModalCreateFile/>', function () {
   it('displays an error when the file limit is reached', async function () {
     const rootFolder = [
       {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: Array.from({ length: 10 }, (_, index) => ({
           _id: `entity-${index}`,
         })),
@@ -73,15 +64,9 @@ describe('<FileTreeModalCreateFile/>', function () {
       },
     ]
 
-    render(
-      <FileTreeContext
-        {...contextProps}
-        rootFolder={rootFolder}
-        initialSelectedEntityId="entity-1"
-      >
-        <OpenWithMode mode="doc" />
-      </FileTreeContext>
-    )
+    renderWithContext(<OpenWithMode mode="doc" />, {
+      contextProps: { projectRootFolder: rootFolder },
+    })
 
     screen.getByRole(
       (role, element) =>
@@ -93,6 +78,8 @@ describe('<FileTreeModalCreateFile/>', function () {
   it('displays a warning when the file limit is nearly reached', async function () {
     const rootFolder = [
       {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: Array.from({ length: 9 }, (_, index) => ({
           _id: `entity-${index}`,
         })),
@@ -101,15 +88,9 @@ describe('<FileTreeModalCreateFile/>', function () {
       },
     ]
 
-    render(
-      <FileTreeContext
-        {...contextProps}
-        rootFolder={rootFolder}
-        initialSelectedEntityId="entity-1"
-      >
-        <OpenWithMode mode="doc" />
-      </FileTreeContext>
-    )
+    renderWithContext(<OpenWithMode mode="doc" />, {
+      contextProps: { projectRootFolder: rootFolder },
+    })
 
     screen.getByText(/This project is approaching the file limit \(\d+\/\d+\)/)
   })
@@ -117,6 +98,8 @@ describe('<FileTreeModalCreateFile/>', function () {
   it('counts files in nested folders', async function () {
     const rootFolder = [
       {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [{ _id: 'entity-1' }],
         fileRefs: [],
         folders: [
@@ -143,15 +126,9 @@ describe('<FileTreeModalCreateFile/>', function () {
       },
     ]
 
-    render(
-      <FileTreeContext
-        {...contextProps}
-        rootFolder={rootFolder}
-        initialSelectedEntityId="entity-1"
-      >
-        <OpenWithMode mode="doc" />
-      </FileTreeContext>
-    )
+    renderWithContext(<OpenWithMode mode="doc" />, {
+      contextProps: { projectRootFolder: rootFolder },
+    })
 
     screen.getByText(/This project is approaching the file limit \(\d+\/\d+\)/)
   })
@@ -159,11 +136,7 @@ describe('<FileTreeModalCreateFile/>', function () {
   it('creates a new file when the form is submitted', async function () {
     fetchMock.post('express:/project/:projectId/doc', () => 204)
 
-    render(
-      <FileTreeContext {...contextProps}>
-        <OpenWithMode mode="doc" />
-      </FileTreeContext>
-    )
+    renderWithContext(<OpenWithMode mode="doc" />)
 
     const input = screen.getByLabelText('File Name')
     await fireEvent.change(input, { target: { value: 'test.tex' } })
@@ -174,7 +147,10 @@ describe('<FileTreeModalCreateFile/>', function () {
 
     expect(
       fetchMock.called('express:/project/:projectId/doc', {
-        body: { name: 'test.tex' },
+        body: {
+          parent_folder_id: 'root-folder-id',
+          name: 'test.tex',
+        },
       })
     ).to.be.true
   })
@@ -222,11 +198,7 @@ describe('<FileTreeModalCreateFile/>', function () {
       })
       .post('express:/project/:projectId/linked_file', () => 204)
 
-    render(
-      <FileTreeContext {...contextProps}>
-        <OpenWithMode mode="project" />
-      </FileTreeContext>
-    )
+    renderWithContext(<OpenWithMode mode="project" />)
 
     // initial state, no project selected
     const projectInput = screen.getByLabelText('Select a Project')
@@ -286,6 +258,7 @@ describe('<FileTreeModalCreateFile/>', function () {
         body: {
           name: 'ball.jpg',
           provider: 'project_output_file',
+          parent_folder_id: 'root-folder-id',
           data: {
             source_project_id: 'project-2',
             source_output_file_path: 'ball.jpg',
@@ -323,11 +296,7 @@ describe('<FileTreeModalCreateFile/>', function () {
         ],
       })
 
-      render(
-        <FileTreeContext {...contextProps}>
-          <OpenWithMode mode="project" />
-        </FileTreeContext>
-      )
+      renderWithContext(<OpenWithMode mode="project" />)
 
       // should not show the toggle
       expect(
@@ -341,11 +310,7 @@ describe('<FileTreeModalCreateFile/>', function () {
   it('import from a URL when the form is submitted', async function () {
     fetchMock.post('express:/project/:projectId/linked_file', () => 204)
 
-    render(
-      <FileTreeContext {...contextProps}>
-        <OpenWithMode mode="url" />
-      </FileTreeContext>
-    )
+    renderWithContext(<OpenWithMode mode="url" />)
 
     const urlInput = screen.getByLabelText('URL to fetch the file from')
     const nameInput = screen.getByLabelText('File Name In This Project')
@@ -373,6 +338,7 @@ describe('<FileTreeModalCreateFile/>', function () {
         body: {
           name: 'test.tex',
           provider: 'url',
+          parent_folder_id: 'root-folder-id',
           data: { url: 'https://example.com/example.tex' },
         },
       })
@@ -386,11 +352,7 @@ describe('<FileTreeModalCreateFile/>', function () {
       requests.push(request)
     }
 
-    render(
-      <FileTreeContext {...contextProps}>
-        <OpenWithMode mode="upload" />
-      </FileTreeContext>
-    )
+    renderWithContext(<OpenWithMode mode="upload" />)
 
     // the submit button should not be present
     expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
@@ -408,7 +370,9 @@ describe('<FileTreeModalCreateFile/>', function () {
     await waitFor(() => expect(requests).to.have.length(1))
 
     const [request] = requests
-    expect(request.url).to.equal('/project/test-project/upload')
+    expect(request.url).to.equal(
+      '/project/123abc/upload?folder_id=root-folder-id'
+    )
     expect(request.method).to.equal('POST')
 
     xhr.restore()
@@ -421,11 +385,7 @@ describe('<FileTreeModalCreateFile/>', function () {
       requests.push(request)
     }
 
-    render(
-      <FileTreeContext {...contextProps}>
-        <OpenWithMode mode="upload" />
-      </FileTreeContext>
-    )
+    renderWithContext(<OpenWithMode mode="upload" />)
 
     // the submit button should not be present
     expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
@@ -443,7 +403,9 @@ describe('<FileTreeModalCreateFile/>', function () {
     await waitFor(() => expect(requests).to.have.length(1))
 
     const [request] = requests
-    expect(request.url).to.equal('/project/test-project/upload')
+    expect(request.url).to.equal(
+      '/project/123abc/upload?folder_id=root-folder-id'
+    )
     expect(request.method).to.equal('POST')
 
     xhr.restore()
@@ -456,11 +418,7 @@ describe('<FileTreeModalCreateFile/>', function () {
       requests.push(request)
     }
 
-    render(
-      <FileTreeContext {...contextProps}>
-        <OpenWithMode mode="upload" />
-      </FileTreeContext>
-    )
+    renderWithContext(<OpenWithMode mode="upload" />)
 
     // the submit button should not be present
     expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
@@ -478,7 +436,9 @@ describe('<FileTreeModalCreateFile/>', function () {
     await waitFor(() => expect(requests).to.have.length(1))
 
     const [request] = requests
-    expect(request.url).to.equal('/project/test-project/upload')
+    expect(request.url).to.equal(
+      '/project/123abc/upload?folder_id=root-folder-id'
+    )
     expect(request.method).to.equal('POST')
 
     request.respond(

+ 9 - 3
services/web/test/frontend/features/file-tree/components/file-tree-doc.test.js

@@ -19,8 +19,10 @@ describe('<FileTreeDoc/>', function () {
       <FileTreeDoc name="foo.tex" id="123abc" isLinkedFile={false} />,
       {
         contextProps: {
-          rootFolder: [
+          projectRootFolder: [
             {
+              _id: 'root-folder-id',
+              name: 'rootFolder',
               docs: [{ _id: '123abc' }],
               fileRefs: [],
               folders: [],
@@ -48,8 +50,10 @@ describe('<FileTreeDoc/>', function () {
   it('selects', function () {
     renderWithContext(<FileTreeDoc name="foo.tex" id="123abc" expanded />, {
       contextProps: {
-        rootFolder: [
+        projectRootFolder: [
           {
+            _id: 'root-folder-id',
+            name: 'rootFolder',
             docs: [{ _id: '123abc' }],
             fileRefs: [],
             folders: [],
@@ -67,8 +71,10 @@ describe('<FileTreeDoc/>', function () {
   it('multi-selects', function () {
     renderWithContext(<FileTreeDoc name="foo.tex" id="123abc" expanded />, {
       contextProps: {
-        rootFolder: [
+        projectRootFolder: [
           {
+            _id: 'root-folder-id',
+            name: 'rootFolder',
             docs: [{ _id: '123abc' }],
             fileRefs: [],
             folders: [],

+ 7 - 3
services/web/test/frontend/features/file-tree/components/file-tree-folder-list.test.js

@@ -42,9 +42,11 @@ describe('<FileTreeFolderList/>', function () {
         <FileTreeFolderList folders={[]} docs={docs} files={[]} />,
         {
           contextProps: {
-            hasWritePermissions: false,
-            rootFolder: [
+            permissionsLevel: 'readOnly',
+            projectRootFolder: [
               {
+                _id: 'root-folder-id',
+                name: 'rootFolder',
                 docs: [{ _id: '1' }, { _id: '2' }],
                 fileRefs: [],
                 folders: [],
@@ -78,8 +80,10 @@ describe('<FileTreeFolderList/>', function () {
         <FileTreeFolderList folders={[]} docs={docs} files={[]} />,
         {
           contextProps: {
-            rootFolder: [
+            projectRootFolder: [
               {
+                _id: 'root-folder-id',
+                name: 'rootFolder',
                 docs: [{ _id: '1' }, { _id: '2' }, { _id: '3' }],
                 fileRefs: [],
                 folders: [],

+ 9 - 3
services/web/test/frontend/features/file-tree/components/file-tree-folder.test.js

@@ -35,8 +35,10 @@ describe('<FileTreeFolder/>', function () {
       />,
       {
         contextProps: {
-          rootFolder: [
+          projectRootFolder: [
             {
+              _id: 'root-folder-id',
+              name: 'rootFolder',
               docs: [{ _id: '123abc' }],
               fileRefs: [],
               folders: [],
@@ -64,8 +66,10 @@ describe('<FileTreeFolder/>', function () {
       />,
       {
         contextProps: {
-          rootFolder: [
+          projectRootFolder: [
             {
+              _id: 'root-folder-id',
+              name: 'rootFolder',
               docs: [{ _id: '123abc' }],
               fileRefs: [],
               folders: [],
@@ -93,8 +97,10 @@ describe('<FileTreeFolder/>', function () {
       />,
       {
         contextProps: {
-          rootFolder: [
+          projectRootFolder: [
             {
+              _id: 'root-folder-id',
+              name: 'rootFolder',
               docs: [{ _id: '123abc' }],
               fileRefs: [],
               folders: [],

+ 13 - 5
services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-inner.test.js

@@ -31,12 +31,19 @@ describe('<FileTreeitemInner />', function () {
 
   describe('context menu', function () {
     it('does not display without write permissions', function () {
-      renderWithContext(
-        <FileTreeitemInner id="123abc" name="bar.tex" isSelected />,
-        { contextProps: { hasWritePermissions: false } }
+      const { container } = renderWithContext(
+        <>
+          <FileTreeitemInner id="123abc" name="bar.tex" isSelected />
+          <FileTreeContextMenu />
+        </>,
+        {
+          contextProps: { permissionsLevel: 'readOnly' },
+        }
       )
 
-      expect(screen.queryByRole('menu', { visible: false })).to.not.exist
+      const entityElement = container.querySelector('div.entity')
+      fireEvent.contextMenu(entityElement)
+      expect(screen.queryByRole('menu')).to.not.exist
     })
 
     it('open / close', function () {
@@ -79,9 +86,10 @@ describe('<FileTreeitemInner />', function () {
         {
           contextProps: {
             rootDocId: '123abc',
-            rootFolder: [
+            projectRootFolder: [
               {
                 _id: 'root-folder-id',
+                name: 'rootFolder',
                 docs: [{ _id: '123abc', name: 'bar.tex' }],
                 folders: [],
                 fileRefs: [],

+ 1 - 1
services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-name.test.js

@@ -75,7 +75,7 @@ describe('<FileTreeItemName />', function () {
         setIsDraggable={setIsDraggable}
       />,
       {
-        contextProps: { hasWritePermissions: false },
+        contextProps: { permissionsLevel: 'readOnly' },
       }
     )
 

+ 54 - 36
services/web/test/frontend/features/file-tree/components/file-tree-root.test.js

@@ -30,6 +30,7 @@ describe('<FileTreeRoot/>', function () {
     const rootFolder = [
       {
         _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [{ _id: '456def', name: 'main.tex' }],
         folders: [],
         fileRefs: [],
@@ -37,19 +38,21 @@ describe('<FileTreeRoot/>', function () {
     ]
     const { container } = renderWithEditorContext(
       <FileTreeRoot
-        rootFolder={rootFolder}
-        projectId="123abc"
-        hasWritePermissions={false}
-        userHasFeature={() => true}
         refProviders={{}}
         reindexReferences={() => null}
         setRefProviderEnabled={() => null}
         setStartedFreeTrial={() => null}
-        rootDocId="456def"
         onSelect={onSelect}
         onInit={onInit}
         isConnected
-      />
+      />,
+      {
+        projectRootFolder: rootFolder,
+        projectId: '123abc',
+        rootDocId: '456def',
+        features: {},
+        permissionsLevel: 'owner',
+      }
     )
 
     screen.queryByRole('tree')
@@ -66,6 +69,7 @@ describe('<FileTreeRoot/>', function () {
     const rootFolder = [
       {
         _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [{ _id: '456def', name: 'main.tex' }],
         folders: [],
         fileRefs: [],
@@ -73,19 +77,21 @@ describe('<FileTreeRoot/>', function () {
     ]
     renderWithEditorContext(
       <FileTreeRoot
-        rootFolder={rootFolder}
-        projectId="123abc"
-        hasWritePermissions
-        userHasFeature={() => true}
         refProviders={{}}
         reindexReferences={() => null}
         setRefProviderEnabled={() => null}
         setStartedFreeTrial={() => null}
-        rootDocId="456def"
         onSelect={onSelect}
         onInit={onInit}
         isConnected
-      />
+      />,
+      {
+        projectRootFolder: rootFolder,
+        projectId: '123abc',
+        rootDocId: '456def',
+        features: {},
+        permissionsLevel: 'owner',
+      }
     )
 
     // as a proxy to check that the invalid entity ha not been select we start
@@ -104,6 +110,7 @@ describe('<FileTreeRoot/>', function () {
     const rootFolder = [
       {
         _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [{ _id: '456def', name: 'main.tex' }],
         folders: [],
         fileRefs: [],
@@ -112,19 +119,21 @@ describe('<FileTreeRoot/>', function () {
 
     const { container } = renderWithEditorContext(
       <FileTreeRoot
-        rootFolder={rootFolder}
-        projectId="123abc"
-        hasWritePermissions={false}
-        rootDocId="456def"
         onSelect={onSelect}
         onInit={onInit}
         isConnected={false}
-        userHasFeature={() => true}
         refProviders={{}}
         reindexReferences={() => null}
         setRefProviderEnabled={() => null}
         setStartedFreeTrial={() => null}
-      />
+      />,
+      {
+        projectRootFolder: rootFolder,
+        projectId: '123abc',
+        rootDocId: '456def',
+        features: {},
+        permissionsLevel: 'owner',
+      }
     )
 
     expect(container.querySelector('.disconnected-overlay')).to.exist
@@ -134,6 +143,7 @@ describe('<FileTreeRoot/>', function () {
     const rootFolder = [
       {
         _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [
           { _id: '456def', name: 'main.tex' },
           { _id: '789ghi', name: 'other.tex' },
@@ -144,11 +154,6 @@ describe('<FileTreeRoot/>', function () {
     ]
     renderWithEditorContext(
       <FileTreeRoot
-        rootFolder={rootFolder}
-        projectId="123abc"
-        rootDocId="456def"
-        hasWritePermissions={false}
-        userHasFeature={() => true}
         refProviders={{}}
         reindexReferences={() => null}
         setRefProviderEnabled={() => null}
@@ -156,7 +161,14 @@ describe('<FileTreeRoot/>', function () {
         onSelect={onSelect}
         onInit={onInit}
         isConnected
-      />
+      />,
+      {
+        projectRootFolder: rootFolder,
+        projectId: '123abc',
+        rootDocId: '456def',
+        features: {},
+        permissionsLevel: 'readOnly',
+      }
     )
     sinon.assert.calledOnce(onSelect)
     sinon.assert.calledWithMatch(onSelect, [
@@ -187,6 +199,7 @@ describe('<FileTreeRoot/>', function () {
     const rootFolder = [
       {
         _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [
           { _id: '456def', name: 'main.tex' },
           { _id: '789ghi', name: 'other.tex' },
@@ -197,11 +210,6 @@ describe('<FileTreeRoot/>', function () {
     ]
     renderWithEditorContext(
       <FileTreeRoot
-        rootFolder={rootFolder}
-        projectId="123abc"
-        rootDocId="456def"
-        hasWritePermissions={false}
-        userHasFeature={() => true}
         refProviders={{}}
         reindexReferences={() => null}
         setRefProviderEnabled={() => null}
@@ -209,7 +217,14 @@ describe('<FileTreeRoot/>', function () {
         onSelect={onSelect}
         onInit={onInit}
         isConnected
-      />
+      />,
+      {
+        projectRootFolder: rootFolder,
+        projectId: '123abc',
+        rootDocId: '456def',
+        features: {},
+        permissionsLevel: 'owner',
+      }
     )
 
     screen.getByRole('treeitem', { name: 'main.tex', selected: true })
@@ -231,6 +246,7 @@ describe('<FileTreeRoot/>', function () {
     const rootFolder = [
       {
         _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [
           { _id: '456def', name: 'main.tex' },
           { _id: '789ghi', name: 'other.tex' },
@@ -241,11 +257,6 @@ describe('<FileTreeRoot/>', function () {
     ]
     renderWithEditorContext(
       <FileTreeRoot
-        rootFolder={rootFolder}
-        projectId="123abc"
-        rootDocId="456def"
-        hasWritePermissions
-        userHasFeature={() => true}
         refProviders={{}}
         reindexReferences={() => null}
         setRefProviderEnabled={() => null}
@@ -253,7 +264,14 @@ describe('<FileTreeRoot/>', function () {
         onSelect={onSelect}
         onInit={onInit}
         isConnected
-      />
+      />,
+      {
+        projectRootFolder: rootFolder,
+        projectId: '123abc',
+        rootDocId: '456def',
+        features: {},
+        permissionsLevel: 'owner',
+      }
     )
 
     const main = screen.getByRole('treeitem', {

+ 3 - 2
services/web/test/frontend/features/file-tree/components/file-tree-toolbar.test.js

@@ -21,7 +21,7 @@ describe('<FileTreeToolbar/>', function () {
 
   it('read-only', function () {
     renderWithContext(<FileTreeToolbar />, {
-      contextProps: { hasWritePermissions: false },
+      contextProps: { permissionsLevel: 'readOnly' },
     })
 
     expect(screen.queryByRole('button')).to.not.exist
@@ -31,9 +31,10 @@ describe('<FileTreeToolbar/>', function () {
     renderWithContext(<FileTreeToolbar />, {
       contextProps: {
         rootDocId: '456def',
-        rootFolder: [
+        projectRootFolder: [
           {
             _id: 'root-folder-id',
+            name: 'rootFolder',
             docs: [{ _id: '456def', name: 'main.tex' }],
             folders: [],
             fileRefs: [],

+ 15 - 12
services/web/test/frontend/features/file-tree/flows/context-menu.test.js

@@ -22,6 +22,7 @@ describe('FileTree Context Menu Flow', function () {
     const rootFolder = [
       {
         _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [{ _id: '456def', name: 'main.tex' }],
         folders: [],
         fileRefs: [],
@@ -29,19 +30,19 @@ describe('FileTree Context Menu Flow', function () {
     ]
     renderWithEditorContext(
       <FileTreeRoot
-        rootFolder={rootFolder}
-        projectId="123abc"
-        hasWritePermissions
-        userHasFeature={() => true}
         refProviders={{}}
         reindexReferences={() => null}
         setRefProviderEnabled={() => null}
         setStartedFreeTrial={() => null}
-        rootDocId="456def"
         onSelect={onSelect}
         onInit={onInit}
         isConnected
-      />
+      />,
+      {
+        projectRootFolder: rootFolder,
+        projectId: '123abc',
+        rootDocId: '456def',
+      }
     )
     const treeitem = screen.getByRole('button', { name: 'main.tex' })
 
@@ -56,6 +57,7 @@ describe('FileTree Context Menu Flow', function () {
     const rootFolder = [
       {
         _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [{ _id: '456def', name: 'main.tex' }],
         folders: [],
         fileRefs: [],
@@ -63,19 +65,20 @@ describe('FileTree Context Menu Flow', function () {
     ]
     renderWithEditorContext(
       <FileTreeRoot
-        rootFolder={rootFolder}
-        projectId="123abc"
-        hasWritePermissions={false}
-        userHasFeature={() => true}
         refProviders={{}}
         reindexReferences={() => null}
         setRefProviderEnabled={() => null}
         setStartedFreeTrial={() => null}
-        rootDocId="456def"
         onSelect={onSelect}
         onInit={onInit}
         isConnected
-      />
+      />,
+      {
+        projectRootFolder: rootFolder,
+        projectId: '123abc',
+        rootDocId: '456def',
+        permissionsLevel: 'readOnly',
+      }
     )
     const treeitem = screen.getByRole('button', { name: 'main.tex' })
 

+ 27 - 23
services/web/test/frontend/features/file-tree/flows/create-folder.test.js

@@ -30,6 +30,7 @@ describe('FileTree Create Folder Flow', function () {
     const rootFolder = [
       {
         _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [{ _id: '456def', name: 'main.tex' }],
         folders: [],
         fileRefs: [],
@@ -37,10 +38,6 @@ describe('FileTree Create Folder Flow', function () {
     ]
     renderWithEditorContext(
       <FileTreeRoot
-        rootFolder={rootFolder}
-        projectId="123abc"
-        hasWritePermissions
-        userHasFeature={() => true}
         refProviders={{}}
         reindexReferences={() => null}
         setRefProviderEnabled={() => null}
@@ -49,7 +46,11 @@ describe('FileTree Create Folder Flow', function () {
         onInit={onInit}
         isConnected
       />,
-      { socket: new MockedSocket() }
+      {
+        socket: new MockedSocket(),
+        projectRootFolder: rootFolder,
+        projectId: '123abc',
+      }
     )
 
     const newFolderName = 'Foo Bar In Root'
@@ -83,6 +84,7 @@ describe('FileTree Create Folder Flow', function () {
     const rootFolder = [
       {
         _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [],
         folders: [
           {
@@ -98,20 +100,20 @@ describe('FileTree Create Folder Flow', function () {
     ]
     renderWithEditorContext(
       <FileTreeRoot
-        rootFolder={rootFolder}
-        projectId="123abc"
-        hasWritePermissions
-        userHasFeature={() => true}
         refProviders={{}}
         reindexReferences={() => null}
         setRefProviderEnabled={() => null}
         setStartedFreeTrial={() => null}
-        rootDocId="789ghi"
         onSelect={onSelect}
         onInit={onInit}
         isConnected
       />,
-      { socket: new MockedSocket() }
+      {
+        socket: new MockedSocket(),
+        projectRootFolder: rootFolder,
+        projectId: '123abc',
+        rootDocId: '789ghi',
+      }
     )
 
     const expandButton = screen.getByRole('button', { name: 'Expand' })
@@ -154,6 +156,7 @@ describe('FileTree Create Folder Flow', function () {
     const rootFolder = [
       {
         _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [],
         folders: [
           {
@@ -169,20 +172,20 @@ describe('FileTree Create Folder Flow', function () {
     ]
     renderWithEditorContext(
       <FileTreeRoot
-        rootFolder={rootFolder}
-        projectId="123abc"
-        hasWritePermissions
-        userHasFeature={() => true}
         refProviders={{}}
         reindexReferences={() => null}
         setRefProviderEnabled={() => null}
         setStartedFreeTrial={() => null}
-        rootDocId="456def"
         onSelect={onSelect}
         onInit={onInit}
         isConnected
       />,
-      { socket: new MockedSocket() }
+      {
+        socket: new MockedSocket(),
+        projectRootFolder: rootFolder,
+        projectId: '123abc',
+        rootDocId: '456def',
+      }
     )
 
     const newFolderName = 'Foo Bar In thefolder'
@@ -222,6 +225,7 @@ describe('FileTree Create Folder Flow', function () {
     const rootFolder = [
       {
         _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [{ _id: '456def', name: 'existingFile' }],
         folders: [],
         fileRefs: [],
@@ -229,20 +233,20 @@ describe('FileTree Create Folder Flow', function () {
     ]
     renderWithEditorContext(
       <FileTreeRoot
-        rootFolder={rootFolder}
-        projectId="123abc"
-        hasWritePermissions
-        userHasFeature={() => true}
         refProviders={{}}
         reindexReferences={() => null}
         setRefProviderEnabled={() => null}
         setStartedFreeTrial={() => null}
-        rootDocId="456def"
         onSelect={onSelect}
         onInit={onInit}
         isConnected
       />,
-      { socket: new MockedSocket() }
+      {
+        socket: new MockedSocket(),
+        projectRootFolder: rootFolder,
+        projectId: '123abc',
+        rootDocId: '456def',
+      }
     )
 
     let newFolderName = 'existingFile'

+ 19 - 15
services/web/test/frontend/features/file-tree/flows/delete-entity.test.js

@@ -26,6 +26,7 @@ describe('FileTree Delete Entity Flow', function () {
       const rootFolder = [
         {
           _id: 'root-folder-id',
+          name: 'rootFolder',
           docs: [{ _id: '456def', name: 'main.tex' }],
           folders: [],
           fileRefs: [],
@@ -33,10 +34,6 @@ describe('FileTree Delete Entity Flow', function () {
       ]
       renderWithEditorContext(
         <FileTreeRoot
-          rootFolder={rootFolder}
-          projectId="123abc"
-          hasWritePermissions
-          userHasFeature={() => true}
           refProviders={{}}
           reindexReferences={() => null}
           setRefProviderEnabled={() => null}
@@ -45,7 +42,11 @@ describe('FileTree Delete Entity Flow', function () {
           onInit={onInit}
           isConnected
         />,
-        { socket: new MockedSocket() }
+        {
+          socket: new MockedSocket(),
+          projectRootFolder: rootFolder,
+          projectId: '123abc',
+        }
       )
 
       const treeitem = screen.getByRole('treeitem', { name: 'main.tex' })
@@ -136,6 +137,8 @@ describe('FileTree Delete Entity Flow', function () {
     beforeEach(function () {
       const rootFolder = [
         {
+          _id: 'root-folder-id',
+          name: 'rootFolder',
           docs: [{ _id: '456def', name: 'main.tex' }],
           folders: [
             {
@@ -151,10 +154,6 @@ describe('FileTree Delete Entity Flow', function () {
       ]
       renderWithEditorContext(
         <FileTreeRoot
-          rootFolder={rootFolder}
-          projectId="123abc"
-          hasWritePermissions
-          userHasFeature={() => true}
           refProviders={{}}
           reindexReferences={() => null}
           setRefProviderEnabled={() => null}
@@ -163,7 +162,11 @@ describe('FileTree Delete Entity Flow', function () {
           onInit={onInit}
           isConnected
         />,
-        { socket: new MockedSocket() }
+        {
+          socket: new MockedSocket(),
+          projectRootFolder: rootFolder,
+          projectId: '123abc',
+        }
       )
 
       const expandButton = screen.queryByRole('button', { name: 'Expand' })
@@ -201,6 +204,7 @@ describe('FileTree Delete Entity Flow', function () {
       const rootFolder = [
         {
           _id: 'root-folder-id',
+          name: 'rootFolder',
           docs: [{ _id: '456def', name: 'main.tex' }],
           folders: [],
           fileRefs: [{ _id: '789ghi', name: 'my.bib' }],
@@ -209,10 +213,6 @@ describe('FileTree Delete Entity Flow', function () {
 
       renderWithEditorContext(
         <FileTreeRoot
-          rootFolder={rootFolder}
-          projectId="123abc"
-          hasWritePermissions
-          userHasFeature={() => true}
           refProviders={{}}
           reindexReferences={() => null}
           setRefProviderEnabled={() => null}
@@ -221,7 +221,11 @@ describe('FileTree Delete Entity Flow', function () {
           onInit={onInit}
           isConnected
         />,
-        { socket: new MockedSocket() }
+        {
+          socket: new MockedSocket(),
+          projectRootFolder: rootFolder,
+          projectId: '123abc',
+        }
       )
 
       // select two files

+ 6 - 5
services/web/test/frontend/features/file-tree/flows/rename-entity.test.js

@@ -30,6 +30,7 @@ describe('FileTree Rename Entity Flow', function () {
     const rootFolder = [
       {
         _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [{ _id: '456def', name: 'a.tex' }],
         folders: [
           {
@@ -48,10 +49,6 @@ describe('FileTree Rename Entity Flow', function () {
     ]
     renderWithEditorContext(
       <FileTreeRoot
-        rootFolder={rootFolder}
-        projectId="123abc"
-        hasWritePermissions
-        userHasFeature={() => true}
         refProviders={{}}
         reindexReferences={() => null}
         setRefProviderEnabled={() => null}
@@ -60,7 +57,11 @@ describe('FileTree Rename Entity Flow', function () {
         onInit={onInit}
         isConnected
       />,
-      { socket: new MockedSocket() }
+      {
+        socket: new MockedSocket(),
+        projectRootFolder: rootFolder,
+        projectId: '123abc',
+      }
     )
     onSelect.reset()
   })

+ 23 - 6
services/web/test/frontend/features/file-tree/helpers/render-with-context.js

@@ -1,19 +1,19 @@
-import { render } from '@testing-library/react'
 import FileTreeContext from '../../../../../frontend/js/features/file-tree/components/file-tree-context'
+import { renderWithEditorContext } from '../../../helpers/render-with-context'
 
 export default (children, options = {}) => {
   let { contextProps = {}, ...renderOptions } = options
   contextProps = {
     projectId: '123abc',
-    rootFolder: [
+    projectRootFolder: [
       {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
         docs: [],
         fileRefs: [],
         folders: [],
       },
     ],
-    hasWritePermissions: true,
-    userHasFeature: () => true,
     refProviders: {},
     reindexReferences: () => {
       console.log('reindex references')
@@ -27,8 +27,25 @@ export default (children, options = {}) => {
     onSelect: () => {},
     ...contextProps,
   }
-  return render(
-    <FileTreeContext {...contextProps}>{children}</FileTreeContext>,
+  const {
+    refProviders,
+    reindexReferences,
+    setRefProviderEnabled,
+    setStartedFreeTrial,
+    onSelect,
+    ...editorContextProps
+  } = contextProps
+  return renderWithEditorContext(
+    <FileTreeContext
+      refProviders={refProviders}
+      reindexReferences={reindexReferences}
+      setRefProviderEnabled={setRefProviderEnabled}
+      setStartedFreeTrial={setStartedFreeTrial}
+      onSelect={onSelect}
+    >
+      {children}
+    </FileTreeContext>,
+    editorContextProps,
     renderOptions
   )
 }

+ 27 - 6
services/web/test/frontend/helpers/render-with-context.js

@@ -23,6 +23,7 @@ export const PROJECT_NAME = 'project-name'
 export function EditorProviders({
   user = { id: '123abd', email: 'testuser@example.com' },
   projectId = PROJECT_ID,
+  rootDocId = '_root_doc_id',
   socket = {
     on: sinon.stub(),
     removeListener: sinon.stub(),
@@ -30,8 +31,21 @@ export function EditorProviders({
   isRestrictedTokenMember = false,
   clsiServerId = '1234',
   scope,
+  features = {
+    referencesSearch: true,
+  },
+  permissionsLevel = 'owner',
   children,
   rootFolder,
+  projectRootFolder = [
+    {
+      _id: 'root-folder-id',
+      name: 'rootFolder',
+      docs: [],
+      folders: [],
+      fileRefs: [],
+    },
+  ],
   ui = { view: null, pdfLayout: 'flat', chatOpen: true },
   fileTreeManager = {
     findEntityById: () => null,
@@ -59,10 +73,9 @@ export function EditorProviders({
         _id: '124abd',
         email: 'owner@example.com',
       },
-      features: {
-        referencesSearch: true,
-      },
-      rootDoc_id: '_root_doc_id',
+      features,
+      rootDoc_id: rootDocId,
+      rootFolder: projectRootFolder,
     },
     rootFolder: rootFolder || {
       children: [],
@@ -74,6 +87,7 @@ export function EditorProviders({
     },
     $applyAsync: sinon.stub(),
     toggleHistory: sinon.stub(),
+    permissionsLevel,
     ...scope,
   }
 
@@ -113,12 +127,19 @@ export function EditorProviders({
   )
 }
 
-export function renderWithEditorContext(component, contextProps) {
+export function renderWithEditorContext(
+  component,
+  contextProps,
+  renderOptions = {}
+) {
   const EditorProvidersWrapper = ({ children }) => (
     <EditorProviders {...contextProps}>{children}</EditorProviders>
   )
 
-  return render(component, { wrapper: EditorProvidersWrapper })
+  return render(component, {
+    wrapper: EditorProvidersWrapper,
+    ...renderOptions,
+  })
 }
 
 export function renderHookWithEditorContext(hook, contextProps) {