瀏覽代碼

Merge pull request #16511 from overleaf/ae-upgrade-uppy

Upgrade Uppy to v3

GitOrigin-RevId: ca3e366a20ac651a98aafe12bf319b1968ac6ec1
Alf Eaton 2 年之前
父節點
當前提交
612c7c28b0
共有 40 個文件被更改,包括 3215 次插入3100 次删除
  1. 552 277
      package-lock.json
  2. 6 2
      services/web/cypress/support/component.ts
  3. 3 3
      services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.jsx
  4. 0 1
      services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.jsx
  5. 12 2
      services/web/frontend/js/features/project-list/components/new-project-button/new-project-button-modal.tsx
  6. 7 8
      services/web/frontend/js/features/project-list/components/new-project-button/upload-project-modal.tsx
  7. 11 7
      services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx
  8. 4 0
      services/web/frontend/stylesheets/app/editor/figure-modal.less
  9. 6 6
      services/web/package.json
  10. 69 0
      services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-create-name-input.spec.tsx
  11. 0 83
      services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-create-name-input.test.jsx
  12. 508 0
      services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-modal-create-file.spec.tsx
  13. 0 505
      services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-modal-create-file.test.jsx
  14. 77 0
      services/web/test/frontend/features/file-tree/components/file-tree-doc.spec.tsx
  15. 0 91
      services/web/test/frontend/features/file-tree/components/file-tree-doc.test.jsx
  16. 190 0
      services/web/test/frontend/features/file-tree/components/file-tree-folder-list.spec.tsx
  17. 0 155
      services/web/test/frontend/features/file-tree/components/file-tree-folder-list.test.jsx
  18. 134 0
      services/web/test/frontend/features/file-tree/components/file-tree-folder.spec.tsx
  19. 0 133
      services/web/test/frontend/features/file-tree/components/file-tree-folder.test.jsx
  20. 98 0
      services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-inner.spec.tsx
  21. 0 104
      services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-inner.test.jsx
  22. 108 0
      services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-name.spec.tsx
  23. 0 117
      services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-name.test.jsx
  24. 394 0
      services/web/test/frontend/features/file-tree/components/file-tree-root.spec.tsx
  25. 0 409
      services/web/test/frontend/features/file-tree/components/file-tree-root.test.jsx
  26. 59 0
      services/web/test/frontend/features/file-tree/components/file-tree-toolbar.spec.tsx
  27. 0 52
      services/web/test/frontend/features/file-tree/components/file-tree-toolbar.test.jsx
  28. 117 0
      services/web/test/frontend/features/file-tree/flows/context-menu.spec.tsx
  29. 0 134
      services/web/test/frontend/features/file-tree/flows/context-menu.test.jsx
  30. 287 0
      services/web/test/frontend/features/file-tree/flows/create-folder.spec.tsx
  31. 0 296
      services/web/test/frontend/features/file-tree/flows/create-folder.test.jsx
  32. 282 0
      services/web/test/frontend/features/file-tree/flows/delete-entity.spec.tsx
  33. 0 302
      services/web/test/frontend/features/file-tree/flows/delete-entity.test.jsx
  34. 167 0
      services/web/test/frontend/features/file-tree/flows/rename-entity.spec.tsx
  35. 0 208
      services/web/test/frontend/features/file-tree/flows/rename-entity.test.jsx
  36. 18 0
      services/web/test/frontend/features/file-tree/helpers/file-tree-provider.tsx
  37. 0 52
      services/web/test/frontend/features/file-tree/helpers/render-with-context.jsx
  38. 105 0
      services/web/test/frontend/features/project-list/components/new-project-button/upload-project-modal.spec.tsx
  39. 0 152
      services/web/test/frontend/features/project-list/components/new-project-button/upload-project-modal.test.tsx
  40. 1 1
      services/web/test/frontend/features/source-editor/components/figure-modal.spec.tsx

File diff suppressed because it is too large
+ 552 - 277
package-lock.json


+ 6 - 2
services/web/cypress/support/component.ts

@@ -7,9 +7,13 @@ import './shared/exceptions'
 import './ct/commands'
 
 beforeEach(function () {
-  window.metaAttributesCache = new Map()
+  cy.window().then(win => {
+    win.metaAttributesCache = new Map()
+  })
 })
 
 afterEach(function () {
-  window.metaAttributesCache.clear()
+  cy.window().then(win => {
+    win.metaAttributesCache?.clear()
+  })
 })

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

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
 import PropTypes from 'prop-types'
 import Uppy from '@uppy/core'
 import XHRUpload from '@uppy/xhr-upload'
-import { Dashboard, useUppy } from '@uppy/react'
+import { Dashboard } from '@uppy/react'
 import { useFileTreeActionable } from '../../../contexts/file-tree-actionable'
 import { useProjectContext } from '../../../../../shared/context/project-context'
 import * as eventTracking from '../../../../../infrastructure/event-tracking'
@@ -45,13 +45,13 @@ export default function FileTreeUploadDoc() {
   }
 
   // initialise the Uppy object
-  const uppy = useUppy(() => {
+  const [uppy] = useState(() => {
     const endpoint = buildEndpoint(projectId, parentFolderId)
 
     return (
       new Uppy({
         // logger: Uppy.debugLogger,
-        allowMultipleUploads: false,
+        allowMultipleUploadBatches: false,
         restrictions: {
           maxNumberOfFiles,
           maxFileSize: maxFileSize || null,

+ 0 - 1
services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.jsx

@@ -2,7 +2,6 @@ import { useRef, useEffect, useState } from 'react'
 import PropTypes from 'prop-types'
 import { useTranslation } from 'react-i18next'
 import getDroppedFiles from '@uppy/utils/lib/getDroppedFiles'
-
 import { DndProvider, createDndContext, useDrag, useDrop } from 'react-dnd'
 import {
   HTML5Backend,

+ 12 - 2
services/web/frontend/js/features/project-list/components/new-project-button/new-project-button-modal.tsx

@@ -1,9 +1,10 @@
 import BlankProjectModal from './blank-project-modal'
 import ExampleProjectModal from './example-project-modal'
 import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
-import { JSXElementConstructor, lazy, Suspense } from 'react'
+import { JSXElementConstructor, lazy, Suspense, useCallback } from 'react'
 import { Nullable } from '../../../../../../types/utils'
 import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
+import { useLocation } from '@/shared/hooks/use-location'
 
 const UploadProjectModal = lazy(() => import('./upload-project-modal'))
 
@@ -26,6 +27,15 @@ function NewProjectButtonModal({ modal, onHide }: NewProjectButtonModalProps) {
     onHide: () => void
   }> = importProjectFromGithubModalWrapper?.import.default
 
+  const location = useLocation()
+
+  const openProject = useCallback(
+    (projectId: string) => {
+      location.assign(`/project/${projectId}`)
+    },
+    [location]
+  )
+
   switch (modal) {
     case 'blank_project':
       return <BlankProjectModal onHide={onHide} />
@@ -34,7 +44,7 @@ function NewProjectButtonModal({ modal, onHide }: NewProjectButtonModalProps) {
     case 'upload_project':
       return (
         <Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
-          <UploadProjectModal onHide={onHide} />
+          <UploadProjectModal onHide={onHide} openProject={openProject} />
         </Suspense>
       )
     case 'import_from_github':

+ 7 - 8
services/web/frontend/js/features/project-list/components/new-project-button/upload-project-modal.tsx

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
 import { Button, Modal } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import Uppy from '@uppy/core'
-import { Dashboard, useUppy } from '@uppy/react'
+import { Dashboard } from '@uppy/react'
 import XHRUpload from '@uppy/xhr-upload'
 import AccessibleModal from '../../../../shared/components/accessible-modal'
 import getMeta from '../../../../utils/meta'
@@ -10,7 +10,6 @@ import { ExposedSettings } from '../../../../../../types/exposed-settings'
 
 import '@uppy/core/dist/style.css'
 import '@uppy/dashboard/dist/style.css'
-import { useLocation } from '../../../../shared/hooks/use-location'
 
 type UploadResponse = {
   project_id: string
@@ -18,19 +17,19 @@ type UploadResponse = {
 
 type UploadProjectModalProps = {
   onHide: () => void
+  openProject: (projectId: string) => void
 }
 
-function UploadProjectModal({ onHide }: UploadProjectModalProps) {
+function UploadProjectModal({ onHide, openProject }: UploadProjectModalProps) {
   const { t } = useTranslation()
   const { maxUploadSize, projectUploadTimeout } = getMeta(
     'ol-ExposedSettings'
   ) as ExposedSettings
   const [ableToUpload, setAbleToUpload] = useState(false)
-  const location = useLocation()
 
-  const uppy: Uppy.Uppy<Uppy.StrictTypes> = useUppy(() => {
-    return Uppy({
-      allowMultipleUploads: false,
+  const [uppy] = useState(() => {
+    return new Uppy({
+      allowMultipleUploadBatches: false,
       restrictions: {
         maxNumberOfFiles: 1,
         maxFileSize: maxUploadSize,
@@ -62,7 +61,7 @@ function UploadProjectModal({ onHide }: UploadProjectModalProps) {
         const { project_id: projectId }: UploadResponse = response.body
 
         if (projectId) {
-          location.assign(`/project/${projectId}`)
+          openProject(projectId)
         }
       })
       .on('restriction-failed', () => {

+ 11 - 7
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx

@@ -2,10 +2,10 @@ import { FC, useCallback, useEffect, useState } from 'react'
 import { useFigureModalContext } from '../figure-modal-context'
 import { useCurrentProjectFolders } from '../../../hooks/use-current-project-folders'
 import { File } from '../../../utils/file'
-import { Dashboard, useUppy } from '@uppy/react'
+import { Dashboard } from '@uppy/react'
 import '@uppy/core/dist/style.css'
 import '@uppy/dashboard/dist/style.css'
-import { Uppy, UppyFile } from '@uppy/core'
+import { Uppy, type UppyFile } from '@uppy/core'
 import XHRUpload from '@uppy/xhr-upload'
 import { refreshProjectMetadata } from '../../../../file-tree/util/api'
 import { useProjectContext } from '../../../../../shared/context/project-context'
@@ -41,10 +41,9 @@ export const FigureModalUploadFileSource: FC = () => {
   const [name, setName] = useState<string>('')
   const [uploading, setUploading] = useState<boolean>(false)
   const [uploadError, setUploadError] = useState<any>(null)
-
-  const uppy = useUppy(() =>
+  const [uppy] = useState(() =>
     new Uppy({
-      allowMultipleUploads: false,
+      allowMultipleUploadBatches: false,
       restrictions: {
         maxNumberOfFiles: 1,
         maxFileSize: maxFileSize || null,
@@ -54,6 +53,7 @@ export const FigureModalUploadFileSource: FC = () => {
     })
       // use the basic XHR uploader
       .use(XHRUpload, {
+        endpoint: `/project/${projectId}/upload?folder_id=${rootFile.id}`,
         headers: {
           'X-CSRF-TOKEN': window.csrfToken,
         },
@@ -91,7 +91,7 @@ export const FigureModalUploadFileSource: FC = () => {
 
   useEffect(() => {
     // broadcast doc metadata after each successful upload
-    const onUploadSuccess = (_file: UppyFile, response: any) => {
+    const onUploadSuccess = (_file: UppyFile | undefined, response: any) => {
       setUploading(false)
       if (response.body.entity_type === 'doc') {
         window.setTimeout(() => {
@@ -139,7 +139,11 @@ export const FigureModalUploadFileSource: FC = () => {
     }
 
     // handle upload errors
-    const onError = (_file: UppyFile, error: any, response: any) => {
+    const onError = (
+      _file: UppyFile | undefined,
+      error: any,
+      response: any
+    ) => {
       setUploading(false)
       setUploadError(error)
       switch (response?.status) {

+ 4 - 0
services/web/frontend/stylesheets/app/editor/figure-modal.less

@@ -145,3 +145,7 @@
 .figure-modal .select-wrapper:not(:first-child) {
   margin-top: 16px;
 }
+
+.figure-modal-upload .uppy-Dashboard-AddFiles-list {
+  display: none;
+}

+ 6 - 6
services/web/package.json

@@ -13,7 +13,7 @@
     "test:unit:all": "npm run test:unit:run_dir -- test/unit/src modules/*/test/unit/src",
     "test:unit:all:silent": "npm run test:unit:all -- --reporter dot",
     "test:unit:app": "npm run test:unit:run_dir -- test/unit/src",
-    "test:frontend": "NODE_ENV=test TZ=GMT mocha --recursive --timeout 5000 --exit --extension js,jsx,mjs,ts,tsx --grep=$MOCHA_GREP --require test/frontend/bootstrap.js --ignore '**/*.spec.{js,jsx,ts,tsx}' test/frontend modules/*/test/frontend",
+    "test:frontend": "NODE_ENV=test TZ=GMT mocha --recursive --timeout 5000 --exit --extension js,jsx,mjs,ts,tsx --grep=$MOCHA_GREP --require test/frontend/bootstrap.js --ignore '**/*.spec.{js,jsx,ts,tsx}' --ignore '**/helpers/**/*.{js,jsx,ts,tsx}' 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",
     "start": "node app.js",
     "nodemon": "node --watch app.js --watch-locales",
@@ -243,11 +243,11 @@
     "@types/uuid": "^8.3.4",
     "@typescript-eslint/eslint-plugin": "^6.7.4",
     "@typescript-eslint/parser": "^6.7.4",
-    "@uppy/core": "^1.15.0",
-    "@uppy/dashboard": "^1.11.0",
-    "@uppy/react": "^1.11.0",
-    "@uppy/utils": "^4.0.7",
-    "@uppy/xhr-upload": "^1.6.8",
+    "@uppy/core": "^3.8.0",
+    "@uppy/dashboard": "^3.7.1",
+    "@uppy/react": "^3.2.1",
+    "@uppy/utils": "^5.7.0",
+    "@uppy/xhr-upload": "^3.6.0",
     "abort-controller": "^3.0.0",
     "acorn": "^7.1.1",
     "acorn-walk": "^7.1.1",

+ 69 - 0
services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-create-name-input.spec.tsx

@@ -0,0 +1,69 @@
+import FileTreeCreateNameInput from '../../../../../../frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input'
+import FileTreeCreateNameProvider from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-create-name'
+
+describe('<FileTreeCreateNameInput/>', function () {
+  it('renders an empty input', function () {
+    cy.mount(
+      <FileTreeCreateNameProvider>
+        <FileTreeCreateNameInput />
+      </FileTreeCreateNameProvider>
+    )
+
+    cy.findByLabelText('File Name')
+    cy.findByPlaceholderText('File Name')
+  })
+
+  it('renders a custom label and placeholder', function () {
+    cy.mount(
+      <FileTreeCreateNameProvider>
+        <FileTreeCreateNameInput
+          label="File name in this project"
+          placeholder="Enter a file name…"
+        />
+      </FileTreeCreateNameProvider>
+    )
+
+    cy.findByLabelText('File name in this project')
+    cy.findByPlaceholderText('Enter a file name…')
+  })
+
+  it('uses an initial name', function () {
+    cy.mount(
+      <FileTreeCreateNameProvider initialName="test.tex">
+        <FileTreeCreateNameInput />
+      </FileTreeCreateNameProvider>
+    )
+
+    cy.findByLabelText('File Name').should('have.value', 'test.tex')
+  })
+
+  it('focuses the name', function () {
+    cy.spy(window, 'requestAnimationFrame').as('requestAnimationFrame')
+
+    cy.mount(
+      <FileTreeCreateNameProvider initialName="test.tex">
+        <FileTreeCreateNameInput focusName />
+      </FileTreeCreateNameProvider>
+    )
+
+    cy.findByLabelText('File Name').as('input')
+
+    cy.get('@input').should('have.value', 'test.tex')
+
+    cy.get('@requestAnimationFrame').should('have.been.calledOnce')
+
+    // https://github.com/jsdom/jsdom/issues/2995
+    // "window.getSelection doesn't work with selection of <input> element"
+    // const selection = window.getSelection().toString()
+    // expect(selection).to.equal('test')
+
+    // wait for the selection to update
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(100)
+
+    cy.get<HTMLInputElement>('@input').then(element => {
+      expect(element.get(0).selectionStart).to.equal(0)
+      expect(element.get(0).selectionEnd).to.equal(4)
+    })
+  })
+})

+ 0 - 83
services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-create-name-input.test.jsx

@@ -1,83 +0,0 @@
-import { expect } from 'chai'
-import { screen, waitFor, cleanup } from '@testing-library/react'
-import sinon from 'sinon'
-
-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 FileTreeCreateNameProvider from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-create-name'
-
-describe('<FileTreeCreateNameInput/>', function () {
-  const sandbox = sinon.createSandbox()
-
-  beforeEach(function () {
-    sandbox.spy(window, 'requestAnimationFrame')
-  })
-
-  afterEach(function () {
-    sandbox.restore()
-    cleanup()
-  })
-
-  it('renders an empty input', async function () {
-    renderWithContext(
-      <FileTreeCreateNameProvider>
-        <FileTreeCreateNameInput />
-      </FileTreeCreateNameProvider>
-    )
-
-    await screen.getByLabelText('File Name')
-    await screen.getByPlaceholderText('File Name')
-  })
-
-  it('renders a custom label and placeholder', async function () {
-    renderWithContext(
-      <FileTreeCreateNameProvider>
-        <FileTreeCreateNameInput
-          label="File name in this project"
-          placeholder="Enter a file name…"
-        />
-      </FileTreeCreateNameProvider>
-    )
-
-    await screen.getByLabelText('File name in this project')
-    await screen.getByPlaceholderText('Enter a file name…')
-  })
-
-  it('uses an initial name', async function () {
-    renderWithContext(
-      <FileTreeCreateNameProvider initialName="test.tex">
-        <FileTreeCreateNameInput />
-      </FileTreeCreateNameProvider>
-    )
-
-    const input = await screen.getByLabelText('File Name')
-    expect(input.value).to.equal('test.tex')
-  })
-
-  it('focuses the name', async function () {
-    renderWithContext(
-      <FileTreeCreateNameProvider initialName="test.tex">
-        <FileTreeCreateNameInput focusName />
-      </FileTreeCreateNameProvider>
-    )
-
-    const input = await screen.getByLabelText('File Name')
-    expect(input.value).to.equal('test.tex')
-
-    await waitFor(
-      () => expect(window.requestAnimationFrame).to.have.been.calledOnce
-    )
-
-    // https://github.com/jsdom/jsdom/issues/2995
-    // "window.getSelection doesn't work with selection of <input> element"
-    // const selection = window.getSelection().toString()
-    // expect(selection).to.equal('test')
-
-    // wait for the selection to update
-    await new Promise(resolve => window.setTimeout(resolve, 100))
-
-    expect(input.selectionStart).to.equal(0)
-    expect(input.selectionEnd).to.equal(4)
-  })
-})

+ 508 - 0
services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-modal-create-file.spec.tsx

@@ -0,0 +1,508 @@
+import { useEffect } from 'react'
+import FileTreeModalCreateFile from '../../../../../../frontend/js/features/file-tree/components/modals/file-tree-modal-create-file'
+import { useFileTreeActionable } from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-actionable'
+import { useFileTreeData } from '../../../../../../frontend/js/shared/context/file-tree-data-context'
+import { EditorProviders } from '../../../../helpers/editor-providers'
+import { FileTreeProvider } from '../../helpers/file-tree-provider'
+
+describe('<FileTreeModalCreateFile/>', function () {
+  it('handles invalid file names', function () {
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <OpenWithMode mode="doc" />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByLabelText('File Name').as('input')
+    cy.findByRole('button', { name: 'Create' }).as('submit')
+
+    cy.get('@input').should('have.value', 'name.tex')
+    cy.get('@submit').should('not.be.disabled')
+    cy.findByRole('alert').should('not.exist')
+
+    cy.get('@input').clear()
+    cy.get('@submit').should('be.disabled')
+    cy.findByRole('alert').should('contain.text', 'File name is empty')
+
+    cy.get('@input').type('test.tex')
+    cy.get('@submit').should('not.be.disabled')
+    cy.findByRole('alert').should('not.exist')
+
+    cy.get('@input').type('oops/i/did/it/again')
+    cy.get('@submit').should('be.disabled')
+    cy.findByRole('alert').should('contain.text', 'contains invalid characters')
+  })
+
+  it('displays an error when the file limit is reached', function () {
+    cy.window().then(win => {
+      win.ExposedSettings.maxEntitiesPerProject = 10
+    })
+
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: Array.from({ length: 10 }, (_, index) => ({
+          _id: `entity-${index}`,
+        })),
+        fileRefs: [],
+        folders: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders rootFolder={rootFolder as any}>
+        <FileTreeProvider>
+          <OpenWithMode mode="doc" />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('alert')
+      .invoke('text')
+      .should('match', /This project has reached the \d+ file limit/)
+  })
+
+  it('displays a warning when the file limit is nearly reached', function () {
+    cy.window().then(win => {
+      win.ExposedSettings.maxEntitiesPerProject = 10
+    })
+
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: Array.from({ length: 9 }, (_, index) => ({
+          _id: `entity-${index}`,
+        })),
+        fileRefs: [],
+        folders: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders rootFolder={rootFolder as any}>
+        <FileTreeProvider>
+          <OpenWithMode mode="doc" />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByText(/This project is approaching the file limit \(\d+\/\d+\)/)
+  })
+
+  it('counts files in nested folders', function () {
+    cy.window().then(win => {
+      win.ExposedSettings.maxEntitiesPerProject = 10
+    })
+
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: 'doc-1' }],
+        fileRefs: [],
+        folders: [
+          {
+            docs: [{ _id: 'doc-2' }],
+            fileRefs: [],
+            folders: [
+              {
+                docs: [
+                  { _id: 'doc-3' },
+                  { _id: 'doc-4' },
+                  { _id: 'doc-5' },
+                  { _id: 'doc-6' },
+                  { _id: 'doc-7' },
+                ],
+                fileRefs: [],
+                folders: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders rootFolder={rootFolder as any}>
+        <FileTreeProvider>
+          <OpenWithMode mode="doc" />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByText(/This project is approaching the file limit \(\d+\/\d+\)/)
+  })
+
+  it('counts folders toward the limit', function () {
+    cy.window().then(win => {
+      win.ExposedSettings.maxEntitiesPerProject = 10
+    })
+
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: 'doc-1' }],
+        fileRefs: [],
+        folders: [
+          { docs: [], fileRefs: [], folders: [] },
+          { docs: [], fileRefs: [], folders: [] },
+          { docs: [], fileRefs: [], folders: [] },
+          { docs: [], fileRefs: [], folders: [] },
+          { docs: [], fileRefs: [], folders: [] },
+          { docs: [], fileRefs: [], folders: [] },
+          { docs: [], fileRefs: [], folders: [] },
+          { docs: [], fileRefs: [], folders: [] },
+        ],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders rootFolder={rootFolder as any}>
+        <FileTreeProvider>
+          <OpenWithMode mode="doc" />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByText(/This project is approaching the file limit \(\d+\/\d+\)/)
+  })
+
+  it('creates a new file when the form is submitted', function () {
+    cy.intercept('post', '/project/*/doc', {
+      statusCode: 204,
+    }).as('createDoc')
+
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <OpenWithMode mode="doc" />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByLabelText('File Name').type('test')
+    cy.findByRole('button', { name: 'Create' }).click()
+
+    cy.wait('@createDoc')
+
+    cy.get('@createDoc').its('request.body').should('deep.equal', {
+      parent_folder_id: 'root-folder-id',
+      name: 'test.tex',
+    })
+  })
+
+  it('imports a new file from a project', function () {
+    cy.window().then(win => {
+      win.ExposedSettings.hasLinkedProjectFileFeature = true
+      win.ExposedSettings.hasLinkedProjectOutputFileFeature = true
+    })
+
+    cy.intercept('/user/projects', {
+      body: {
+        projects: [
+          {
+            _id: 'test-project',
+            name: 'This Project',
+          },
+          {
+            _id: 'project-1',
+            name: 'Project One',
+          },
+          {
+            _id: 'project-2',
+            name: 'Project Two',
+          },
+        ],
+      },
+    })
+
+    cy.intercept('/project/*/entities', {
+      body: {
+        entities: [
+          {
+            path: '/foo.tex',
+          },
+          {
+            path: '/bar.tex',
+          },
+        ],
+      },
+    })
+
+    cy.intercept('post', '/project/*/compile', {
+      body: {
+        status: 'success',
+        outputFiles: [
+          {
+            build: 'test',
+            path: 'baz.jpg',
+          },
+          {
+            build: 'test',
+            path: 'ball.jpg',
+          },
+        ],
+      },
+    })
+
+    cy.intercept('post', '/project/*/linked_file', {
+      statusCode: 204,
+    }).as('createLinkedFile')
+
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <OpenWithMode mode="project" />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    // initial state, no project selected
+    cy.findByLabelText('Select a Project').should('not.be.disabled')
+
+    // the submit button should be disabled
+    cy.findByRole('button', { name: 'Create' }).should('be.disabled')
+
+    // the source file selector should be disabled
+    cy.findByLabelText('Select a File').should('be.disabled')
+    cy.findByLabelText('Select an Output File').should('not.exist')
+    // TODO: check for options length, excluding current project
+
+    // select a project
+    cy.findByLabelText('Select a Project').select('project-2')
+
+    // wait for the source file selector to be enabled
+    cy.findByLabelText('Select a File').should('not.be.disabled')
+    cy.findByLabelText('Select an Output File').should('not.exist')
+    cy.findByRole('button', { name: 'Create' }).should('be.disabled')
+
+    // TODO: check for fileInput options length, excluding current project
+
+    // click on the button to toggle between source and output files
+    cy.findByRole('button', {
+      // NOTE: When changing the label, update the other tests with this label as well.
+      name: 'select from output files',
+    }).click()
+
+    // wait for the output file selector to be enabled
+    cy.findByLabelText('Select an Output File').should('not.be.disabled')
+    cy.findByLabelText('Select a File').should('not.exist')
+    cy.findByRole('button', { name: 'Create' }).should('be.disabled')
+
+    // TODO: check for entityInput options length, excluding current project
+    cy.findByLabelText('Select an Output File').select('ball.jpg')
+    cy.findByRole('button', { name: 'Create' }).should('not.be.disabled')
+    cy.findByRole('button', { name: 'Create' }).click()
+
+    cy.get('@createLinkedFile')
+      .its('request.body')
+      .should('deep.equal', {
+        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',
+          build_id: 'test',
+        },
+      })
+  })
+
+  describe('when the output files feature is not available', function () {
+    beforeEach(function () {
+      cy.window().then(win => {
+        win.ExposedSettings.hasLinkedProjectFileFeature = true
+        win.ExposedSettings.hasLinkedProjectOutputFileFeature = false
+      })
+    })
+
+    it('should not show the import from output file mode', function () {
+      cy.intercept('/user/projects', {
+        body: {
+          projects: [
+            {
+              _id: 'test-project',
+              name: 'This Project',
+            },
+            {
+              _id: 'project-1',
+              name: 'Project One',
+            },
+            {
+              _id: 'project-2',
+              name: 'Project Two',
+            },
+          ],
+        },
+      })
+
+      cy.mount(
+        <EditorProviders>
+          <FileTreeProvider>
+            <OpenWithMode mode="project" />
+          </FileTreeProvider>
+        </EditorProviders>
+      )
+
+      cy.findByLabelText('Select a File')
+
+      cy.findByRole('button', {
+        name: 'select from output files',
+      }).should('not.exist')
+    })
+  })
+
+  it('import from a URL when the form is submitted', function () {
+    cy.intercept('/project/*/linked_file', {
+      statusCode: 204,
+    }).as('createLinkedFile')
+
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <OpenWithMode mode="url" />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByLabelText('URL to fetch the file from').type(
+      'https://example.com/example.tex'
+    )
+    cy.findByLabelText('File Name In This Project').should(
+      'have.value',
+      'example.tex'
+    )
+
+    // check that the name can still be edited manually
+    cy.findByLabelText('File Name In This Project').clear()
+    cy.findByLabelText('File Name In This Project').type('test.tex')
+    cy.findByLabelText('File Name In This Project').should(
+      'have.value',
+      'test.tex'
+    )
+
+    cy.findByRole('button', { name: 'Create' }).click()
+
+    cy.get('@createLinkedFile')
+      .its('request.body')
+      .should('deep.equal', {
+        name: 'test.tex',
+        provider: 'url',
+        parent_folder_id: 'root-folder-id',
+        data: { url: 'https://example.com/example.tex' },
+      })
+  })
+
+  it('uploads a dropped file', function () {
+    cy.intercept('post', '/project/*/upload?folder_id=root-folder-id', {
+      statusCode: 204,
+    }).as('uploadFile')
+
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <OpenWithMode mode="upload" />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    // the submit button should not be present
+    cy.findByRole('button', { name: 'Create' }).should('not.exist')
+
+    cy.get('input[type=file]')
+      .eq(0)
+      .selectFile(
+        {
+          contents: Cypress.Buffer.from('test'),
+          fileName: 'test.tex',
+          mimeType: 'text/plain',
+          lastModified: Date.now(),
+        },
+        {
+          action: 'drag-drop',
+          force: true, // invisible element
+        }
+      )
+
+    cy.wait('@uploadFile')
+  })
+
+  it('uploads a pasted file', function () {
+    cy.intercept('post', '/project/*/upload?folder_id=root-folder-id', {
+      statusCode: 204,
+    }).as('uploadFile')
+
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <OpenWithMode mode="upload" />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    // the submit button should not be present
+    cy.findByRole('button', { name: 'Create' }).should('not.exist')
+
+    cy.wrap(null).then(() => {
+      const clipboardData = new DataTransfer()
+      clipboardData.items.add(
+        new File(['test'], 'test.tex', { type: 'text/plain' })
+      )
+      cy.findByLabelText('Uppy Dashboard').trigger('paste', { clipboardData })
+    })
+
+    cy.wait('@uploadFile')
+  })
+
+  it('displays upload errors', function () {
+    cy.intercept('post', '/project/*/upload?folder_id=root-folder-id', {
+      statusCode: 422,
+      body: { success: false, error: 'invalid_filename' },
+    }).as('uploadFile')
+
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <OpenWithMode mode="upload" />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    // the submit button should not be present
+    cy.findByRole('button', { name: 'Create' }).should('not.exist')
+
+    cy.wrap(null).then(() => {
+      const clipboardData = new DataTransfer()
+      clipboardData.items.add(
+        new File(['test'], 'tes!t.tex', { type: 'text/plain' })
+      )
+      cy.findByLabelText('Uppy Dashboard').trigger('paste', { clipboardData })
+    })
+
+    cy.wait('@uploadFile')
+
+    cy.findByText(
+      `Upload failed: check that the file name doesn’t contain special characters, trailing/leading whitespace or more than 150 characters`
+    )
+  })
+})
+
+function OpenWithMode({ mode }: { mode: string }) {
+  const { newFileCreateMode, startCreatingFile } = useFileTreeActionable()
+
+  const { fileCount } = useFileTreeData()
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  useEffect(() => startCreatingFile(mode), [])
+
+  if (!fileCount || !newFileCreateMode) {
+    return null
+  }
+
+  return <FileTreeModalCreateFile />
+}

+ 0 - 505
services/web/test/frontend/features/file-tree/components/file-tree-create/file-tree-modal-create-file.test.jsx

@@ -1,505 +0,0 @@
-import { expect } from 'chai'
-import * as sinon from 'sinon'
-import { useEffect } from 'react'
-import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react'
-import fetchMock from 'fetch-mock'
-import PropTypes from 'prop-types'
-
-import renderWithContext from '../../helpers/render-with-context'
-import FileTreeModalCreateFile from '../../../../../../frontend/js/features/file-tree/components/modals/file-tree-modal-create-file'
-import { useFileTreeActionable } from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-actionable'
-import { useFileTreeData } from '../../../../../../frontend/js/shared/context/file-tree-data-context'
-
-describe('<FileTreeModalCreateFile/>', function () {
-  beforeEach(function () {
-    window.csrfToken = 'token'
-  })
-
-  afterEach(function () {
-    delete window.csrfToken
-    fetchMock.restore()
-    cleanup()
-  })
-
-  it('handles invalid file names', async function () {
-    renderWithContext(<OpenWithMode mode="doc" />)
-
-    const submitButton = screen.getByRole('button', { name: 'Create' })
-
-    const input = screen.getByLabelText('File Name')
-    expect(input.value).to.equal('name.tex')
-    expect(submitButton.disabled).to.be.false
-    expect(screen.queryAllByRole('alert')).to.be.empty
-
-    fireEvent.change(input, { target: { value: '' } })
-    expect(submitButton.disabled).to.be.true
-    screen.getByRole(
-      (role, element) =>
-        role === 'alert' && element.textContent.match(/File name is empty/)
-    )
-
-    await fireEvent.change(input, { target: { value: 'test.tex' } })
-    expect(submitButton.disabled).to.be.false
-    expect(screen.queryAllByRole('alert')).to.be.empty
-
-    await fireEvent.change(input, { target: { value: 'oops/i/did/it/again' } })
-    expect(submitButton.disabled).to.be.true
-    screen.getByRole(
-      (role, element) =>
-        role === 'alert' &&
-        element.textContent.match(/contains invalid characters/)
-    )
-  })
-
-  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}`,
-        })),
-        fileRefs: [],
-        folders: [],
-      },
-    ]
-
-    renderWithContext(<OpenWithMode mode="doc" />, {
-      contextProps: { rootFolder },
-    })
-
-    screen.getByRole(
-      (role, element) =>
-        role === 'alert' &&
-        element.textContent.match(/This project has reached the \d+ file limit/)
-    )
-  })
-
-  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}`,
-        })),
-        fileRefs: [],
-        folders: [],
-      },
-    ]
-
-    renderWithContext(<OpenWithMode mode="doc" />, {
-      contextProps: { rootFolder },
-    })
-
-    screen.getByText(/This project is approaching the file limit \(\d+\/\d+\)/)
-  })
-
-  it('counts files in nested folders', async function () {
-    const rootFolder = [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [{ _id: 'doc-1' }],
-        fileRefs: [],
-        folders: [
-          {
-            docs: [{ _id: 'doc-2' }],
-            fileRefs: [],
-            folders: [
-              {
-                docs: [
-                  { _id: 'doc-3' },
-                  { _id: 'doc-4' },
-                  { _id: 'doc-5' },
-                  { _id: 'doc-6' },
-                  { _id: 'doc-7' },
-                ],
-                fileRefs: [],
-                folders: [],
-              },
-            ],
-          },
-        ],
-      },
-    ]
-
-    renderWithContext(<OpenWithMode mode="doc" />, {
-      contextProps: { rootFolder },
-    })
-
-    screen.getByText(/This project is approaching the file limit \(\d+\/\d+\)/)
-  })
-
-  it('counts folders toward the limit', async function () {
-    const rootFolder = [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [{ _id: 'doc-1' }],
-        fileRefs: [],
-        folders: [
-          { docs: [], fileRefs: [], folders: [] },
-          { docs: [], fileRefs: [], folders: [] },
-          { docs: [], fileRefs: [], folders: [] },
-          { docs: [], fileRefs: [], folders: [] },
-          { docs: [], fileRefs: [], folders: [] },
-          { docs: [], fileRefs: [], folders: [] },
-          { docs: [], fileRefs: [], folders: [] },
-          { docs: [], fileRefs: [], folders: [] },
-        ],
-      },
-    ]
-
-    renderWithContext(<OpenWithMode mode="doc" />, {
-      contextProps: { rootFolder },
-    })
-
-    screen.getByText(/This project is approaching the file limit \(\d+\/\d+\)/)
-  })
-
-  it('creates a new file when the form is submitted', async function () {
-    fetchMock.post('express:/project/:projectId/doc', () => 204)
-
-    renderWithContext(<OpenWithMode mode="doc" />)
-
-    const input = screen.getByLabelText('File Name')
-    await fireEvent.change(input, { target: { value: 'test.tex' } })
-
-    const submitButton = screen.getByRole('button', { name: 'Create' })
-
-    await fireEvent.click(submitButton)
-
-    expect(
-      fetchMock.called('express:/project/:projectId/doc', {
-        body: {
-          parent_folder_id: 'root-folder-id',
-          name: 'test.tex',
-        },
-      })
-    ).to.be.true
-  })
-
-  it('imports a new file from a project', async function () {
-    fetchMock
-      .get('path:/user/projects', {
-        projects: [
-          {
-            _id: 'test-project',
-            name: 'This Project',
-          },
-          {
-            _id: 'project-1',
-            name: 'Project One',
-          },
-          {
-            _id: 'project-2',
-            name: 'Project Two',
-          },
-        ],
-      })
-      .get('express:/project/:projectId/entities', {
-        entities: [
-          {
-            path: '/foo.tex',
-          },
-          {
-            path: '/bar.tex',
-          },
-        ],
-      })
-      .post('express:/project/:projectId/compile', {
-        status: 'success',
-        outputFiles: [
-          {
-            build: 'test',
-            path: 'baz.jpg',
-          },
-          {
-            build: 'test',
-            path: 'ball.jpg',
-          },
-        ],
-      })
-      .post('express:/project/:projectId/linked_file', () => 204)
-
-    renderWithContext(<OpenWithMode mode="project" />)
-
-    // initial state, no project selected
-    const projectInput = screen.getByLabelText('Select a Project')
-    expect(projectInput.disabled).to.be.true
-    await waitFor(() => {
-      expect(projectInput.disabled).to.be.false
-    })
-
-    // the submit button should be disabled
-    const submitButton = screen.getByRole('button', { name: 'Create' })
-    expect(submitButton.disabled).to.be.true
-
-    // the source file selector should be disabled
-    const fileInput = screen.getByLabelText('Select a File')
-    expect(fileInput.disabled).to.be.true
-    // TODO: check for options length, excluding current project
-
-    // select a project
-    await fireEvent.change(projectInput, { target: { value: 'project-2' } }) // TODO: getByRole('option')?
-
-    // wait for the source file selector to be enabled
-    await waitFor(() => {
-      expect(fileInput.disabled).to.be.false
-    })
-    expect(screen.queryByLabelText('Select a File')).not.to.be.null
-    expect(screen.queryByLabelText('Select an Output File')).to.be.null
-    expect(submitButton.disabled).to.be.true
-
-    // TODO: check for fileInput options length, excluding current project
-
-    // click on the button to toggle between source and output files
-    const sourceTypeButton = screen.getByRole('button', {
-      // NOTE: When changing the label, update the other tests with this label as well.
-      name: 'select from output files',
-    })
-    await fireEvent.click(sourceTypeButton)
-
-    // wait for the output file selector to be enabled
-    const entityInput = screen.getByLabelText('Select an Output File')
-    await waitFor(() => {
-      expect(entityInput.disabled).to.be.false
-    })
-    expect(screen.queryByLabelText('Select a File')).to.be.null
-    expect(screen.queryByLabelText('Select an Output File')).not.to.be.null
-    expect(submitButton.disabled).to.be.true
-
-    // TODO: check for entityInput options length, excluding current project
-    await fireEvent.change(entityInput, { target: { value: 'ball.jpg' } }) // TODO: getByRole('option')?
-
-    await waitFor(() => {
-      expect(submitButton.disabled).to.be.false
-    })
-    await fireEvent.click(submitButton)
-
-    expect(
-      fetchMock.called('express:/project/:projectId/linked_file', {
-        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',
-            build_id: 'test',
-          },
-        },
-      })
-    ).to.be.true
-  })
-
-  describe('when the output files feature is not available', function () {
-    const flagBefore = window.ExposedSettings.hasLinkedProjectOutputFileFeature
-    before(function () {
-      window.ExposedSettings.hasLinkedProjectOutputFileFeature = false
-    })
-    after(function () {
-      window.ExposedSettings.hasLinkedProjectOutputFileFeature = flagBefore
-    })
-
-    it('should not show the import from output file mode', async function () {
-      fetchMock.get('path:/user/projects', {
-        projects: [
-          {
-            _id: 'test-project',
-            name: 'This Project',
-          },
-          {
-            _id: 'project-1',
-            name: 'Project One',
-          },
-          {
-            _id: 'project-2',
-            name: 'Project Two',
-          },
-        ],
-      })
-
-      renderWithContext(<OpenWithMode mode="project" />)
-
-      // should not show the toggle
-      expect(
-        screen.queryByRole('button', {
-          name: 'select from output files',
-        })
-      ).to.be.null
-    })
-  })
-
-  it('import from a URL when the form is submitted', async function () {
-    fetchMock.post('express:/project/:projectId/linked_file', () => 204)
-
-    renderWithContext(<OpenWithMode mode="url" />)
-
-    const urlInput = screen.getByLabelText('URL to fetch the file from')
-    const nameInput = screen.getByLabelText('File Name In This Project')
-
-    await fireEvent.change(urlInput, {
-      target: { value: 'https://example.com/example.tex' },
-    })
-
-    // check that the name has updated automatically
-    expect(nameInput.value).to.equal('example.tex')
-
-    await fireEvent.change(nameInput, {
-      target: { value: 'test.tex' },
-    })
-
-    // check that the name can still be edited manually
-    expect(nameInput.value).to.equal('test.tex')
-
-    const submitButton = screen.getByRole('button', { name: 'Create' })
-
-    await fireEvent.click(submitButton)
-
-    expect(
-      fetchMock.called('express:/project/:projectId/linked_file', {
-        body: {
-          name: 'test.tex',
-          provider: 'url',
-          parent_folder_id: 'root-folder-id',
-          data: { url: 'https://example.com/example.tex' },
-        },
-      })
-    ).to.be.true
-  })
-
-  it('uploads a dropped file', async function () {
-    const xhr = sinon.useFakeXMLHttpRequest()
-    const requests = []
-    xhr.onCreate = request => {
-      requests.push(request)
-    }
-
-    renderWithContext(<OpenWithMode mode="upload" />)
-
-    // the submit button should not be present
-    expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
-
-    await waitFor(() => {
-      const dropzone = screen.getByLabelText('File Uploader')
-
-      expect(dropzone).not.to.be.null
-
-      fireEvent.drop(dropzone, {
-        dataTransfer: {
-          files: [new File(['test'], 'test.tex', { type: 'text/plain' })],
-        },
-      })
-    })
-
-    await waitFor(() => expect(requests).to.have.length(1))
-
-    const [request] = requests
-    expect(request.url).to.equal(
-      '/project/123abc/upload?folder_id=root-folder-id'
-    )
-    expect(request.method).to.equal('POST')
-
-    xhr.restore()
-  })
-
-  it('uploads a pasted file', async function () {
-    const xhr = sinon.useFakeXMLHttpRequest()
-    const requests = []
-    xhr.onCreate = request => {
-      requests.push(request)
-    }
-
-    renderWithContext(<OpenWithMode mode="upload" />)
-
-    // the submit button should not be present
-    expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
-
-    await waitFor(() => {
-      const dropzone = screen.getByLabelText('File Uploader')
-
-      expect(dropzone).not.to.be.null
-
-      fireEvent.paste(dropzone, {
-        clipboardData: {
-          files: [new File(['test'], 'test.tex', { type: 'text/plain' })],
-        },
-      })
-    })
-
-    await waitFor(() => expect(requests).to.have.length(1))
-
-    const [request] = requests
-    expect(request.url).to.equal(
-      '/project/123abc/upload?folder_id=root-folder-id'
-    )
-    expect(request.method).to.equal('POST')
-
-    xhr.restore()
-  })
-
-  it('displays upload errors', async function () {
-    const xhr = sinon.useFakeXMLHttpRequest()
-    const requests = []
-    xhr.onCreate = request => {
-      requests.push(request)
-    }
-
-    renderWithContext(<OpenWithMode mode="upload" />)
-
-    // the submit button should not be present
-    expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
-
-    await waitFor(() => {
-      const dropzone = screen.getByLabelText('File Uploader')
-
-      expect(dropzone).not.to.be.null
-
-      fireEvent.paste(dropzone, {
-        clipboardData: {
-          files: [new File(['test'], 'tes!t.tex', { type: 'text/plain' })],
-        },
-      })
-    })
-
-    await waitFor(() => expect(requests).to.have.length(1))
-
-    const [request] = requests
-    expect(request.url).to.equal(
-      '/project/123abc/upload?folder_id=root-folder-id'
-    )
-    expect(request.method).to.equal('POST')
-
-    request.respond(
-      422,
-      { 'Content-Type': 'application/json' },
-      '{ "success": false, "error": "invalid_filename" }'
-    )
-
-    await screen.findByText(
-      `Upload failed: check that the file name doesn’t contain special characters, trailing/leading whitespace or more than 150 characters`
-    )
-
-    xhr.restore()
-  })
-})
-
-function OpenWithMode({ mode }) {
-  const { newFileCreateMode, startCreatingFile } = useFileTreeActionable()
-
-  const { fileCount } = useFileTreeData()
-
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  useEffect(() => startCreatingFile(mode), [])
-
-  if (!fileCount || !newFileCreateMode) {
-    return null
-  }
-
-  return <FileTreeModalCreateFile />
-}
-OpenWithMode.propTypes = {
-  mode: PropTypes.string.isRequired,
-}

+ 77 - 0
services/web/test/frontend/features/file-tree/components/file-tree-doc.spec.tsx

@@ -0,0 +1,77 @@
+import FileTreeDoc from '../../../../../frontend/js/features/file-tree/components/file-tree-doc'
+import { EditorProviders } from '../../../helpers/editor-providers'
+import { FileTreeProvider } from '../helpers/file-tree-provider'
+
+describe('<FileTreeDoc/>', function () {
+  it('renders unselected', function () {
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <FileTreeDoc name="foo.tex" id="123abc" />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('treeitem', { selected: false })
+    cy.get('i.linked-file-highlight').should('not.exist')
+  })
+
+  it('renders selected', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '123abc' }],
+        fileRefs: [],
+        folders: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders rootFolder={rootFolder as any}>
+        <FileTreeProvider>
+          <FileTreeDoc name="foo.tex" id="123abc" />,
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('treeitem', { selected: false }).click()
+    cy.findByRole('treeitem', { selected: true })
+  })
+
+  it('renders as linked file', function () {
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <FileTreeDoc name="foo.tex" id="123abc" isLinkedFile />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('treeitem')
+    cy.get('i.linked-file-highlight')
+  })
+
+  it('multi-selects', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '123abc' }],
+        fileRefs: [],
+        folders: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders rootFolder={rootFolder as any}>
+        <FileTreeProvider>
+          <FileTreeDoc name="foo.tex" id="123abc" />,
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('treeitem').click({ ctrlKey: true, cmdKey: true })
+    cy.findByRole('treeitem', { selected: true })
+  })
+})

+ 0 - 91
services/web/test/frontend/features/file-tree/components/file-tree-doc.test.jsx

@@ -1,91 +0,0 @@
-import { expect } from 'chai'
-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} />,
-      {
-        contextProps: {
-          rootFolder: [
-            {
-              _id: 'root-folder-id',
-              name: 'rootFolder',
-              docs: [{ _id: '123abc' }],
-              fileRefs: [],
-              folders: [],
-            },
-          ],
-        },
-      }
-    )
-
-    const treeitem = screen.getByRole('treeitem', { selected: false })
-    fireEvent.click(treeitem)
-
-    screen.getByRole('treeitem', { selected: true })
-  })
-
-  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 />, {
-      contextProps: {
-        rootFolder: [
-          {
-            _id: 'root-folder-id',
-            name: 'rootFolder',
-            docs: [{ _id: '123abc' }],
-            fileRefs: [],
-            folders: [],
-          },
-        ],
-      },
-    })
-
-    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 />, {
-      contextProps: {
-        rootFolder: [
-          {
-            _id: 'root-folder-id',
-            name: 'rootFolder',
-            docs: [{ _id: '123abc' }],
-            fileRefs: [],
-            folders: [],
-          },
-        ],
-      },
-    })
-
-    const treeitem = screen.getByRole('treeitem')
-
-    fireEvent.click(treeitem, { ctrlKey: true })
-    screen.getByRole('treeitem', { selected: true })
-  })
-})

+ 190 - 0
services/web/test/frontend/features/file-tree/components/file-tree-folder-list.spec.tsx

@@ -0,0 +1,190 @@
+import FileTreeFolderList from '../../../../../frontend/js/features/file-tree/components/file-tree-folder-list'
+import { EditorProviders } from '../../../helpers/editor-providers'
+import { FileTreeProvider } from '../helpers/file-tree-provider'
+
+describe('<FileTreeFolderList/>', function () {
+  it('renders empty', function () {
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <FileTreeFolderList folders={[]} docs={[]} files={[]} />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('tree')
+    cy.findByRole('treeitem').should('not.exist')
+  })
+
+  it('renders docs, files and folders', function () {
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <FileTreeFolderList
+            folders={[
+              {
+                _id: '456def',
+                name: 'A Folder',
+                folders: [],
+                docs: [],
+                fileRefs: [],
+              },
+            ]}
+            docs={[{ _id: '789ghi', name: 'doc.tex', linkedFileData: {} }]}
+            files={[{ _id: '987jkl', name: 'file.bib', linkedFileData: {} }]}
+          />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('tree')
+    cy.findByRole('treeitem', { name: 'A Folder' })
+    cy.findByRole('treeitem', { name: 'doc.tex' })
+    cy.findByRole('treeitem', { name: 'file.bib' })
+  })
+
+  describe('selection and multi-selection', function () {
+    it('without write permissions', function () {
+      const rootFolder = [
+        {
+          _id: 'root-folder-id',
+          name: 'rootFolder',
+          docs: [{ _id: '1' }, { _id: '2' }],
+          fileRefs: [],
+          folders: [],
+        },
+      ]
+
+      cy.mount(
+        <EditorProviders
+          rootFolder={rootFolder as any}
+          permissionsLevel="readOnly"
+        >
+          <FileTreeProvider>
+            <FileTreeFolderList
+              folders={[]}
+              docs={[
+                { _id: '1', name: '1.tex' },
+                { _id: '2', name: '2.tex' },
+              ]}
+              files={[]}
+            />
+          </FileTreeProvider>
+        </EditorProviders>
+      )
+
+      // click on item 1: it gets selected
+      cy.findByRole('treeitem', { name: '1.tex' }).click()
+      cy.findByRole('treeitem', { name: '1.tex', selected: true })
+      cy.findByRole('treeitem', { name: '2.tex', selected: false })
+
+      // meta-click on item 2: no changes
+      cy.findByRole('treeitem', { name: '2.tex' }).click({
+        ctrlKey: true,
+        cmdKey: true,
+      })
+      cy.findByRole('treeitem', { name: '1.tex', selected: true })
+      cy.findByRole('treeitem', { name: '2.tex', selected: false })
+    })
+
+    it('with write permissions', function () {
+      const rootFolder = [
+        {
+          _id: 'root-folder-id',
+          name: 'rootFolder',
+          docs: [{ _id: '1' }, { _id: '2' }, { _id: '3' }],
+          fileRefs: [],
+          folders: [],
+        },
+      ]
+
+      cy.mount(
+        <EditorProviders rootFolder={rootFolder as any}>
+          <FileTreeProvider>
+            <FileTreeFolderList
+              folders={[]}
+              docs={[
+                { _id: '1', name: '1.tex' },
+                { _id: '2', name: '2.tex' },
+                { _id: '3', name: '3.tex' },
+              ]}
+              files={[]}
+            />
+          </FileTreeProvider>
+        </EditorProviders>
+      )
+
+      // click item 1: it gets selected
+      cy.findByRole('treeitem', { name: '1.tex' }).click()
+      cy.findByRole('treeitem', { name: '1.tex', selected: true })
+      cy.findByRole('treeitem', { name: '2.tex', selected: false })
+      cy.findByRole('treeitem', { name: '3.tex', selected: false })
+
+      // click on item 2: it gets selected and item 1 is not selected anymore
+      cy.findByRole('treeitem', { name: '2.tex' }).click()
+      cy.findByRole('treeitem', { name: '1.tex', selected: false })
+      cy.findByRole('treeitem', { name: '2.tex', selected: true })
+      cy.findByRole('treeitem', { name: '3.tex', selected: false })
+
+      // meta-click on item 3: it gets selected and item 2 as well
+      cy.findByRole('treeitem', { name: '3.tex' }).click({
+        ctrlKey: true,
+        cmdKey: true,
+      })
+      cy.findByRole('treeitem', { name: '1.tex', selected: false })
+      cy.findByRole('treeitem', { name: '2.tex', selected: true })
+      cy.findByRole('treeitem', { name: '3.tex', selected: true })
+
+      // meta-click on item 1: add to selection
+      cy.findByRole('treeitem', { name: '1.tex' }).click({
+        ctrlKey: true,
+        cmdKey: true,
+      })
+      cy.findByRole('treeitem', { name: '1.tex', selected: true })
+      cy.findByRole('treeitem', { name: '2.tex', selected: true })
+      cy.findByRole('treeitem', { name: '3.tex', selected: true })
+
+      // meta-click on item 1: remove from selection
+      cy.findByRole('treeitem', { name: '1.tex' }).click({
+        ctrlKey: true,
+        cmdKey: true,
+      })
+      cy.findByRole('treeitem', { name: '1.tex', selected: false })
+      cy.findByRole('treeitem', { name: '2.tex', selected: true })
+      cy.findByRole('treeitem', { name: '3.tex', selected: true })
+
+      // meta-click on item 3: remove from selection
+      cy.findByRole('treeitem', { name: '3.tex' }).click({
+        ctrlKey: true,
+        cmdKey: true,
+      })
+      cy.findByRole('treeitem', { name: '1.tex', selected: false })
+      cy.findByRole('treeitem', { name: '2.tex', selected: true })
+      cy.findByRole('treeitem', { name: '3.tex', selected: false })
+
+      // meta-click on item 2: cannot unselect
+      cy.findByRole('treeitem', { name: '2.tex' }).click({
+        ctrlKey: true,
+        cmdKey: true,
+      })
+      cy.findByRole('treeitem', { name: '1.tex', selected: false })
+      cy.findByRole('treeitem', { name: '2.tex', selected: true })
+      cy.findByRole('treeitem', { name: '3.tex', selected: false })
+
+      // meta-click on item 3: add back to selection
+      cy.findByRole('treeitem', { name: '3.tex' }).click({
+        ctrlKey: true,
+        cmdKey: true,
+      })
+      cy.findByRole('treeitem', { name: '1.tex', selected: false })
+      cy.findByRole('treeitem', { name: '2.tex', selected: true })
+      cy.findByRole('treeitem', { name: '3.tex', selected: true })
+
+      // click on item 3: unselect other items
+      cy.findByRole('treeitem', { name: '3.tex' }).click()
+      cy.findByRole('treeitem', { name: '1.tex', selected: false })
+      cy.findByRole('treeitem', { name: '2.tex', selected: false })
+      cy.findByRole('treeitem', { name: '3.tex', selected: true })
+    })
+  })
+})

+ 0 - 155
services/web/test/frontend/features/file-tree/components/file-tree-folder-list.test.jsx

@@ -1,155 +0,0 @@
-import { expect } from 'chai'
-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: {
-            permissionsLevel: 'readOnly',
-            rootFolder: [
-              {
-                _id: 'root-folder-id',
-                name: 'rootFolder',
-                docs: [{ _id: '1' }, { _id: '2' }],
-                fileRefs: [],
-                folders: [],
-              },
-            ],
-          },
-        }
-      )
-
-      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: {
-            rootFolder: [
-              {
-                _id: 'root-folder-id',
-                name: 'rootFolder',
-                docs: [{ _id: '1' }, { _id: '2' }, { _id: '3' }],
-                fileRefs: [],
-                folders: [],
-              },
-            ],
-          },
-        }
-      )
-
-      const treeitem1 = screen.getByRole('treeitem', { name: '1.tex' })
-      const treeitem2 = screen.getByRole('treeitem', { name: '2.tex' })
-      const treeitem3 = screen.getByRole('treeitem', { name: '3.tex' })
-
-      // click item 1: it gets selected
-      fireEvent.click(treeitem1)
-      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 })
-    })
-  })
-})

+ 134 - 0
services/web/test/frontend/features/file-tree/components/file-tree-folder.spec.tsx

@@ -0,0 +1,134 @@
+import FileTreeFolder from '../../../../../frontend/js/features/file-tree/components/file-tree-folder'
+import { EditorProviders } from '../../../helpers/editor-providers'
+import { FileTreeProvider } from '../helpers/file-tree-provider'
+import { getContainerEl } from 'cypress/react'
+import ReactDom from 'react-dom'
+
+describe('<FileTreeFolder/>', function () {
+  it('renders unselected', function () {
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <FileTreeFolder
+            name="foo"
+            id="123abc"
+            folders={[]}
+            docs={[]}
+            files={[]}
+          />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('treeitem', { selected: false })
+    cy.findByRole('tree').should('not.exist')
+  })
+
+  it('renders selected', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '123abc' }],
+        fileRefs: [],
+        folders: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders rootFolder={rootFolder as any}>
+        <FileTreeProvider>
+          <FileTreeFolder
+            name="foo"
+            id="123abc"
+            folders={[]}
+            docs={[]}
+            files={[]}
+          />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('treeitem', { selected: false }).click()
+    cy.findByRole('treeitem', { selected: true })
+    cy.findByRole('tree').should('not.exist')
+  })
+
+  it('expands', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '123abc' }],
+        fileRefs: [],
+        folders: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders rootFolder={rootFolder as any}>
+        <FileTreeProvider>
+          <FileTreeFolder
+            name="foo"
+            id="123abc"
+            folders={[]}
+            docs={[]}
+            files={[]}
+          />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('treeitem')
+    cy.findByRole('button', { name: 'Expand' }).click()
+    cy.findByRole('tree')
+  })
+
+  it('saves the expanded state for the next render', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '123abc' }],
+        fileRefs: [],
+        folders: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders rootFolder={rootFolder as any}>
+        <FileTreeProvider>
+          <FileTreeFolder
+            name="foo"
+            id="123abc"
+            folders={[]}
+            docs={[]}
+            files={[]}
+          />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('tree').should('not.exist')
+    cy.findByRole('button', { name: 'Expand' }).click()
+    cy.findByRole('tree')
+
+    cy.then(() => ReactDom.unmountComponentAtNode(getContainerEl()))
+
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <FileTreeFolder
+            name="foo"
+            id="123abc"
+            folders={[]}
+            docs={[]}
+            files={[]}
+          />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('tree')
+  })
+})

+ 0 - 133
services/web/test/frontend/features/file-tree/components/file-tree-folder.test.jsx

@@ -1,133 +0,0 @@
-import { expect } from 'chai'
-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 () {
-  beforeEach(function () {
-    global.localStorage.clear()
-  })
-
-  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={[]}
-      />,
-      {
-        contextProps: {
-          rootFolder: [
-            {
-              _id: 'root-folder-id',
-              name: 'rootFolder',
-              docs: [{ _id: '123abc' }],
-              fileRefs: [],
-              folders: [],
-            },
-          ],
-        },
-      }
-    )
-
-    const treeitem = screen.getByRole('treeitem', { selected: false })
-    fireEvent.click(treeitem)
-
-    screen.getByRole('treeitem', { selected: true })
-    expect(screen.queryByRole('tree')).to.not.exist
-  })
-
-  it('expands', function () {
-    renderWithContext(
-      <FileTreeFolder
-        name="foo"
-        id="123abc"
-        folders={[]}
-        docs={[]}
-        files={[]}
-      />,
-      {
-        contextProps: {
-          rootFolder: [
-            {
-              _id: 'root-folder-id',
-              name: 'rootFolder',
-              docs: [{ _id: '123abc' }],
-              fileRefs: [],
-              folders: [],
-            },
-          ],
-        },
-      }
-    )
-
-    screen.getByRole('treeitem')
-    const expandButton = screen.getByRole('button', { name: 'Expand' })
-
-    fireEvent.click(expandButton)
-    screen.getByRole('tree')
-  })
-
-  it('saves the expanded state for the next render', function () {
-    const { unmount } = renderWithContext(
-      <FileTreeFolder
-        name="foo"
-        id="123abc"
-        folders={[]}
-        docs={[]}
-        files={[]}
-      />,
-      {
-        contextProps: {
-          rootFolder: [
-            {
-              _id: 'root-folder-id',
-              name: 'rootFolder',
-              docs: [{ _id: '123abc' }],
-              fileRefs: [],
-              folders: [],
-            },
-          ],
-        },
-      }
-    )
-
-    expect(screen.queryByRole('tree')).to.not.exist
-
-    const expandButton = screen.getByRole('button', { name: 'Expand' })
-    fireEvent.click(expandButton)
-    screen.getByRole('tree')
-
-    unmount()
-
-    renderWithContext(
-      <FileTreeFolder
-        name="foo"
-        id="123abc"
-        folders={[]}
-        docs={[]}
-        files={[]}
-      />
-    )
-
-    screen.getByRole('tree')
-  })
-})

+ 98 - 0
services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-inner.spec.tsx

@@ -0,0 +1,98 @@
+import FileTreeitemInner from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner'
+import FileTreeContextMenu from '../../../../../../frontend/js/features/file-tree/components/file-tree-context-menu'
+import { EditorProviders } from '../../../../helpers/editor-providers'
+import { FileTreeProvider } from '../../helpers/file-tree-provider'
+
+describe('<FileTreeitemInner />', function () {
+  describe('menu', function () {
+    it('does not display if file is not selected', function () {
+      cy.mount(
+        <EditorProviders>
+          <FileTreeProvider>
+            <FileTreeitemInner id="123abc" name="bar.tex" isSelected={false} />,
+          </FileTreeProvider>
+        </EditorProviders>
+      )
+
+      cy.findByRole('menu', { hidden: true }).should('not.exist')
+    })
+  })
+
+  describe('context menu', function () {
+    it('does not display without write permissions', function () {
+      cy.mount(
+        <EditorProviders permissionsLevel="readOnly">
+          <FileTreeProvider>
+            <FileTreeitemInner id="123abc" name="bar.tex" isSelected />
+            <FileTreeContextMenu />
+          </FileTreeProvider>
+        </EditorProviders>
+      )
+
+      cy.get('div.entity').trigger('contextmenu')
+      cy.findByRole('menu', { hidden: true }).should('not.exist')
+    })
+
+    it('open / close', function () {
+      cy.mount(
+        <EditorProviders>
+          <FileTreeProvider>
+            <FileTreeitemInner id="123abc" name="bar.tex" isSelected />
+            <FileTreeContextMenu />
+          </FileTreeProvider>
+        </EditorProviders>
+      )
+
+      cy.findByRole('menu', { hidden: true }).should('not.exist')
+
+      // open the context menu
+      cy.get('div.entity').trigger('contextmenu')
+      cy.findByRole('menu')
+
+      // close the context menu
+      cy.get('div.entity').click()
+      cy.findByRole('menu').should('not.exist')
+    })
+  })
+
+  describe('name', function () {
+    it('renders name', function () {
+      cy.mount(
+        <EditorProviders>
+          <FileTreeProvider>
+            <FileTreeitemInner id="123abc" name="bar.tex" isSelected />
+          </FileTreeProvider>
+        </EditorProviders>
+      )
+
+      cy.findByRole('button', { name: 'bar.tex' })
+      cy.findByRole('textbox').should('not.exist')
+    })
+
+    it('starts rename on menu item click', function () {
+      const rootFolder = [
+        {
+          _id: 'root-folder-id',
+          name: 'rootFolder',
+          docs: [{ _id: '123abc', name: 'bar.tex' }],
+          folders: [],
+          fileRefs: [],
+        },
+      ]
+
+      cy.mount(
+        <EditorProviders rootDocId="123abc" rootFolder={rootFolder as any}>
+          <FileTreeProvider>
+            <FileTreeitemInner id="123abc" name="bar.tex" isSelected />
+            <FileTreeContextMenu />
+          </FileTreeProvider>
+        </EditorProviders>
+      )
+
+      cy.findByRole('button', { name: 'Menu' }).click()
+      cy.findByRole('menuitem', { name: 'Rename' }).click()
+      cy.findByRole('button', { name: 'bar.tex' }).should('not.exist')
+      cy.findByRole('textbox')
+    })
+  })
+})

+ 0 - 104
services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-inner.test.jsx

@@ -1,104 +0,0 @@
-import { expect } from 'chai'
-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'
-import FileTreeContextMenu from '../../../../../../frontend/js/features/file-tree/components/file-tree-context-menu'
-
-describe('<FileTreeitemInner />', function () {
-  const setContextMenuCoords = sinon.stub()
-
-  afterEach(function () {
-    setContextMenuCoords.reset()
-  })
-
-  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 () {
-      const { container } = renderWithContext(
-        <>
-          <FileTreeitemInner id="123abc" name="bar.tex" isSelected />
-          <FileTreeContextMenu />
-        </>,
-        {
-          contextProps: { permissionsLevel: 'readOnly' },
-        }
-      )
-
-      const entityElement = container.querySelector('div.entity')
-      fireEvent.contextMenu(entityElement)
-      expect(screen.queryByRole('menu')).to.not.exist
-    })
-
-    it('open / close', function () {
-      const { container } = renderWithContext(
-        <>
-          <FileTreeitemInner id="123abc" name="bar.tex" isSelected />
-          <FileTreeContextMenu />
-        </>
-      )
-
-      expect(screen.queryByRole('menu')).to.be.null
-
-      // open the context menu
-      const entityElement = container.querySelector('div.entity')
-      fireEvent.contextMenu(entityElement)
-      screen.getByRole('menu', { visible: true })
-
-      // close the context menu
-      fireEvent.click(entityElement)
-      expect(screen.queryByRole('menu')).to.be.null
-    })
-  })
-
-  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 />
-          <FileTreeContextMenu />
-        </>,
-        {
-          contextProps: {
-            rootDocId: '123abc',
-            rootFolder: [
-              {
-                _id: 'root-folder-id',
-                name: 'rootFolder',
-                docs: [{ _id: '123abc', name: 'bar.tex' }],
-                folders: [],
-                fileRefs: [],
-              },
-            ],
-          },
-        }
-      )
-      const toggleButton = screen.getByRole('button', { name: 'Menu' })
-      fireEvent.click(toggleButton)
-      const renameButton = screen.getByRole('menuitem', { name: 'Rename' })
-      fireEvent.click(renameButton)
-      expect(screen.queryByRole('button', { name: 'bar.tex' })).to.not.exist
-      screen.getByRole('textbox')
-    })
-  })
-})

+ 108 - 0
services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-name.spec.tsx

@@ -0,0 +1,108 @@
+import FileTreeItemName from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name'
+import { EditorProviders } from '../../../../helpers/editor-providers'
+import { FileTreeProvider } from '../../helpers/file-tree-provider'
+
+describe('<FileTreeItemName />', function () {
+  it('renders name as button', function () {
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <FileTreeItemName
+            name="foo.tex"
+            isSelected
+            setIsDraggable={cy.stub()}
+          />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('button', { name: 'foo.tex' })
+    cy.findByRole('textbox').should('not.exist')
+  })
+
+  it("doesn't start renaming on unselected component", function () {
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <FileTreeItemName
+            name="foo.tex"
+            isSelected={false}
+            setIsDraggable={cy.stub()}
+          />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('button').click()
+    cy.findByRole('button').click()
+    cy.findByRole('button').dblclick()
+    cy.findByRole('textbox').should('not.exist')
+  })
+
+  it('start renaming on double-click', function () {
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <FileTreeItemName
+            name="foo.tex"
+            isSelected
+            setIsDraggable={cy.stub().as('setIsDraggable')}
+          />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('button').click()
+    cy.findByRole('button').click()
+    cy.findByRole('button').dblclick()
+    cy.findByRole('textbox')
+    cy.findByRole('button').should('not.exist')
+    cy.get('@setIsDraggable').should('have.been.calledWith', false)
+  })
+
+  it('cannot start renaming in read-only', function () {
+    cy.mount(
+      <EditorProviders permissionsLevel="readOnly">
+        <FileTreeProvider>
+          <FileTreeItemName
+            name="foo.tex"
+            isSelected
+            setIsDraggable={cy.stub()}
+          />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findByRole('button').click()
+    cy.findByRole('button').click()
+    cy.findByRole('button').dblclick()
+
+    cy.findByRole('textbox').should('not.exist')
+  })
+
+  describe('stop renaming', function () {
+    it('on Escape', function () {
+      cy.mount(
+        <EditorProviders>
+          <FileTreeProvider>
+            <FileTreeItemName
+              name="foo.tex"
+              isSelected
+              setIsDraggable={cy.stub().as('setIsDraggable')}
+            />
+          </FileTreeProvider>
+        </EditorProviders>
+      )
+
+      cy.findByRole('button').click()
+      cy.findByRole('button').click()
+      cy.findByRole('button').dblclick()
+
+      cy.findByRole('textbox').clear()
+      cy.findByRole('textbox').type('bar.tex{esc}')
+
+      cy.findByRole('button', { name: 'foo.tex' })
+      cy.get('@setIsDraggable').should('have.been.calledWith', true)
+    })
+  })
+})

+ 0 - 117
services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-name.test.jsx

@@ -1,117 +0,0 @@
-import { expect } from 'chai'
-import sinon from 'sinon'
-import { screen, fireEvent, cleanup } 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 () {
-  const sandbox = sinon.createSandbox()
-  const setIsDraggable = sinon.stub()
-
-  beforeEach(function () {
-    sandbox.spy(window, 'requestAnimationFrame')
-  })
-
-  afterEach(function () {
-    sandbox.restore()
-    setIsDraggable.reset()
-    cleanup()
-  })
-
-  it('renders name as button', function () {
-    renderWithContext(
-      <FileTreeItemName
-        name="foo.tex"
-        isSelected
-        setIsDraggable={setIsDraggable}
-      />
-    )
-
-    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}
-        setIsDraggable={setIsDraggable}
-      />
-    )
-
-    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
-        setIsDraggable={setIsDraggable}
-      />
-    )
-
-    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(window.requestAnimationFrame).to.be.calledOnce
-    expect(setIsDraggable).to.be.calledWith(false)
-  })
-
-  it('cannot start renaming in read-only', function () {
-    renderWithContext(
-      <FileTreeItemName
-        name="foo.tex"
-        isSelected
-        setIsDraggable={setIsDraggable}
-      />,
-      {
-        contextProps: { permissionsLevel: 'readOnly' },
-      }
-    )
-
-    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
-          setIsDraggable={setIsDraggable}
-        />
-      )
-
-      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' })
-      expect(setIsDraggable).to.be.calledWith(true)
-    })
-  })
-})

+ 394 - 0
services/web/test/frontend/features/file-tree/components/file-tree-root.spec.tsx

@@ -0,0 +1,394 @@
+// @ts-ignore
+import MockedSocket from 'socket.io-mock'
+import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
+import { EditorProviders } from '../../../helpers/editor-providers'
+
+describe('<FileTreeRoot/>', function () {
+  beforeEach(function () {
+    cy.window().then(win => {
+      win.metaAttributesCache.set('ol-user', { id: 'user1' })
+    })
+  })
+
+  it('renders', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '456def', name: 'main.tex' }],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="456def"
+        features={{} as any}
+        permissionsLevel="owner"
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          reindexReferences={cy.stub()}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    cy.findByRole('tree')
+    cy.findByRole('treeitem')
+    cy.findByRole('treeitem', { name: 'main.tex', selected: true })
+    cy.get('.disconnected-overlay').should('not.exist')
+  })
+
+  it('renders with invalid selected doc in local storage', function () {
+    global.localStorage.setItem(
+      'doc.open_id.123abc',
+      JSON.stringify('not-a-valid-id')
+    )
+
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '456def', name: 'main.tex' }],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="456def"
+        features={{} as any}
+        permissionsLevel="owner"
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          reindexReferences={cy.stub()}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    // as a proxy to check that the invalid entity has not been select we start
+    // a delete and ensure the modal is displayed (the cancel button can be
+    // selected) This is needed to make sure the test fail.
+    cy.findByRole('treeitem', { name: 'main.tex' }).click({
+      ctrlKey: true,
+      cmdKey: true,
+    })
+    cy.findByRole('button', { name: 'Menu' }).click()
+    cy.findByRole('menuitem', { name: 'Delete' }).click()
+    cy.findByRole('button', { name: 'Cancel' })
+  })
+
+  it('renders disconnected overlay', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '456def', name: 'main.tex' }],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="456def"
+        features={{} as any}
+        permissionsLevel="owner"
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          reindexReferences={cy.stub()}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected={false}
+        />
+      </EditorProviders>
+    )
+
+    cy.get('.disconnected-overlay')
+  })
+
+  it('fire onSelect', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [
+          { _id: '456def', name: 'main.tex' },
+          { _id: '789ghi', name: 'other.tex' },
+        ],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="456def"
+        features={{} as any}
+        permissionsLevel="readOnly"
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          reindexReferences={cy.stub()}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub().as('onSelect')}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    cy.get('@onSelect').should('have.been.calledOnceWith', [
+      Cypress.sinon.match({
+        entity: Cypress.sinon.match({ _id: '456def', name: 'main.tex' }),
+      }),
+    ])
+    cy.findByRole('tree')
+    cy.findByRole('treeitem', { name: 'other.tex' }).click()
+    cy.get('@onSelect').should('have.been.calledWith', [
+      Cypress.sinon.match({
+        entity: Cypress.sinon.match({ _id: '789ghi', name: 'other.tex' }),
+      }),
+    ])
+  })
+
+  it('listen to editor.openDoc', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [
+          { _id: '456def', name: 'main.tex' },
+          { _id: '789ghi', name: 'other.tex' },
+        ],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="456def"
+        features={{} as any}
+        permissionsLevel="readOnly"
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          reindexReferences={cy.stub()}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    cy.findByRole('treeitem', { name: 'main.tex', selected: true })
+
+    // entities not found should be ignored
+    cy.document().trigger('editor.openDoc', { detail: 'not-an-id' })
+    cy.findByRole('treeitem', { name: 'main.tex', selected: true })
+
+    cy.document().trigger('editor.openDoc', { detail: '789ghi' })
+    cy.findByRole('treeitem', { name: 'main.tex', selected: false })
+    cy.findByRole('treeitem', { name: 'other.tex', selected: true })
+  })
+
+  it('only shows a menu button when a single item is selected', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [
+          { _id: '456def', name: 'main.tex' },
+          { _id: '789ghi', name: 'other.tex' },
+        ],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="456def"
+        features={{} as any}
+        permissionsLevel="owner"
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          reindexReferences={cy.stub()}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    cy.findByRole('treeitem', { name: 'main.tex', selected: true })
+    cy.findByRole('treeitem', { name: 'other.tex', selected: false })
+
+    // single item selected: menu button is visible
+    cy.findAllByRole('button', { name: 'Menu' }).should('have.length', 1)
+
+    // select the other item
+    cy.findByRole('treeitem', { name: 'other.tex' }).click()
+
+    cy.findByRole('treeitem', { name: 'main.tex', selected: false })
+    cy.findByRole('treeitem', { name: 'other.tex', selected: true })
+
+    // single item selected: menu button is visible
+    cy.findAllByRole('button', { name: 'Menu' }).should('have.length', 1)
+
+    // multi-select the main item
+    cy.findByRole('treeitem', { name: 'main.tex' }).click({
+      ctrlKey: true,
+      cmdKey: true,
+    })
+
+    cy.findByRole('treeitem', { name: 'main.tex', selected: true })
+    cy.findByRole('treeitem', { name: 'other.tex', selected: true })
+
+    // multiple items selected: no menu button is visible
+    cy.findAllByRole('button', { name: 'Menu' }).should('have.length', 0)
+  })
+
+  describe('when deselecting files', function () {
+    beforeEach(function () {
+      const rootFolder = [
+        {
+          _id: 'root-folder-id',
+          name: 'rootFolder',
+          docs: [{ _id: '123abc', name: 'main.tex' }],
+          folders: [
+            {
+              _id: '789ghi',
+              name: 'thefolder',
+              docs: [{ _id: '456def', name: 'sub.tex' }],
+              fileRefs: [],
+              folders: [],
+            },
+          ],
+          fileRefs: [],
+        },
+      ]
+
+      cy.mount(
+        <EditorProviders
+          rootFolder={rootFolder as any}
+          projectId="123abc"
+          rootDocId="456def"
+          features={{} as any}
+          permissionsLevel="owner"
+          socket={new MockedSocket()}
+        >
+          <FileTreeRoot
+            refProviders={{}}
+            reindexReferences={cy.stub()}
+            setRefProviderEnabled={cy.stub()}
+            setStartedFreeTrial={cy.stub()}
+            onSelect={cy.stub()}
+            onInit={cy.stub()}
+            isConnected
+          />
+        </EditorProviders>
+      )
+
+      // select the sub file
+      cy.findByRole('treeitem', { name: 'sub.tex' }).click()
+      cy.findByRole('treeitem', { name: 'sub.tex' }).should(
+        'have.attr',
+        'aria-selected',
+        'true'
+      )
+
+      // click on empty area (after giving it extra height below the tree)
+      cy.findByTestId('file-tree-inner')
+        .invoke('attr', 'style', 'height: 400px')
+        .click()
+    })
+
+    it('removes the selected indicator', function () {
+      cy.findByRole('treeitem', { selected: true }).should('not.exist')
+    })
+
+    it('disables the "rename" and "delete" buttons', function () {
+      cy.findByRole('button', { name: 'Rename' }).should('not.exist')
+      cy.findByRole('button', { name: 'Delete' }).should('not.exist')
+    })
+
+    it('creates new file in the root folder', function () {
+      cy.intercept('project/*/doc', { statusCode: 200 })
+
+      cy.findByRole('button', { name: /new file/i }).click()
+      cy.findByRole('button', { name: /create/i }).click()
+
+      cy.window().then(win => {
+        // @ts-ignore
+        win._ide.socket.socketClient.emit('reciveNewDoc', 'root-folder-id', {
+          _id: '12345',
+          name: 'abcdef.tex',
+          docs: [],
+          fileRefs: [],
+          folders: [],
+        })
+      })
+
+      cy.findByRole('treeitem', { name: 'abcdef.tex' }).then($itemEl => {
+        cy.findByTestId('file-tree-list-root').then($rootEl => {
+          expect($itemEl.get(0).parentNode).to.equal($rootEl.get(0))
+        })
+      })
+    })
+
+    it('starts a new selection', function () {
+      cy.findByRole('treeitem', { name: 'sub.tex' }).should(
+        'have.attr',
+        'aria-selected',
+        'false'
+      )
+
+      cy.findByRole('treeitem', { name: 'main.tex' }).click({
+        ctrlKey: true,
+        cmdKey: true,
+      })
+
+      cy.findByRole('treeitem', { name: 'main.tex' }).should(
+        'have.attr',
+        'aria-selected',
+        'true'
+      )
+    })
+  })
+})

+ 0 - 409
services/web/test/frontend/features/file-tree/components/file-tree-root.test.jsx

@@ -1,409 +0,0 @@
-import { expect } from 'chai'
-import sinon from 'sinon'
-import { screen, fireEvent, waitFor } from '@testing-library/react'
-import fetchMock from 'fetch-mock'
-import MockedSocket from 'socket.io-mock'
-
-import {
-  renderWithEditorContext,
-  cleanUpContext,
-} from '../../../helpers/render-with-context'
-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 () {
-    window.metaAttributesCache = new Map()
-    window.metaAttributesCache.set('ol-user', { id: 'user1' })
-  })
-
-  afterEach(function () {
-    fetchMock.restore()
-    onSelect.reset()
-    onInit.reset()
-    cleanUpContext()
-    global.localStorage.clear()
-    window.metaAttributesCache = new Map()
-  })
-
-  it('renders', function () {
-    const rootFolder = [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [{ _id: '456def', name: 'main.tex' }],
-        folders: [],
-        fileRefs: [],
-      },
-    ]
-    const { container } = renderWithEditorContext(
-      <FileTreeRoot
-        refProviders={{}}
-        reindexReferences={() => null}
-        setRefProviderEnabled={() => null}
-        setStartedFreeTrial={() => null}
-        onSelect={onSelect}
-        onInit={onInit}
-        isConnected
-      />,
-      {
-        rootFolder,
-        projectId: '123abc',
-        rootDocId: '456def',
-        features: {},
-        permissionsLevel: 'owner',
-      }
-    )
-
-    screen.queryByRole('tree')
-    screen.getByRole('treeitem')
-    screen.getByRole('treeitem', { name: 'main.tex', selected: true })
-    expect(container.querySelector('.disconnected-overlay')).to.not.exist
-  })
-
-  it('renders with invalid selected doc in local storage', async function () {
-    global.localStorage.setItem(
-      'doc.open_id.123abc',
-      JSON.stringify('not-a-valid-id')
-    )
-    const rootFolder = [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [{ _id: '456def', name: 'main.tex' }],
-        folders: [],
-        fileRefs: [],
-      },
-    ]
-    renderWithEditorContext(
-      <FileTreeRoot
-        refProviders={{}}
-        reindexReferences={() => null}
-        setRefProviderEnabled={() => null}
-        setStartedFreeTrial={() => null}
-        onSelect={onSelect}
-        onInit={onInit}
-        isConnected
-      />,
-      {
-        rootFolder,
-        projectId: '123abc',
-        rootDocId: '456def',
-        features: {},
-        permissionsLevel: 'owner',
-      }
-    )
-
-    // as a proxy to check that the invalid entity ha not been select we start
-    // a delete and ensure the modal is displayed (the cancel button can be
-    // selected) This is needed to make sure the test fail.
-    const treeitemFile = screen.getByRole('treeitem', { name: 'main.tex' })
-    fireEvent.click(treeitemFile, { ctrlKey: true })
-    const toggleButton = screen.getByRole('button', { name: 'Menu' })
-    fireEvent.click(toggleButton)
-    const deleteButton = screen.getByRole('menuitem', { name: 'Delete' })
-    fireEvent.click(deleteButton)
-    await waitFor(() => screen.getByRole('button', { name: 'Cancel' }))
-  })
-
-  it('renders disconnected overlay', function () {
-    const rootFolder = [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [{ _id: '456def', name: 'main.tex' }],
-        folders: [],
-        fileRefs: [],
-      },
-    ]
-
-    const { container } = renderWithEditorContext(
-      <FileTreeRoot
-        onSelect={onSelect}
-        onInit={onInit}
-        isConnected={false}
-        refProviders={{}}
-        reindexReferences={() => null}
-        setRefProviderEnabled={() => null}
-        setStartedFreeTrial={() => null}
-      />,
-      {
-        rootFolder,
-        projectId: '123abc',
-        rootDocId: '456def',
-        features: {},
-        permissionsLevel: 'owner',
-      }
-    )
-
-    expect(container.querySelector('.disconnected-overlay')).to.exist
-  })
-
-  it('fire onSelect', function () {
-    const rootFolder = [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [
-          { _id: '456def', name: 'main.tex' },
-          { _id: '789ghi', name: 'other.tex' },
-        ],
-        folders: [],
-        fileRefs: [],
-      },
-    ]
-    renderWithEditorContext(
-      <FileTreeRoot
-        refProviders={{}}
-        reindexReferences={() => null}
-        setRefProviderEnabled={() => null}
-        setStartedFreeTrial={() => null}
-        onSelect={onSelect}
-        onInit={onInit}
-        isConnected
-      />,
-      {
-        rootFolder,
-        projectId: '123abc',
-        rootDocId: '456def',
-        features: {},
-        permissionsLevel: 'readOnly',
-      }
-    )
-    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 = [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [
-          { _id: '456def', name: 'main.tex' },
-          { _id: '789ghi', name: 'other.tex' },
-        ],
-        folders: [],
-        fileRefs: [],
-      },
-    ]
-    renderWithEditorContext(
-      <FileTreeRoot
-        refProviders={{}}
-        reindexReferences={() => null}
-        setRefProviderEnabled={() => null}
-        setStartedFreeTrial={() => null}
-        onSelect={onSelect}
-        onInit={onInit}
-        isConnected
-      />,
-      {
-        rootFolder,
-        projectId: '123abc',
-        rootDocId: '456def',
-        features: {},
-        permissionsLevel: 'owner',
-      }
-    )
-
-    screen.getByRole('treeitem', { name: 'main.tex', selected: true })
-
-    // entities not found should be ignored
-    window.dispatchEvent(
-      new CustomEvent('editor.openDoc', { detail: 'not-an-id' })
-    )
-    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 })
-  })
-
-  it('only shows a menu button when a single item is selected', function () {
-    const rootFolder = [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [
-          { _id: '456def', name: 'main.tex' },
-          { _id: '789ghi', name: 'other.tex' },
-        ],
-        folders: [],
-        fileRefs: [],
-      },
-    ]
-    renderWithEditorContext(
-      <FileTreeRoot
-        refProviders={{}}
-        reindexReferences={() => null}
-        setRefProviderEnabled={() => null}
-        setStartedFreeTrial={() => null}
-        onSelect={onSelect}
-        onInit={onInit}
-        isConnected
-      />,
-      {
-        rootFolder,
-        projectId: '123abc',
-        rootDocId: '456def',
-        features: {},
-        permissionsLevel: 'owner',
-      }
-    )
-
-    const main = screen.getByRole('treeitem', {
-      name: 'main.tex',
-      selected: true,
-    })
-    const other = screen.getByRole('treeitem', {
-      name: 'other.tex',
-      selected: false,
-    })
-
-    // single item selected: menu button is visible
-    expect(screen.queryAllByRole('button', { name: 'Menu' })).to.have.length(1)
-
-    // select the other item
-    fireEvent.click(other)
-
-    screen.getByRole('treeitem', { name: 'main.tex', selected: false })
-    screen.getByRole('treeitem', { name: 'other.tex', selected: true })
-
-    // single item selected: menu button is visible
-    expect(screen.queryAllByRole('button', { name: 'Menu' })).to.have.length(1)
-
-    // multi-select the main item
-    fireEvent.click(main, { ctrlKey: true })
-
-    screen.getByRole('treeitem', { name: 'main.tex', selected: true })
-    screen.getByRole('treeitem', { name: 'other.tex', selected: true })
-
-    // multiple items selected: no menu button is visible
-    expect(screen.queryAllByRole('button', { name: 'Menu' })).to.have.length(0)
-  })
-
-  describe('when deselecting files', function () {
-    beforeEach(function () {
-      const rootFolder = [
-        {
-          _id: 'root-folder-id',
-          name: 'rootFolder',
-          docs: [{ _id: '123abc', name: 'main.tex' }],
-          folders: [
-            {
-              _id: '789ghi',
-              name: 'thefolder',
-              docs: [{ _id: '456def', name: 'sub.tex' }],
-              fileRefs: [],
-              folders: [],
-            },
-          ],
-          fileRefs: [],
-        },
-      ]
-      renderWithEditorContext(
-        <FileTreeRoot
-          refProviders={{}}
-          reindexReferences={() => null}
-          setRefProviderEnabled={() => null}
-          setStartedFreeTrial={() => null}
-          onSelect={onSelect}
-          onInit={onInit}
-          isConnected
-        />,
-        {
-          rootFolder,
-          projectId: '123abc',
-          rootDocId: '456def',
-          features: {},
-          permissionsLevel: 'owner',
-          socket: new MockedSocket(),
-        }
-      )
-
-      // select the sub file
-      const subDoc = screen.getByRole('treeitem', { name: 'sub.tex' })
-      fireEvent.click(subDoc)
-      expect(subDoc.getAttribute('aria-selected')).to.equal('true')
-
-      // click on empty area
-      fireEvent.click(screen.getByTestId('file-tree-inner'))
-    })
-
-    afterEach(function () {
-      fetchMock.reset()
-    })
-
-    it('removes the selected indicator', function () {
-      expect(screen.queryByRole('treeitem', { selected: true })).to.be.null
-    })
-
-    it('disables the "rename" and "delete" buttons', function () {
-      expect(screen.queryByRole('button', { name: 'Rename' })).to.be.null
-      expect(screen.queryByRole('button', { name: 'Delete' })).to.be.null
-    })
-
-    it('creates new file in the root folder', async function () {
-      fetchMock.post('express:/project/:projectId/doc', () => 200)
-
-      fireEvent.click(screen.getByRole('button', { name: /new file/i }))
-      fireEvent.click(screen.getByRole('button', { name: /create/i }))
-
-      const socketData = {
-        _id: '12345',
-        name: 'abcdef.tex',
-        docs: [],
-        fileRefs: [],
-        folders: [],
-      }
-      window._ide.socket.socketClient.emit(
-        'reciveNewDoc',
-        'root-folder-id',
-        socketData
-      )
-
-      await fetchMock.flush(true)
-
-      const newItem = screen.getByRole('treeitem', { name: socketData.name })
-      const rootEl = screen.getByTestId('file-tree-list-root')
-
-      expect(newItem.parentNode).to.equal(rootEl)
-    })
-
-    it('starts a new selection', function () {
-      const subDoc = screen.getByRole('treeitem', { name: 'sub.tex' })
-      expect(subDoc.getAttribute('aria-selected')).to.equal('false')
-
-      const mainDoc = screen.getByRole('treeitem', { name: 'main.tex' })
-      fireEvent.click(mainDoc, { ctrlKey: true })
-      expect(mainDoc.getAttribute('aria-selected')).to.equal('true')
-
-      expect(subDoc.getAttribute('aria-selected')).to.equal('false')
-    })
-  })
-})

+ 59 - 0
services/web/test/frontend/features/file-tree/components/file-tree-toolbar.spec.tsx

@@ -0,0 +1,59 @@
+import FileTreeToolbar from '../../../../../frontend/js/features/file-tree/components/file-tree-toolbar'
+import { EditorProviders } from '../../../helpers/editor-providers'
+import { FileTreeProvider } from '../helpers/file-tree-provider'
+
+describe('<FileTreeToolbar/>', function () {
+  it('without selected files', function () {
+    cy.mount(
+      <EditorProviders>
+        <FileTreeProvider>
+          <FileTreeToolbar />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findAllByRole('button', { name: 'New File' })
+    cy.findAllByRole('button', { name: 'New Folder' })
+    cy.findAllByRole('button', { name: 'Upload' })
+    cy.findAllByRole('button', { name: 'Rename' }).should('not.exist')
+    cy.findAllByRole('button', { name: 'Delete' }).should('not.exist')
+  })
+
+  it('read-only', function () {
+    cy.mount(
+      <EditorProviders permissionsLevel="readOnly">
+        <FileTreeProvider>
+          <FileTreeToolbar />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findAllByRole('button').should('not.exist')
+  })
+
+  it('with one selected file', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '456def', name: 'main.tex' }],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders rootDocId="456def" rootFolder={rootFolder as any}>
+        <FileTreeProvider>
+          <FileTreeToolbar />
+        </FileTreeProvider>
+      </EditorProviders>
+    )
+
+    cy.findAllByRole('button', { name: 'New File' })
+    cy.findAllByRole('button', { name: 'New Folder' })
+    cy.findAllByRole('button', { name: 'Upload' })
+    cy.findAllByRole('button', { name: 'Rename' })
+    cy.findAllByRole('button', { name: 'Delete' })
+  })
+})

+ 0 - 52
services/web/test/frontend/features/file-tree/components/file-tree-toolbar.test.jsx

@@ -1,52 +0,0 @@
-import { expect } from 'chai'
-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 () {
-  beforeEach(function () {
-    global.localStorage.clear()
-  })
-
-  it('without selected files', function () {
-    renderWithContext(<FileTreeToolbar />)
-
-    screen.getByRole('button', { name: 'New File' })
-    screen.getByRole('button', { name: 'New Folder' })
-    screen.getByRole('button', { name: 'Upload' })
-    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: { permissionsLevel: 'readOnly' },
-    })
-
-    expect(screen.queryByRole('button')).to.not.exist
-  })
-
-  it('with one selected file', function () {
-    renderWithContext(<FileTreeToolbar />, {
-      contextProps: {
-        rootDocId: '456def',
-        rootFolder: [
-          {
-            _id: 'root-folder-id',
-            name: 'rootFolder',
-            docs: [{ _id: '456def', name: 'main.tex' }],
-            folders: [],
-            fileRefs: [],
-          },
-        ],
-      },
-    })
-
-    screen.getByRole('button', { name: 'New File' })
-    screen.getByRole('button', { name: 'New Folder' })
-    screen.getByRole('button', { name: 'Upload' })
-    screen.getByRole('button', { name: 'Rename' })
-    screen.getByRole('button', { name: 'Delete' })
-  })
-})

+ 117 - 0
services/web/test/frontend/features/file-tree/flows/context-menu.spec.tsx

@@ -0,0 +1,117 @@
+import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
+import { EditorProviders } from '../../../helpers/editor-providers'
+
+describe('FileTree Context Menu Flow', function () {
+  beforeEach(function () {
+    cy.window().then(win => {
+      win.metaAttributesCache.set('ol-user', { id: 'user1' })
+    })
+  })
+
+  it('opens on contextMenu event', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '456def', name: 'main.tex' }],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="456def"
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          reindexReferences={cy.stub()}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    cy.findByRole('menu').should('not.exist')
+    cy.findByRole('button', { name: 'main.tex' }).trigger('contextmenu')
+    cy.findByRole('menu')
+  })
+
+  it('closes when a new selection is started', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [
+          { _id: '456def', name: 'main.tex' },
+          { _id: '456def', name: 'foo.tex' },
+        ],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="456def"
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          reindexReferences={cy.stub()}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    cy.findByRole('menu').should('not.exist')
+    cy.findByRole('button', { name: 'main.tex' }).trigger('contextmenu')
+    cy.findByRole('menu')
+    cy.findAllByRole('button', { name: 'foo.tex' }).click()
+    cy.findByRole('menu').should('not.exist')
+  })
+
+  it("doesn't open in read only mode", function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '456def', name: 'main.tex' }],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="456def"
+        permissionsLevel="readOnly"
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          reindexReferences={cy.stub()}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    cy.findAllByRole('button', { name: 'main.tex' }).trigger('contextmenu')
+    cy.findByRole('menu').should('not.exist')
+  })
+})

+ 0 - 134
services/web/test/frontend/features/file-tree/flows/context-menu.test.jsx

@@ -1,134 +0,0 @@
-import { expect } from 'chai'
-import sinon from 'sinon'
-import { screen, fireEvent } from '@testing-library/react'
-
-import {
-  renderWithEditorContext,
-  cleanUpContext,
-} from '../../../helpers/render-with-context'
-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()
-
-  beforeEach(function () {
-    window.metaAttributesCache = new Map()
-    window.metaAttributesCache.set('ol-user', { id: 'user1' })
-  })
-
-  afterEach(function () {
-    onSelect.reset()
-    onInit.reset()
-    cleanUpContext()
-    window.metaAttributesCache = new Map()
-  })
-
-  it('opens on contextMenu event', async function () {
-    const rootFolder = [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [{ _id: '456def', name: 'main.tex' }],
-        folders: [],
-        fileRefs: [],
-      },
-    ]
-    renderWithEditorContext(
-      <FileTreeRoot
-        refProviders={{}}
-        reindexReferences={() => null}
-        setRefProviderEnabled={() => null}
-        setStartedFreeTrial={() => null}
-        onSelect={onSelect}
-        onInit={onInit}
-        isConnected
-      />,
-      {
-        rootFolder,
-        projectId: '123abc',
-        rootDocId: '456def',
-      }
-    )
-    const treeitem = screen.getByRole('button', { name: 'main.tex' })
-
-    expect(screen.queryByRole('menu')).to.be.null
-
-    fireEvent.contextMenu(treeitem)
-
-    screen.getByRole('menu')
-  })
-
-  it('closes when a new selection is started', async function () {
-    const rootFolder = [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [
-          { _id: '456def', name: 'main.tex' },
-          { _id: '456def', name: 'foo.tex' },
-        ],
-        folders: [],
-        fileRefs: [],
-      },
-    ]
-    renderWithEditorContext(
-      <FileTreeRoot
-        refProviders={{}}
-        reindexReferences={() => null}
-        setRefProviderEnabled={() => null}
-        setStartedFreeTrial={() => null}
-        onSelect={onSelect}
-        onInit={onInit}
-        isConnected
-      />,
-      {
-        rootFolder,
-        projectId: '123abc',
-        rootDocId: '456def',
-      }
-    )
-    const treeitem = screen.getByRole('button', { name: 'main.tex' })
-    expect(screen.queryByRole('menu')).to.be.null
-
-    fireEvent.contextMenu(treeitem)
-    screen.getByRole('menu')
-
-    screen.getByRole('button', { name: 'foo.tex' }).click()
-    expect(screen.queryByRole('menu')).to.be.null
-  })
-
-  it("doesn't open in read only mode", async function () {
-    const rootFolder = [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [{ _id: '456def', name: 'main.tex' }],
-        folders: [],
-        fileRefs: [],
-      },
-    ]
-    renderWithEditorContext(
-      <FileTreeRoot
-        refProviders={{}}
-        reindexReferences={() => null}
-        setRefProviderEnabled={() => null}
-        setStartedFreeTrial={() => null}
-        onSelect={onSelect}
-        onInit={onInit}
-        isConnected
-      />,
-      {
-        rootFolder,
-        projectId: '123abc',
-        rootDocId: '456def',
-        permissionsLevel: 'readOnly',
-      }
-    )
-    const treeitem = screen.getByRole('button', { name: 'main.tex' })
-
-    fireEvent.contextMenu(treeitem)
-
-    expect(screen.queryByRole('menu')).to.not.exist
-  })
-})

+ 287 - 0
services/web/test/frontend/features/file-tree/flows/create-folder.spec.tsx

@@ -0,0 +1,287 @@
+// @ts-ignore
+import MockedSocket from 'socket.io-mock'
+import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
+import { EditorProviders } from '../../../helpers/editor-providers'
+
+describe('FileTree Create Folder Flow', function () {
+  beforeEach(function () {
+    cy.window().then(win => {
+      win.metaAttributesCache.set('ol-user', { id: 'user1' })
+    })
+  })
+
+  it('add to root when no files are selected', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '456def', name: 'main.tex' }],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        socket={new MockedSocket()}
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          reindexReferences={cy.stub()}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    const name = 'Foo Bar In Root'
+
+    cy.intercept('post', '/project/*/folder', {
+      body: {
+        folders: [],
+        fileRefs: [],
+        docs: [],
+        _id: fakeId(),
+        name,
+      },
+    }).as('createFolder')
+
+    createFolder(name)
+
+    cy.get('@createFolder').its('request.body').should('deep.equal', {
+      parent_folder_id: 'root-folder-id',
+      name,
+    })
+
+    cy.window().then(win => {
+      // @ts-ignore
+      win._ide.socket.socketClient.emit('reciveNewFolder', 'root-folder-id', {
+        _id: fakeId(),
+        name,
+        docs: [],
+        fileRefs: [],
+        folders: [],
+      })
+    })
+
+    cy.findByRole('treeitem', { name })
+  })
+
+  it('add to folder from folder', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [],
+        folders: [
+          {
+            _id: '789ghi',
+            name: 'thefolder',
+            docs: [],
+            fileRefs: [],
+            folders: [],
+          },
+        ],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="789ghi"
+        socket={new MockedSocket()}
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          reindexReferences={cy.stub()}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    cy.findByRole('button', { name: 'Expand' }).click()
+
+    const name = 'Foo Bar In thefolder'
+
+    cy.intercept('post', '/project/*/folder', {
+      body: {
+        folders: [],
+        fileRefs: [],
+        docs: [],
+        _id: fakeId(),
+        name,
+      },
+    }).as('createFolder')
+
+    createFolder(name)
+
+    cy.get('@createFolder').its('request.body').should('deep.equal', {
+      parent_folder_id: '789ghi',
+      name,
+    })
+
+    cy.window().then(win => {
+      // @ts-ignore
+      win._ide.socket.socketClient.emit('reciveNewFolder', '789ghi', {
+        _id: fakeId(),
+        name,
+        docs: [],
+        fileRefs: [],
+        folders: [],
+      })
+    })
+
+    // find the created folder
+    cy.findByRole('treeitem', { name })
+
+    // collapse the parent folder; created folder should not be rendered anymore
+    cy.findByRole('button', { name: 'Collapse' }).click()
+    cy.findByRole('treeitem', { name }).should('not.exist')
+  })
+
+  it('add to folder from child', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [],
+        folders: [
+          {
+            _id: '789ghi',
+            name: 'thefolder',
+            docs: [],
+            fileRefs: [{ _id: '456def', name: 'sub.tex' }],
+            folders: [],
+          },
+        ],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="456def"
+        socket={new MockedSocket()}
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          reindexReferences={cy.stub()}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    const name = 'Foo Bar In thefolder'
+
+    cy.intercept('post', '/project/*/folder', {
+      body: {
+        folders: [],
+        fileRefs: [],
+        docs: [],
+        _id: fakeId(),
+        name,
+      },
+    }).as('createFolder')
+
+    createFolder(name)
+
+    cy.get('@createFolder').its('request.body').should('deep.equal', {
+      parent_folder_id: '789ghi',
+      name,
+    })
+
+    cy.window().then(win => {
+      // @ts-ignore
+      win._ide.socket.socketClient.emit('reciveNewFolder', '789ghi', {
+        _id: fakeId(),
+        name,
+        docs: [],
+        fileRefs: [],
+        folders: [],
+      })
+    })
+
+    // find the created folder
+    cy.findByRole('treeitem', { name })
+
+    // collapse the parent folder; created folder should not be rendered anymore
+    cy.findByRole('button', { name: 'Collapse' }).click()
+    cy.findByRole('treeitem', { name }).should('not.exist')
+  })
+
+  it('prevents adding duplicate or invalid names', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '456def', name: 'existingFile' }],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="456def"
+        socket={new MockedSocket()}
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          reindexReferences={cy.stub()}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    const name = 'existingFile'
+
+    cy.intercept('post', '/project/*/folder', cy.spy().as('createFolder'))
+
+    createFolder(name)
+
+    cy.get('@createFolder').should('not.have.been.called')
+
+    cy.findByRole('alert', {
+      name: 'A file or folder with this name already exists',
+    })
+
+    cy.findByRole('textbox').type('in/valid ')
+
+    cy.findByRole('alert', {
+      name: 'File name is empty or contains invalid characters',
+    })
+  })
+
+  function createFolder(name: string) {
+    cy.findByRole('button', { name: 'New Folder' }).click()
+    cy.findByRole('textbox').type(name)
+    cy.findByRole('button', { name: 'Create' }).click()
+  }
+
+  function fakeId() {
+    return Math.random().toString(16).replace(/0\./, 'random-test-id-')
+  }
+})

+ 0 - 296
services/web/test/frontend/features/file-tree/flows/create-folder.test.jsx

@@ -1,296 +0,0 @@
-import { expect } from 'chai'
-import sinon from 'sinon'
-import { screen, fireEvent, waitFor } from '@testing-library/react'
-import fetchMock from 'fetch-mock'
-import MockedSocket from 'socket.io-mock'
-
-import {
-  renderWithEditorContext,
-  cleanUpContext,
-} from '../../../helpers/render-with-context'
-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 () {
-    window.metaAttributesCache = new Map()
-    window.metaAttributesCache.set('ol-user', { id: 'user1' })
-  })
-
-  afterEach(function () {
-    fetchMock.restore()
-    onSelect.reset()
-    onInit.reset()
-    cleanUpContext()
-    window.metaAttributesCache = new Map()
-  })
-
-  it('add to root when no files are selected', async function () {
-    const rootFolder = [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [{ _id: '456def', name: 'main.tex' }],
-        folders: [],
-        fileRefs: [],
-      },
-    ]
-    renderWithEditorContext(
-      <FileTreeRoot
-        refProviders={{}}
-        reindexReferences={() => null}
-        setRefProviderEnabled={() => null}
-        setStartedFreeTrial={() => null}
-        onSelect={onSelect}
-        onInit={onInit}
-        isConnected
-      />,
-      {
-        socket: new MockedSocket(),
-        rootFolder,
-        projectId: '123abc',
-      }
-    )
-
-    const newFolderName = 'Foo Bar In Root'
-    const matcher = /\/project\/\w+\/folder/
-    const response = {
-      folders: [],
-      fileRefs: [],
-      docs: [],
-      _id: fakeId(),
-      name: newFolderName,
-    }
-    fetchMock.post(matcher, response)
-
-    await 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',
-        name: 'rootFolder',
-        docs: [],
-        folders: [
-          {
-            _id: '789ghi',
-            name: 'thefolder',
-            docs: [],
-            fileRefs: [],
-            folders: [],
-          },
-        ],
-        fileRefs: [],
-      },
-    ]
-    renderWithEditorContext(
-      <FileTreeRoot
-        refProviders={{}}
-        reindexReferences={() => null}
-        setRefProviderEnabled={() => null}
-        setStartedFreeTrial={() => null}
-        onSelect={onSelect}
-        onInit={onInit}
-        isConnected
-      />,
-      {
-        socket: new MockedSocket(),
-        rootFolder,
-        projectId: '123abc',
-        rootDocId: '789ghi',
-      }
-    )
-
-    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)
-
-    await 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',
-        name: 'rootFolder',
-        docs: [],
-        folders: [
-          {
-            _id: '789ghi',
-            name: 'thefolder',
-            docs: [],
-            fileRefs: [{ _id: '456def', name: 'sub.tex' }],
-            folders: [],
-          },
-        ],
-        fileRefs: [],
-      },
-    ]
-    renderWithEditorContext(
-      <FileTreeRoot
-        refProviders={{}}
-        reindexReferences={() => null}
-        setRefProviderEnabled={() => null}
-        setStartedFreeTrial={() => null}
-        onSelect={onSelect}
-        onInit={onInit}
-        isConnected
-      />,
-      {
-        socket: new MockedSocket(),
-        rootFolder,
-        projectId: '123abc',
-        rootDocId: '456def',
-      }
-    )
-
-    const newFolderName = 'Foo Bar In thefolder'
-    const matcher = /\/project\/\w+\/folder/
-    const response = {
-      folders: [],
-      fileRefs: [],
-      docs: [],
-      _id: fakeId(),
-      name: newFolderName,
-    }
-    fetchMock.post(matcher, response)
-
-    await 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(screen.getByRole('button', { name: 'Collapse' }))
-    expect(screen.queryByRole('treeitem', { name: newFolderName })).to.not.exist
-  })
-
-  it('prevents adding duplicate or invalid names', async function () {
-    const rootFolder = [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [{ _id: '456def', name: 'existingFile' }],
-        folders: [],
-        fileRefs: [],
-      },
-    ]
-    renderWithEditorContext(
-      <FileTreeRoot
-        refProviders={{}}
-        reindexReferences={() => null}
-        setRefProviderEnabled={() => null}
-        setStartedFreeTrial={() => null}
-        onSelect={onSelect}
-        onInit={onInit}
-        isConnected
-      />,
-      {
-        socket: new MockedSocket(),
-        rootFolder,
-        projectId: '123abc',
-        rootDocId: '456def',
-      }
-    )
-
-    let newFolderName = 'existingFile'
-
-    await fireCreateFolder(newFolderName)
-
-    expect(fetchMock.called()).to.be.false
-
-    await screen.findByRole('alert', {
-      name: 'A file or folder with this name already exists',
-      hidden: true,
-    })
-
-    newFolderName = 'in/valid '
-    setFolderName(newFolderName)
-    await screen.findByRole('alert', {
-      name: 'File name is empty or contains invalid characters',
-      hidden: true,
-    })
-  })
-
-  async function fireCreateFolder(name) {
-    const createFolderButton = screen.getByRole('button', {
-      name: 'New Folder',
-    })
-    fireEvent.click(createFolderButton)
-
-    setFolderName(name)
-
-    const modalCreateButton = await getModalCreateButton()
-    fireEvent.click(modalCreateButton)
-  }
-
-  function setFolderName(name) {
-    const input = screen.getByRole('textbox')
-    fireEvent.change(input, { target: { value: name } })
-  }
-
-  function fakeId() {
-    return Math.random().toString(16).replace(/0\./, 'random-test-id-')
-  }
-
-  async function getModalCreateButton() {
-    return waitFor(() => screen.getByRole('button', { name: 'Create' }))
-  }
-})

+ 282 - 0
services/web/test/frontend/features/file-tree/flows/delete-entity.spec.tsx

@@ -0,0 +1,282 @@
+// @ts-ignore
+import MockedSocket from 'socket.io-mock'
+import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
+import { EditorProviders } from '../../../helpers/editor-providers'
+
+describe('FileTree Delete Entity Flow', function () {
+  beforeEach(function () {
+    cy.window().then(win => {
+      win.metaAttributesCache.set('ol-user', { id: 'user1' })
+    })
+  })
+
+  describe('single entity', function () {
+    beforeEach(function () {
+      const rootFolder = [
+        {
+          _id: 'root-folder-id',
+          name: 'rootFolder',
+          docs: [
+            { _id: '123abc', name: 'foo.tex' },
+            { _id: '456def', name: 'main.tex' },
+          ],
+          folders: [],
+          fileRefs: [],
+        },
+      ]
+
+      cy.mount(
+        <EditorProviders
+          rootFolder={rootFolder as any}
+          projectId="123abc"
+          socket={new MockedSocket()}
+        >
+          <FileTreeRoot
+            refProviders={{}}
+            reindexReferences={cy.stub().as('reindexReferences')}
+            setRefProviderEnabled={cy.stub()}
+            setStartedFreeTrial={cy.stub()}
+            onSelect={cy.stub()}
+            onInit={cy.stub()}
+            isConnected
+          />
+        </EditorProviders>
+      )
+
+      cy.findByRole('treeitem', { name: 'main.tex' }).click()
+      cy.findByRole('button', { name: 'Menu' }).click()
+      cy.findByRole('menuitem', { name: 'Delete' }).click()
+    })
+
+    it('removes item', function () {
+      cy.intercept('delete', '/project/*/doc/*', { statusCode: 204 }).as(
+        'deleteDoc'
+      )
+
+      // check that the confirmation modal is open
+      cy.findByText(
+        'Are you sure you want to permanently delete the following files?'
+      )
+
+      cy.findByRole('button', { name: 'Delete' }).click()
+
+      cy.wait('@deleteDoc')
+
+      cy.window().then(win => {
+        // @ts-ignore
+        win._ide.socket.socketClient.emit('removeEntity', '456def')
+      })
+
+      cy.findByRole('treeitem', {
+        name: 'main.tex',
+        hidden: true, // treeitem might be hidden behind the modal
+      }).should('not.exist')
+
+      cy.findByRole('treeitem', {
+        name: 'main.tex',
+      }).should('not.exist')
+
+      // check that the confirmation modal is closed
+      cy.findByText(
+        'Are you sure you want to permanently delete the following files?'
+      ).should('not.exist')
+
+      cy.get('@deleteDoc.all').should('have.length', 1)
+      cy.get('@reindexReferences').should('not.have.been.called')
+    })
+
+    it('continues delete on 404s', function () {
+      cy.intercept('delete', '/project/*/doc/*', { statusCode: 404 }).as(
+        'deleteDoc'
+      )
+
+      // check that the confirmation modal is open
+      cy.findByText(
+        'Are you sure you want to permanently delete the following files?'
+      )
+
+      cy.findByRole('button', { name: 'Delete' }).click()
+
+      cy.window().then(win => {
+        // @ts-ignore
+        win._ide.socket.socketClient.emit('removeEntity', '456def')
+      })
+
+      cy.findByRole('treeitem', {
+        name: 'main.tex',
+        hidden: true, // treeitem might be hidden behind the modal
+      }).should('not.exist')
+
+      cy.findByRole('treeitem', {
+        name: 'main.tex',
+      }).should('not.exist')
+
+      // check that the confirmation modal is closed
+      // is not, the 404 probably triggered a bug
+      cy.findByText(
+        'Are you sure you want to permanently delete the following files?'
+      ).should('not.exist')
+    })
+
+    it('aborts delete on error', function () {
+      cy.intercept('delete', '/project/*/doc/*', { statusCode: 500 }).as(
+        'deleteDoc'
+      )
+
+      cy.findByRole('button', { name: 'Delete' }).click()
+
+      // The modal should still be open, but the file should not be deleted
+      cy.findByRole('treeitem', { name: 'main.tex', hidden: true })
+    })
+  })
+
+  describe('folders', function () {
+    beforeEach(function () {
+      const rootFolder = [
+        {
+          _id: 'root-folder-id',
+          name: 'rootFolder',
+          docs: [{ _id: '456def', name: 'main.tex' }],
+          folders: [
+            {
+              _id: '123abc',
+              name: 'folder',
+              docs: [],
+              folders: [],
+              fileRefs: [{ _id: '789ghi', name: 'my.bib' }],
+            },
+          ],
+          fileRefs: [],
+        },
+      ]
+
+      cy.mount(
+        <EditorProviders
+          rootFolder={rootFolder as any}
+          projectId="123abc"
+          socket={new MockedSocket()}
+        >
+          <FileTreeRoot
+            refProviders={{}}
+            reindexReferences={cy.stub().as('reindexReferences')}
+            setRefProviderEnabled={cy.stub()}
+            setStartedFreeTrial={cy.stub()}
+            onSelect={cy.stub()}
+            onInit={cy.stub()}
+            isConnected
+          />
+        </EditorProviders>
+      )
+
+      cy.findByRole('button', { name: 'Expand' }).click()
+      cy.findByRole('treeitem', { name: 'main.tex' }).click()
+      cy.findByRole('treeitem', { name: 'my.bib' }).click({
+        ctrlKey: true,
+        cmdKey: true,
+      })
+
+      cy.window().then(win => {
+        // @ts-ignore
+        win._ide.socket.socketClient.emit('removeEntity', '123abc')
+      })
+    })
+
+    it('removes the folder', function () {
+      cy.findByRole('treeitem', { name: 'folder' }).should('not.exist')
+    })
+
+    it('leaves the main file selected', function () {
+      cy.findByRole('treeitem', { name: 'main.tex', selected: true })
+    })
+
+    it('unselect the child entity', function () {
+      // as a proxy to check that the child entity has been unselect we start
+      // a delete and ensure the modal is displayed (the cancel button can be
+      // selected) This is needed to make sure the test fail.
+      cy.findByRole('button', { name: 'Menu' }).click()
+      cy.findByRole('menuitem', { name: 'Delete' }).click()
+      cy.findByRole('button', { name: 'Cancel' })
+    })
+  })
+
+  describe('multiple entities', function () {
+    beforeEach(function () {
+      const rootFolder = [
+        {
+          _id: 'root-folder-id',
+          name: 'rootFolder',
+          docs: [{ _id: '456def', name: 'main.tex' }],
+          folders: [],
+          fileRefs: [{ _id: '789ghi', name: 'my.bib' }],
+        },
+      ]
+
+      cy.mount(
+        <EditorProviders
+          rootFolder={rootFolder as any}
+          projectId="123abc"
+          socket={new MockedSocket()}
+        >
+          <FileTreeRoot
+            refProviders={{}}
+            reindexReferences={cy.stub().as('reindexReferences')}
+            setRefProviderEnabled={cy.stub()}
+            setStartedFreeTrial={cy.stub()}
+            onSelect={cy.stub()}
+            onInit={cy.stub()}
+            isConnected
+          />
+        </EditorProviders>
+      )
+
+      // select two files
+      cy.findByRole('treeitem', { name: 'main.tex' }).click()
+      cy.findByRole('treeitem', { name: 'my.bib' }).click({
+        ctrlKey: true,
+        cmdKey: true,
+      })
+
+      // open the context menu
+      cy.findByRole('button', { name: 'my.bib' }).trigger('contextmenu')
+
+      // make sure the menu has opened, with only a "Delete" item (as multiple files are selected)
+      cy.findByRole('menu')
+      cy.findAllByRole('menuitem').should('have.length', 1)
+
+      // select the Delete menu item
+      cy.findByRole('menuitem', { name: 'Delete' }).click()
+    })
+
+    it('removes all items and reindexes references after deleting .bib file', function () {
+      cy.intercept('delete', '/project/123abc/doc/456def', {
+        statusCode: 204,
+      }).as('deleteDoc')
+
+      cy.intercept('delete', '/project/123abc/file/789ghi', {
+        statusCode: 204,
+      }).as('deleteFile')
+
+      cy.findByRole('button', { name: 'Delete' }).click()
+
+      cy.window().then(win => {
+        // @ts-ignore
+        win._ide.socket.socketClient.emit('removeEntity', '456def')
+        // @ts-ignore
+        win._ide.socket.socketClient.emit('removeEntity', '789ghi')
+      })
+
+      for (const name of ['main.tex', 'my.bib']) {
+        for (const hidden of [true, false]) {
+          cy.findByRole('treeitem', { name, hidden }).should('not.exist')
+        }
+      }
+
+      // check that the confirmation modal is closed
+      cy.findByText('Are you sure').should('not.exist')
+
+      cy.get('@deleteDoc.all').should('have.length', 1)
+      cy.get('@deleteFile.all').should('have.length', 1)
+      cy.get('@reindexReferences').should('have.been.calledOnce')
+    })
+  })
+})

+ 0 - 302
services/web/test/frontend/features/file-tree/flows/delete-entity.test.jsx

@@ -1,302 +0,0 @@
-import { expect } from 'chai'
-import sinon from 'sinon'
-import { screen, fireEvent, waitFor } from '@testing-library/react'
-import fetchMock from 'fetch-mock'
-import MockedSocket from 'socket.io-mock'
-
-import {
-  renderWithEditorContext,
-  cleanUpContext,
-} from '../../../helpers/render-with-context'
-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()
-  const reindexReferences = sinon.stub()
-
-  beforeEach(function () {
-    window.metaAttributesCache = new Map()
-    window.metaAttributesCache.set('ol-user', { id: 'user1' })
-  })
-
-  afterEach(function () {
-    fetchMock.restore()
-    onSelect.reset()
-    onInit.reset()
-    reindexReferences.reset()
-    cleanUpContext()
-    window.metaAttributesCache = new Map()
-  })
-
-  describe('single entity', function () {
-    beforeEach(function () {
-      const rootFolder = [
-        {
-          _id: 'root-folder-id',
-          name: 'rootFolder',
-          docs: [{ _id: '456def', name: 'main.tex' }],
-          folders: [],
-          fileRefs: [],
-        },
-      ]
-      renderWithEditorContext(
-        <FileTreeRoot
-          refProviders={{}}
-          reindexReferences={reindexReferences}
-          setRefProviderEnabled={() => null}
-          setStartedFreeTrial={() => null}
-          onSelect={onSelect}
-          onInit={onInit}
-          isConnected
-        />,
-        {
-          socket: new MockedSocket(),
-          rootFolder,
-          projectId: '123abc',
-        }
-      )
-
-      const treeitem = screen.getByRole('treeitem', { name: 'main.tex' })
-      fireEvent.click(treeitem)
-
-      const toggleButton = screen.getByRole('button', { name: 'Menu' })
-      fireEvent.click(toggleButton)
-
-      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 = await 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
-
-        // check that the confirmation modal is closed
-        expect(screen.queryByText(/Are you sure/)).to.not.exist
-      })
-
-      const [lastFetchPath] = fetchMock.lastCall(fetchMatcher)
-      expect(lastFetchPath).to.equal('/project/123abc/doc/456def')
-      expect(reindexReferences).not.to.have.been.called
-    })
-
-    it('continues delete on 404s', async function () {
-      fetchMock.delete(/\/project\/\w+\/doc\/\w+/, 404)
-
-      const modalDeleteButton = await getModalDeleteButton()
-      fireEvent.click(modalDeleteButton)
-
-      window._ide.socket.socketClient.emit('removeEntity', '456def')
-
-      // check that the confirmation modal is open
-      screen.getByText(/Are you sure/)
-
-      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
-
-        // check that the confirmation modal is closed
-        // is not, the 404 probably triggered a bug
-        expect(screen.queryByText(/Are you sure/)).to.not.exist
-      })
-    })
-
-    it('aborts delete on error', async function () {
-      const fetchMatcher = /\/project\/\w+\/doc\/\w+/
-      fetchMock.delete(fetchMatcher, 500)
-
-      const modalDeleteButton = await 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('folders', function () {
-    beforeEach(function () {
-      const rootFolder = [
-        {
-          _id: 'root-folder-id',
-          name: 'rootFolder',
-          docs: [{ _id: '456def', name: 'main.tex' }],
-          folders: [
-            {
-              _id: '123abc',
-              name: 'folder',
-              docs: [],
-              folders: [],
-              fileRefs: [{ _id: '789ghi', name: 'my.bib' }],
-            },
-          ],
-          fileRefs: [],
-        },
-      ]
-      renderWithEditorContext(
-        <FileTreeRoot
-          refProviders={{}}
-          reindexReferences={() => null}
-          setRefProviderEnabled={() => null}
-          setStartedFreeTrial={() => null}
-          onSelect={onSelect}
-          onInit={onInit}
-          isConnected
-        />,
-        {
-          socket: new MockedSocket(),
-          rootFolder,
-          projectId: '123abc',
-        }
-      )
-
-      const expandButton = screen.queryByRole('button', { name: 'Expand' })
-      if (expandButton) fireEvent.click(expandButton)
-      const treeitemDoc = screen.getByRole('treeitem', { name: 'main.tex' })
-      fireEvent.click(treeitemDoc)
-      const treeitemFile = screen.getByRole('treeitem', { name: 'my.bib' })
-      fireEvent.click(treeitemFile, { ctrlKey: true })
-
-      window._ide.socket.socketClient.emit('removeEntity', '123abc')
-    })
-
-    it('removes the folder', function () {
-      expect(screen.queryByRole('treeitem', { name: 'folder' })).to.not.exist
-    })
-
-    it('leaves the main file selected', function () {
-      screen.getByRole('treeitem', { name: 'main.tex', selected: true })
-    })
-
-    it('unselect the child entity', async function () {
-      // as a proxy to check that the child entity has been unselect we start
-      // a delete and ensure the modal is displayed (the cancel button can be
-      // selected) This is needed to make sure the test fail.
-      const toggleButton = screen.getByRole('button', { name: 'Menu' })
-      fireEvent.click(toggleButton)
-      const deleteButton = screen.getByRole('menuitem', { name: 'Delete' })
-      fireEvent.click(deleteButton)
-      await waitFor(() => screen.getByRole('button', { name: 'Cancel' }))
-    })
-  })
-
-  describe('multiple entities', function () {
-    beforeEach(async function () {
-      const rootFolder = [
-        {
-          _id: 'root-folder-id',
-          name: 'rootFolder',
-          docs: [{ _id: '456def', name: 'main.tex' }],
-          folders: [],
-          fileRefs: [{ _id: '789ghi', name: 'my.bib' }],
-        },
-      ]
-
-      renderWithEditorContext(
-        <FileTreeRoot
-          refProviders={{}}
-          reindexReferences={reindexReferences}
-          setRefProviderEnabled={() => null}
-          setStartedFreeTrial={() => null}
-          onSelect={onSelect}
-          onInit={onInit}
-          isConnected
-        />,
-        {
-          socket: new MockedSocket(),
-          rootFolder,
-          projectId: '123abc',
-        }
-      )
-
-      // select two files
-      const treeitemDoc = screen.getByRole('treeitem', { name: 'main.tex' })
-      fireEvent.click(treeitemDoc)
-      const treeitemFile = screen.getByRole('treeitem', { name: 'my.bib' })
-      fireEvent.click(treeitemFile, { ctrlKey: true })
-
-      // open the context menu
-      const treeitemButton = screen.getByRole('button', { name: 'my.bib' })
-      fireEvent.contextMenu(treeitemButton)
-
-      // make sure the menu has opened, with only a "Delete" item (as multiple files are selected)
-      screen.getByRole('menu')
-      const menuItems = await screen.findAllByRole('menuitem')
-      expect(menuItems.length).to.equal(1)
-
-      // select the Delete menu item
-      const deleteButton = screen.getByRole('menuitem', { name: 'Delete' })
-      fireEvent.click(deleteButton)
-    })
-
-    it('removes all items and reindexes references after deleting .bib file', async function () {
-      const fetchMatcher = /\/project\/\w+\/(doc|file)\/\w+/
-      fetchMock.delete(fetchMatcher, 204)
-
-      const modalDeleteButton = await 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
-
-          // check that the confirmation modal is closed
-          expect(screen.queryByText(/Are you sure/)).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')
-      expect(reindexReferences).to.have.been.called
-    })
-  })
-
-  async function getModalDeleteButton() {
-    return waitFor(() => screen.getByRole('button', { name: 'Delete' }))
-  }
-})

+ 167 - 0
services/web/test/frontend/features/file-tree/flows/rename-entity.spec.tsx

@@ -0,0 +1,167 @@
+// @ts-ignore
+import MockedSocket from 'socket.io-mock'
+import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
+import { EditorProviders } from '../../../helpers/editor-providers'
+
+describe('FileTree Rename Entity Flow', function () {
+  beforeEach(function () {
+    cy.window().then(win => {
+      win.metaAttributesCache.set('ol-user', { id: 'user1' })
+    })
+  })
+
+  beforeEach(function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [{ _id: '456def', name: 'a.tex' }],
+        folders: [
+          {
+            _id: '987jkl',
+            name: 'folder',
+            docs: [],
+            fileRefs: [
+              { _id: '789ghi', name: 'c.tex' },
+              { _id: '981gkp', name: 'e.tex' },
+            ],
+            folders: [],
+          },
+        ],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        socket={new MockedSocket()}
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          reindexReferences={cy.stub()}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub().as('onSelect')}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+  })
+
+  it('renames doc', function () {
+    cy.intercept('/project/*/doc/*/rename', { statusCode: 204 }).as('renameDoc')
+
+    renameItem('a.tex', 'b.tex')
+
+    cy.findByRole('treeitem', { name: 'b.tex' })
+
+    cy.get('@renameDoc').its('request.body').should('deep.equal', {
+      name: 'b.tex',
+    })
+  })
+
+  it('renames folder', function () {
+    cy.intercept('/project/*/folder/*/rename', { statusCode: 204 }).as(
+      'renameFolder'
+    )
+
+    renameItem('folder', 'new folder name')
+
+    cy.findByRole('treeitem', { name: 'new folder name' })
+
+    cy.get('@renameFolder').its('request.body').should('deep.equal', {
+      name: 'new folder name',
+    })
+  })
+
+  it('renames file in subfolder', function () {
+    cy.intercept('/project/*/file/*/rename', { statusCode: 204 }).as(
+      'renameFile'
+    )
+
+    cy.findByRole('button', { name: 'Expand' }).click()
+
+    renameItem('c.tex', 'd.tex')
+
+    cy.findByRole('treeitem', { name: 'folder' })
+    cy.findByRole('treeitem', { name: 'd.tex' })
+
+    cy.get('@renameFile').its('request.body').should('deep.equal', {
+      name: 'd.tex',
+    })
+  })
+
+  it('reverts rename on error', function () {
+    cy.intercept('/project/*/doc/*/rename', { statusCode: 500 })
+
+    renameItem('a.tex', 'b.tex')
+
+    cy.findByRole('treeitem', { name: 'a.tex' })
+  })
+
+  it('shows error modal on invalid filename', function () {
+    renameItem('a.tex', '///')
+
+    cy.findByRole('alert', {
+      name: 'File name is empty or contains invalid characters',
+      hidden: true,
+    })
+  })
+
+  it('shows error modal on duplicate filename', function () {
+    renameItem('a.tex', 'folder')
+
+    cy.findByRole('alert', {
+      name: 'A file or folder with this name already exists',
+      hidden: true,
+    })
+  })
+
+  it('shows error modal on duplicate filename in subfolder', function () {
+    cy.findByRole('button', { name: 'Expand' }).click()
+
+    renameItem('c.tex', 'e.tex')
+
+    cy.findByRole('alert', {
+      name: 'A file or folder with this name already exists',
+      hidden: true,
+    })
+  })
+
+  it('shows error modal on blocked filename', function () {
+    renameItem('a.tex', 'prototype')
+
+    cy.findByRole('alert', {
+      name: 'This file name is blocked.',
+      hidden: true,
+    })
+  })
+
+  describe('via socket event', function () {
+    it('renames doc', function () {
+      cy.findByRole('treeitem', { name: 'a.tex' })
+
+      cy.window().then(win => {
+        // @ts-ignore
+        win._ide.socket.socketClient.emit(
+          'reciveEntityRename',
+          '456def',
+          'socket.tex'
+        )
+      })
+
+      cy.findByRole('treeitem', { name: 'socket.tex' })
+    })
+  })
+
+  function renameItem(from: string, to: string) {
+    cy.findByRole('treeitem', { name: from }).click()
+    cy.findByRole('button', { name: 'Menu' }).click()
+    cy.findByRole('menuitem', { name: 'Rename' }).click()
+    cy.findByRole('textbox').clear()
+    cy.findByRole('textbox').type(to + '{enter}')
+  }
+})

+ 0 - 208
services/web/test/frontend/features/file-tree/flows/rename-entity.test.jsx

@@ -1,208 +0,0 @@
-import { expect } from 'chai'
-import sinon from 'sinon'
-import { screen, fireEvent } from '@testing-library/react'
-import fetchMock from 'fetch-mock'
-import MockedSocket from 'socket.io-mock'
-
-import {
-  renderWithEditorContext,
-  cleanUpContext,
-} from '../../../helpers/render-with-context'
-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.metaAttributesCache = new Map()
-    window.metaAttributesCache.set('ol-user', { id: 'user1' })
-  })
-
-  afterEach(function () {
-    fetchMock.restore()
-    onSelect.reset()
-    onInit.reset()
-    cleanUpContext()
-    window.metaAttributesCache = new Map()
-  })
-
-  beforeEach(function () {
-    const rootFolder = [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [{ _id: '456def', name: 'a.tex' }],
-        folders: [
-          {
-            _id: '987jkl',
-            name: 'folder',
-            docs: [],
-            fileRefs: [
-              { _id: '789ghi', name: 'c.tex' },
-              { _id: '981gkp', name: 'e.tex' },
-            ],
-            folders: [],
-          },
-        ],
-        fileRefs: [],
-      },
-    ]
-    renderWithEditorContext(
-      <FileTreeRoot
-        refProviders={{}}
-        reindexReferences={() => null}
-        setRefProviderEnabled={() => null}
-        setStartedFreeTrial={() => null}
-        onSelect={onSelect}
-        onInit={onInit}
-        isConnected
-      />,
-      {
-        socket: new MockedSocket(),
-        rootFolder,
-        projectId: '123abc',
-      }
-    )
-    onSelect.reset()
-  })
-
-  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')
-
-    // onSelect should have been called once only: when the doc was selected for
-    // rename
-    sinon.assert.calledOnce(onSelect)
-  })
-
-  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.queryByRole('button', { name: 'Expand' })
-    if (expandButton) 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' })
-  })
-
-  it('shows error modal on invalid filename', async function () {
-    const input = initItemRename('a.tex')
-    fireEvent.change(input, { target: { value: '///' } })
-    fireEvent.keyDown(input, { key: 'Enter' })
-
-    await screen.findByRole('alert', {
-      name: 'File name is empty or contains invalid characters',
-      hidden: true,
-    })
-  })
-
-  it('shows error modal on duplicate filename', async function () {
-    const input = initItemRename('a.tex')
-    fireEvent.change(input, { target: { value: 'folder' } })
-    fireEvent.keyDown(input, { key: 'Enter' })
-
-    await screen.findByRole('alert', {
-      name: 'A file or folder with this name already exists',
-      hidden: true,
-    })
-  })
-
-  it('shows error modal on duplicate filename in subfolder', async function () {
-    const expandButton = screen.queryByRole('button', { name: 'Expand' })
-    if (expandButton) fireEvent.click(expandButton)
-
-    const input = initItemRename('c.tex')
-    fireEvent.change(input, { target: { value: 'e.tex' } })
-    fireEvent.keyDown(input, { key: 'Enter' })
-
-    await screen.findByRole('alert', {
-      name: 'A file or folder with this name already exists',
-      hidden: true,
-    })
-  })
-
-  it('shows error modal on blocked filename', async function () {
-    const input = initItemRename('a.tex')
-    fireEvent.change(input, { target: { value: 'prototype' } })
-    fireEvent.keyDown(input, { key: 'Enter' })
-
-    await screen.findByRole('alert', {
-      name: 'This file name is blocked.',
-      hidden: true,
-    })
-  })
-
-  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 toggleButton = screen.getByRole('button', { name: 'Menu' })
-    fireEvent.click(toggleButton)
-
-    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)
-  }
-})

+ 18 - 0
services/web/test/frontend/features/file-tree/helpers/file-tree-provider.tsx

@@ -0,0 +1,18 @@
+import { FC } from 'react'
+import FileTreeContext from '@/features/file-tree/components/file-tree-context'
+
+export const FileTreeProvider: FC<{
+  refProviders?: Record<string, boolean>
+}> = ({ children, refProviders = {} }) => {
+  return (
+    <FileTreeContext
+      refProviders={refProviders}
+      reindexReferences={cy.stub().as('reindexReferences')}
+      setRefProviderEnabled={cy.stub().as('setRefProviderEnabled')}
+      setStartedFreeTrial={cy.stub().as('setStartedFreeTrial')}
+      onSelect={() => {}}
+    >
+      <>{children}</>
+    </FileTreeContext>
+  )
+}

+ 0 - 52
services/web/test/frontend/features/file-tree/helpers/render-with-context.jsx

@@ -1,52 +0,0 @@
-import FileTreeContext from '../../../../../frontend/js/features/file-tree/components/file-tree-context'
-import { renderWithEditorContext } from '../../../helpers/render-with-context'
-import { debugConsole } from '@/utils/debugging'
-
-export default (children, options = {}) => {
-  let { contextProps = {}, ...renderOptions } = options
-  contextProps = {
-    projectId: '123abc',
-    rootFolder: [
-      {
-        _id: 'root-folder-id',
-        name: 'rootFolder',
-        docs: [],
-        fileRefs: [],
-        folders: [],
-      },
-    ],
-    refProviders: {},
-    reindexReferences: () => {
-      debugConsole.warn('reindex references')
-    },
-    setRefProviderEnabled: provider => {
-      debugConsole.warn(`ref provider ${provider} enabled`)
-    },
-    setStartedFreeTrial: () => {
-      debugConsole.warn('started free trial')
-    },
-    onSelect: () => {},
-    ...contextProps,
-  }
-  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
-  )
-}

+ 105 - 0
services/web/test/frontend/features/project-list/components/new-project-button/upload-project-modal.spec.tsx

@@ -0,0 +1,105 @@
+import UploadProjectModal from '../../../../../../frontend/js/features/project-list/components/new-project-button/upload-project-modal'
+
+describe('<UploadProjectModal />', function () {
+  const maxUploadSize = 10 * 1024 * 1024 // 10 MB
+
+  beforeEach(function () {
+    cy.window().then(win => {
+      win.metaAttributesCache.set('ol-ExposedSettings', { maxUploadSize })
+    })
+  })
+
+  it('uploads a dropped file', function () {
+    cy.intercept('post', '/project/new/upload', {
+      body: { success: true, project_id: '123abc' },
+    }).as('uploadProject')
+
+    cy.mount(
+      <UploadProjectModal
+        onHide={cy.stub()}
+        openProject={cy.stub().as('openProject')}
+      />
+    )
+
+    cy.findByRole('button', {
+      name: 'Select a .zip file',
+    }).trigger('drop', {
+      dataTransfer: {
+        files: [new File(['test'], 'test.zip', { type: 'application/zip' })],
+      },
+    })
+
+    cy.wait('@uploadProject')
+    cy.get('@openProject').should('have.been.calledOnceWith', '123abc')
+  })
+
+  it('shows error on file type other than zip', function () {
+    cy.mount(
+      <UploadProjectModal
+        onHide={cy.stub()}
+        openProject={cy.stub().as('openProject')}
+      />
+    )
+
+    cy.findByRole('button', {
+      name: 'Select a .zip file',
+    }).trigger('drop', {
+      dataTransfer: {
+        files: [new File(['test'], 'test.png', { type: 'image/png' })],
+      },
+    })
+
+    cy.findByText('You can only upload: .zip')
+    cy.get('@openProject').should('not.have.been.called')
+  })
+
+  it('shows error for files bigger than maxUploadSize', function () {
+    cy.mount(
+      <UploadProjectModal
+        onHide={cy.stub()}
+        openProject={cy.stub().as('openProject')}
+      />
+    )
+
+    const file = new File(['test'], 'test.zip', { type: 'application/zip' })
+    Object.defineProperty(file, 'size', { value: maxUploadSize + 1 })
+
+    cy.findByRole('button', {
+      name: 'Select a .zip file',
+    }).trigger('drop', {
+      dataTransfer: {
+        files: [file],
+      },
+    })
+
+    cy.findByText('test.zip exceeds maximum allowed size of 10 MB')
+    cy.get('@openProject').should('not.have.been.called')
+  })
+
+  it('handles server error', function () {
+    cy.intercept('post', '/project/new/upload', {
+      statusCode: 422,
+      body: { success: false },
+    }).as('uploadProject')
+
+    cy.mount(
+      <UploadProjectModal
+        onHide={cy.stub()}
+        openProject={cy.stub().as('openProject')}
+      />
+    )
+
+    cy.findByRole('button', {
+      name: 'Select a .zip file',
+    }).trigger('drop', {
+      dataTransfer: {
+        files: [new File(['test'], 'test.zip', { type: 'application/zip' })],
+      },
+    })
+
+    cy.wait('@uploadProject')
+
+    cy.findByText('Upload failed')
+    cy.get('@openProject').should('not.have.been.called')
+  })
+})

+ 0 - 152
services/web/test/frontend/features/project-list/components/new-project-button/upload-project-modal.test.tsx

@@ -1,152 +0,0 @@
-import sinon from 'sinon'
-import { render, screen, fireEvent, waitFor } from '@testing-library/react'
-import UploadProjectModal from '../../../../../../frontend/js/features/project-list/components/new-project-button/upload-project-modal'
-import { expect } from 'chai'
-import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
-
-describe('<UploadProjectModal />', function () {
-  const originalWindowCSRFToken = window.csrfToken
-  const maxUploadSize = 10 * 1024 * 1024 // 10 MB
-
-  let assignStub: sinon.SinonStub
-
-  beforeEach(function () {
-    assignStub = sinon.stub()
-    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
-      assign: assignStub,
-      reload: sinon.stub(),
-    })
-    window.metaAttributesCache.set('ol-ExposedSettings', {
-      maxUploadSize,
-    })
-    window.csrfToken = 'token'
-  })
-
-  afterEach(function () {
-    this.locationStub.restore()
-    window.metaAttributesCache = new Map()
-    window.csrfToken = originalWindowCSRFToken
-  })
-
-  it('uploads a dropped file', async function () {
-    const xhr = sinon.useFakeXMLHttpRequest()
-    const requests: sinon.SinonFakeXMLHttpRequest[] = []
-    xhr.onCreate = request => {
-      requests.push(request)
-    }
-
-    render(<UploadProjectModal onHide={() => {}} />)
-
-    const uploadButton = screen.getByRole('button', {
-      name: 'Select a .zip file',
-    })
-
-    expect(uploadButton).not.to.be.null
-
-    fireEvent.drop(uploadButton, {
-      dataTransfer: {
-        files: [new File(['test'], 'test.zip', { type: 'application/zip' })],
-      },
-    })
-
-    await waitFor(() => expect(requests).to.have.length(1))
-
-    const [request] = requests
-    expect(request.url).to.equal('/project/new/upload')
-    expect(request.method).to.equal('POST')
-
-    const projectId = '123abc'
-    request.respond(
-      200,
-      { 'Content-Type': 'application/json' },
-      JSON.stringify({ success: true, project_id: projectId })
-    )
-
-    await waitFor(() => {
-      sinon.assert.calledOnce(assignStub)
-      sinon.assert.calledWith(assignStub, `/project/${projectId}`)
-    })
-
-    xhr.restore()
-  })
-
-  it('shows error on file type other than zip', async function () {
-    render(<UploadProjectModal onHide={() => {}} />)
-
-    const uploadButton = screen.getByRole('button', {
-      name: 'Select a .zip file',
-    })
-
-    expect(uploadButton).not.to.be.null
-
-    fireEvent.drop(uploadButton, {
-      dataTransfer: {
-        files: [new File(['test'], 'test.png', { type: 'image/png' })],
-      },
-    })
-
-    await waitFor(() => screen.getByText('You can only upload: .zip'))
-  })
-
-  it('shows error for files bigger than maxUploadSize', async function () {
-    render(<UploadProjectModal onHide={() => {}} />)
-
-    const uploadButton = screen.getByRole('button', {
-      name: 'Select a .zip file',
-    })
-    expect(uploadButton).not.to.be.null
-
-    const filename = 'test.zip'
-    const file = new File(['test'], filename, { type: 'application/zip' })
-    Object.defineProperty(file, 'size', { value: maxUploadSize + 1 })
-
-    fireEvent.drop(uploadButton, {
-      dataTransfer: {
-        files: [file],
-      },
-    })
-
-    await waitFor(() =>
-      screen.getByText(`${filename} exceeds maximum allowed size of 10 MB`)
-    )
-  })
-
-  it('handles server error', async function () {
-    const xhr = sinon.useFakeXMLHttpRequest()
-    const requests: sinon.SinonFakeXMLHttpRequest[] = []
-    xhr.onCreate = request => {
-      requests.push(request)
-    }
-
-    render(<UploadProjectModal onHide={() => {}} />)
-
-    const uploadButton = screen.getByRole('button', {
-      name: 'Select a .zip file',
-    })
-    expect(uploadButton).not.to.be.null
-
-    fireEvent.drop(uploadButton, {
-      dataTransfer: {
-        files: [new File(['test'], 'test.zip', { type: 'application/zip' })],
-      },
-    })
-
-    await waitFor(() => expect(requests).to.have.length(1))
-
-    const [request] = requests
-    expect(request.url).to.equal('/project/new/upload')
-    expect(request.method).to.equal('POST')
-    request.respond(
-      422,
-      { 'Content-Type': 'application/json' },
-      JSON.stringify({ success: false })
-    )
-
-    await waitFor(() => {
-      sinon.assert.notCalled(assignStub)
-      screen.getByText('Upload failed')
-    })
-
-    xhr.restore()
-  })
-})

+ 1 - 1
services/web/test/frontend/features/source-editor/components/figure-modal.spec.tsx

@@ -83,7 +83,7 @@ describe('<FigureModal />', function () {
       cy.findByRole('menu').within(() => {
         cy.findByText('Upload from computer').click()
       })
-      cy.findByLabelText('File Uploader')
+      cy.findByLabelText('Uppy Dashboard')
         .get('.uppy-Dashboard-input:first')
         .as('file-input')
     })

Some files were not shown because too many files changed in this diff