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

React File Tree

GitOrigin-RevId: fb3141ba8cd9ca0d68e87edb74764a360144c8fe
This commit is contained in:
Timothée Alby
2020-11-27 03:05:05 +00:00
committed by Copybot
parent d5544f0626
commit 420aa4a657
66 changed files with 4210 additions and 24 deletions
@@ -29,13 +29,11 @@ export function tearDownMathJaxStubs() {
export function stubChatStore({ user }) {
window._ide = { socket: { on: sinon.stub(), off: sinon.stub() } }
window.dispatchEvent = sinon.stub()
window.user = user
resetChatStore()
}
export function tearDownChatStore() {
delete window._ide
delete window.dispatchEvent
delete window.user
}
@@ -30,8 +30,6 @@ describe('ChatStore', function() {
window._ide = { socket }
mockSocketMessage = message => socket.on.getCall(0).args[1](message)
window.dispatchEvent = sinon.stub()
store = new ChatStore()
})
@@ -0,0 +1,60 @@
import { expect } from 'chai'
import React from 'react'
import { screen, fireEvent } from '@testing-library/react'
import renderWithContext from '../helpers/render-with-context'
import FileTreeDoc from '../../../../../frontend/js/features/file-tree/components/file-tree-doc'
describe('<FileTreeDoc/>', function() {
it('renders unselected', function() {
const { container } = renderWithContext(
<FileTreeDoc name="foo.tex" id="123abc" isLinkedFile={false} />
)
screen.getByRole('treeitem', { selected: false })
expect(container.querySelector('i.linked-file-highlight')).to.not.exist
})
it('renders selected', function() {
renderWithContext(
<FileTreeDoc name="foo.tex" id="123abc" isLinkedFile={false} />
)
const treeitem = screen.getByRole('treeitem', { selected: false })
fireEvent.click(treeitem)
screen.getByRole('treeitem', { selected: true })
screen.getByRole('menuitem', { name: 'Rename' })
screen.getByRole('menuitem', { name: 'Delete' })
screen.getByRole('menuitem', { name: 'New File' })
screen.getByRole('menuitem', { name: 'New Folder' })
screen.getByRole('menuitem', { name: 'Upload' })
})
it('renders as linked file', function() {
const { container } = renderWithContext(
<FileTreeDoc name="foo.tex" id="123abc" isLinkedFile />
)
screen.getByRole('treeitem')
expect(container.querySelector('i.linked-file-highlight')).to.exist
})
it('selects', function() {
renderWithContext(<FileTreeDoc name="foo.tex" id="123abc" expanded />)
const treeitem = screen.getByRole('treeitem', { selected: false })
fireEvent.click(treeitem)
screen.getByRole('treeitem', { selected: true })
})
it('multi-selects', function() {
renderWithContext(<FileTreeDoc name="foo.tex" id="123abc" expanded />)
const treeitem = screen.getByRole('treeitem')
fireEvent.click(treeitem, { ctrlKey: true })
screen.getByRole('treeitem', { selected: true })
})
})
@@ -0,0 +1,127 @@
import { expect } from 'chai'
import React from 'react'
import { screen, fireEvent } from '@testing-library/react'
import renderWithContext from '../helpers/render-with-context'
import FileTreeFolderList from '../../../../../frontend/js/features/file-tree/components/file-tree-folder-list'
describe('<FileTreeFolderList/>', function() {
it('renders empty', function() {
renderWithContext(<FileTreeFolderList folders={[]} docs={[]} files={[]} />)
screen.queryByRole('tree')
expect(screen.queryByRole('treeitem')).to.not.exist
})
it('renders docs, files and folders', function() {
const aFolder = {
_id: '456def',
name: 'A Folder',
folders: [],
docs: [],
fileRefs: []
}
const aDoc = { _id: '789ghi', name: 'doc.tex', linkedFileData: {} }
const aFile = { _id: '987jkl', name: 'file.bib', linkedFileData: {} }
renderWithContext(
<FileTreeFolderList folders={[aFolder]} docs={[aDoc]} files={[aFile]} />
)
screen.queryByRole('tree')
screen.queryByRole('treeitem', { name: 'A Folder' })
screen.queryByRole('treeitem', { name: 'doc.tex' })
screen.queryByRole('treeitem', { name: 'file.bib' })
})
describe('selection and multi-selection', function() {
it('without write permissions', function() {
const docs = [{ _id: '1', name: '1.tex' }, { _id: '2', name: '2.tex' }]
renderWithContext(
<FileTreeFolderList folders={[]} docs={docs} files={[]} />,
{ contextProps: { hasWritePermissions: false } }
)
const treeitem1 = screen.getByRole('treeitem', { name: '1.tex' })
const treeitem2 = screen.getByRole('treeitem', { name: '2.tex' })
// click on item 1: it gets selected
fireEvent.click(treeitem1)
screen.getByRole('treeitem', { name: '1.tex', selected: true })
screen.getByRole('treeitem', { name: '2.tex', selected: false })
// meta-click on item 2: no changes
fireEvent.click(treeitem2, { ctrlKey: true })
screen.getByRole('treeitem', { name: '1.tex', selected: true })
screen.getByRole('treeitem', { name: '2.tex', selected: false })
})
it('with write permissions', function() {
const docs = [
{ _id: '1', name: '1.tex' },
{ _id: '2', name: '2.tex' },
{ _id: '3', name: '3.tex' }
]
renderWithContext(
<FileTreeFolderList folders={[]} docs={docs} files={[]} />,
{ contextProps: { initialSelectedEntityId: '1' } }
)
const treeitem1 = screen.getByRole('treeitem', { name: '1.tex' })
const treeitem2 = screen.getByRole('treeitem', { name: '2.tex' })
const treeitem3 = screen.getByRole('treeitem', { name: '3.tex' })
// item 1 i selected by default
screen.getByRole('treeitem', { name: '1.tex', selected: true })
screen.getByRole('treeitem', { name: '2.tex', selected: false })
screen.getByRole('treeitem', { name: '3.tex', selected: false })
// click on item 2: it gets selected and item 1 is not selected anymore
fireEvent.click(treeitem2)
screen.getByRole('treeitem', { name: '1.tex', selected: false })
screen.getByRole('treeitem', { name: '2.tex', selected: true })
screen.getByRole('treeitem', { name: '3.tex', selected: false })
// meta-click on item 3: it gets selected and item 2 as well
fireEvent.click(treeitem3, { ctrlKey: true })
screen.getByRole('treeitem', { name: '1.tex', selected: false })
screen.getByRole('treeitem', { name: '2.tex', selected: true })
screen.getByRole('treeitem', { name: '3.tex', selected: true })
// meta-click on item 1: add to selection
fireEvent.click(treeitem1, { ctrlKey: true })
screen.getByRole('treeitem', { name: '1.tex', selected: true })
screen.getByRole('treeitem', { name: '2.tex', selected: true })
screen.getByRole('treeitem', { name: '3.tex', selected: true })
// meta-click on item 1: remove from selection
fireEvent.click(treeitem1, { ctrlKey: true })
screen.getByRole('treeitem', { name: '1.tex', selected: false })
screen.getByRole('treeitem', { name: '2.tex', selected: true })
screen.getByRole('treeitem', { name: '3.tex', selected: true })
// meta-click on item 3: remove from selection
fireEvent.click(treeitem3, { ctrlKey: true })
screen.getByRole('treeitem', { name: '1.tex', selected: false })
screen.getByRole('treeitem', { name: '2.tex', selected: true })
screen.getByRole('treeitem', { name: '3.tex', selected: false })
// meta-click on item 2: cannot unselect
fireEvent.click(treeitem2, { ctrlKey: true })
screen.getByRole('treeitem', { name: '1.tex', selected: false })
screen.getByRole('treeitem', { name: '2.tex', selected: true })
screen.getByRole('treeitem', { name: '3.tex', selected: false })
// meta-click on item 3: add back to selection
fireEvent.click(treeitem3, { ctrlKey: true })
screen.getByRole('treeitem', { name: '1.tex', selected: false })
screen.getByRole('treeitem', { name: '2.tex', selected: true })
screen.getByRole('treeitem', { name: '3.tex', selected: true })
// click on item 3: unselect other items
fireEvent.click(treeitem3)
screen.getByRole('treeitem', { name: '1.tex', selected: false })
screen.getByRole('treeitem', { name: '2.tex', selected: false })
screen.getByRole('treeitem', { name: '3.tex', selected: true })
})
})
})
@@ -0,0 +1,66 @@
import { expect } from 'chai'
import React from 'react'
import { screen, fireEvent } from '@testing-library/react'
import renderWithContext from '../helpers/render-with-context'
import FileTreeFolder from '../../../../../frontend/js/features/file-tree/components/file-tree-folder'
describe('<FileTreeFolder/>', function() {
it('renders unselected', function() {
renderWithContext(
<FileTreeFolder
name="foo"
id="123abc"
folders={[]}
docs={[]}
files={[]}
/>
)
screen.getByRole('treeitem', { selected: false })
expect(screen.queryByRole('tree')).to.not.exist
})
it('renders selected', function() {
renderWithContext(
<FileTreeFolder
name="foo"
id="123abc"
folders={[]}
docs={[]}
files={[]}
/>
)
const treeitem = screen.getByRole('treeitem', { selected: false })
fireEvent.click(treeitem)
screen.getByRole('treeitem', { selected: true })
screen.getByRole('menuitem', { name: 'Rename' })
screen.getByRole('menuitem', { name: 'Delete' })
screen.getByRole('menuitem', { name: 'New File' })
screen.getByRole('menuitem', { name: 'New Folder' })
screen.getByRole('menuitem', { name: 'Upload' })
screen.getByRole('treeitem', { selected: true })
expect(screen.queryByRole('tree')).to.not.exist
})
it('expands', function() {
renderWithContext(
<FileTreeFolder
name="foo"
id="123abc"
folders={[]}
docs={[]}
files={[]}
/>
)
screen.getByRole('treeitem')
const expandButton = screen.getByRole('button', { name: 'Expand' })
fireEvent.click(expandButton)
screen.getByRole('tree')
})
})
@@ -0,0 +1,81 @@
import { expect } from 'chai'
import React from 'react'
import sinon from 'sinon'
import { screen, fireEvent } from '@testing-library/react'
import renderWithContext from '../../helpers/render-with-context'
import FileTreeitemInner from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner'
describe('<FileTreeitemInner />', function() {
const setContextMenuCoords = sinon.stub()
beforeEach(function() {
global.requestAnimationFrame = sinon.stub()
})
afterEach(function() {
setContextMenuCoords.reset()
delete global.requestAnimationFrame
})
describe('menu', function() {
it('does not display if file is not selected', function() {
renderWithContext(
<FileTreeitemInner id="123abc" name="bar.tex" isSelected={false} />,
{}
)
expect(screen.queryByRole('menu', { visible: false })).to.not.exist
})
})
describe('context menu', function() {
it('does not display without write permissions', function() {
renderWithContext(
<FileTreeitemInner id="123abc" name="bar.tex" isSelected />,
{ contextProps: { hasWritePermissions: false } }
)
expect(screen.queryByRole('menu', { visible: false })).to.not.exist
})
it('open / close', function() {
const { container } = renderWithContext(
<FileTreeitemInner id="123abc" name="bar.tex" isSelected />
)
const entityElement = container.querySelector('div.entity')
screen.getByRole('menu', { visible: false })
fireEvent.contextMenu(entityElement)
screen.getByRole('menu', { visible: true })
fireEvent.contextMenu(entityElement)
screen.getByRole('menu', { visible: false })
})
})
describe('name', function() {
it('renders name', function() {
renderWithContext(
<FileTreeitemInner id="123abc" name="bar.tex" isSelected />
)
screen.getByRole('button', { name: 'bar.tex' })
expect(screen.queryByRole('textbox')).to.not.exist
})
it('starts rename on menu item click', function() {
renderWithContext(
<FileTreeitemInner id="123abc" name="bar.tex" isSelected />,
{ contextProps: { initialSelectedEntityId: '123abc' } }
)
const renameButton = screen.getByRole('menuitem', { name: 'Rename' })
fireEvent.click(renameButton)
expect(screen.queryByRole('button', { name: 'bar.tex' })).to.not.exist
screen.getByRole('textbox')
})
})
})
@@ -0,0 +1,45 @@
import React from 'react'
import sinon from 'sinon'
import { screen, fireEvent } from '@testing-library/react'
import renderWithContext from '../../helpers/render-with-context'
import FileTreeitemMenu from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu'
describe('<FileTreeitemMenu />', function() {
const setContextMenuCoords = sinon.stub()
afterEach(function() {
setContextMenuCoords.reset()
})
it('renders dropdown', function() {
renderWithContext(
<FileTreeitemMenu
id="123abc"
setContextMenuCoords={setContextMenuCoords}
/>
)
screen.getByRole('button', { name: 'Menu' })
screen.getByRole('menu')
})
it('open / close', function() {
renderWithContext(
<FileTreeitemMenu
id="123abc"
setContextMenuCoords={setContextMenuCoords}
/>
)
const toggleButton = screen.getByRole('button', { name: 'Menu' })
screen.getByRole('menu', { visible: false })
fireEvent.click(toggleButton)
screen.getByRole('menu', { visible: true })
fireEvent.click(toggleButton)
screen.getByRole('menu', { visible: false })
})
})
@@ -0,0 +1,80 @@
import { expect } from 'chai'
import React from 'react'
import sinon from 'sinon'
import { screen, fireEvent } from '@testing-library/react'
import renderWithContext from '../../helpers/render-with-context'
import FileTreeItemName from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name'
describe('<FileTreeItemName />', function() {
beforeEach(function() {
global.requestAnimationFrame = sinon.stub()
})
afterEach(function() {
delete global.requestAnimationFrame
})
it('renders name as button', function() {
renderWithContext(<FileTreeItemName name="foo.tex" isSelected />)
screen.getByRole('button', { name: 'foo.tex' })
expect(screen.queryByRole('textbox')).to.not.exist
})
it("doesn't start renaming on unselected component", function() {
renderWithContext(<FileTreeItemName name="foo.tex" isSelected={false} />)
const button = screen.queryByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.doubleClick(button)
expect(screen.queryByRole('textbox')).to.not.exist
})
it('start renaming on double-click', function() {
renderWithContext(<FileTreeItemName name="foo.tex" isSelected />)
const button = screen.queryByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.doubleClick(button)
screen.getByRole('textbox')
expect(screen.queryByRole('button')).to.not.exist
expect(global.requestAnimationFrame).to.be.calledOnce
})
it('cannot start renaming in read-only', function() {
renderWithContext(<FileTreeItemName name="foo.tex" isSelected />, {
contextProps: { hasWritePermissions: false }
})
const button = screen.queryByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.doubleClick(button)
expect(screen.queryByRole('textbox')).to.not.exist
})
describe('stop renaming', function() {
beforeEach(function() {
renderWithContext(<FileTreeItemName name="foo.tex" isSelected />)
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.doubleClick(button)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'bar.tex' } })
})
it('on Escape', function() {
const input = screen.getByRole('textbox')
fireEvent.keyDown(input, { key: 'Escape' })
screen.getByRole('button', { name: 'foo.tex' })
})
})
})
@@ -0,0 +1,123 @@
import React from 'react'
import sinon from 'sinon'
import { screen, render, fireEvent } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
describe('<FileTreeRoot/>', function() {
const onSelect = sinon.stub()
const onInit = sinon.stub()
beforeEach(function() {
global.requestAnimationFrame = sinon.stub()
})
afterEach(function() {
delete global.requestAnimationFrame
fetchMock.restore()
onSelect.reset()
onInit.reset()
})
it('renders', function() {
const rootFolder = [
{
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId={'123abc'}
hasWritePermissions={false}
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
/>
)
screen.queryByRole('tree')
screen.getByRole('treeitem')
screen.getByRole('treeitem', { name: 'main.tex', selected: true })
})
it('fire onSelect', function() {
const rootFolder = [
{
docs: [
{ _id: '456def', name: 'main.tex' },
{ _id: '789ghi', name: 'other.tex' }
],
folders: [],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId="123abc"
rootDocId="456def"
hasWritePermissions={false}
onSelect={onSelect}
onInit={onInit}
/>
)
sinon.assert.calledOnce(onSelect)
sinon.assert.calledWithMatch(onSelect, [
sinon.match({
entity: {
_id: '456def',
name: 'main.tex'
}
})
])
onSelect.reset()
screen.queryByRole('tree')
const treeitem = screen.getByRole('treeitem', { name: 'other.tex' })
fireEvent.click(treeitem)
sinon.assert.calledOnce(onSelect)
sinon.assert.calledWithMatch(onSelect, [
sinon.match({
entity: {
_id: '789ghi',
name: 'other.tex'
}
})
])
})
it('listen to editor.openDoc', function() {
const rootFolder = [
{
docs: [
{ _id: '456def', name: 'main.tex' },
{ _id: '789ghi', name: 'other.tex' }
],
folders: [],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId="123abc"
rootDocId="456def"
hasWritePermissions={false}
onSelect={onSelect}
onInit={onInit}
/>
)
screen.getByRole('treeitem', { name: 'main.tex', selected: true })
window.dispatchEvent(
new CustomEvent('editor.openDoc', { detail: '789ghi' })
)
screen.getByRole('treeitem', { name: 'main.tex', selected: false })
screen.getByRole('treeitem', { name: 'other.tex', selected: true })
})
})
@@ -0,0 +1,35 @@
import { expect } from 'chai'
import React from 'react'
import { screen } from '@testing-library/react'
import renderWithContext from '../helpers/render-with-context'
import FileTreeToolbar from '../../../../../frontend/js/features/file-tree/components/file-tree-toolbar'
describe('<FileTreeToolbar/>', function() {
it('without selected files', function() {
renderWithContext(<FileTreeToolbar />)
expect(screen.queryByRole('button', { name: 'New File' })).to.not.exist
expect(screen.queryByRole('button', { name: 'New Folder' })).to.not.exist
expect(screen.queryByRole('button', { name: 'Upload' })).to.not.exist
expect(screen.queryByRole('button', { name: 'Rename' })).to.not.exist
expect(screen.queryByRole('button', { name: 'Delete' })).to.not.exist
})
it('read-only', function() {
renderWithContext(<FileTreeToolbar />, {
contextProps: { hasWritePermissions: false }
})
expect(screen.queryByRole('button')).to.not.exist
})
it('with one selected file', function() {
renderWithContext(<FileTreeToolbar />, {
contextProps: { initialSelectedEntityId: '123abc' }
})
screen.getByRole('button', { name: 'Rename' })
screen.getByRole('button', { name: 'Delete' })
})
})
@@ -0,0 +1,65 @@
import { expect } from 'chai'
import React from 'react'
import sinon from 'sinon'
import { screen, render, fireEvent } from '@testing-library/react'
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
describe('FileTree Context Menu Flow', function() {
const onSelect = sinon.stub()
const onInit = sinon.stub()
it('opens on contextMenu event', async function() {
const rootFolder = [
{
_id: 'root-folder-id',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
/>
)
const treeitem = screen.getByRole('button', { name: 'main.tex' })
expect(screen.getAllByRole('menu').length).to.equal(1) // toolbar
fireEvent.contextMenu(treeitem)
expect(screen.getAllByRole('menu').length).to.equal(2) // toolbar + menu
})
it("doesn't open in read only mode", async function() {
const rootFolder = [
{
_id: 'root-folder-id',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions={false}
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
/>
)
const treeitem = screen.getByRole('button', { name: 'main.tex' })
fireEvent.contextMenu(treeitem)
expect(screen.queryByRole('menu')).to.not.exist
})
})
@@ -0,0 +1,231 @@
import { expect } from 'chai'
import React from 'react'
import sinon from 'sinon'
import { screen, render, fireEvent } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import MockedSocket from 'socket.io-mock'
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
describe('FileTree Create Folder Flow', function() {
const onSelect = sinon.stub()
const onInit = sinon.stub()
beforeEach(function() {
global.requestAnimationFrame = sinon.stub()
window._ide = {
socket: new MockedSocket()
}
})
afterEach(function() {
delete global.requestAnimationFrame
fetchMock.restore()
onSelect.reset()
onInit.reset()
delete window._ide
})
it('add to root', async function() {
const rootFolder = [
{
_id: 'root-folder-id',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
/>
)
const newFolderName = 'Foo Bar In Root'
const matcher = /\/project\/\w+\/folder/
const response = {
folders: [],
fileRefs: [],
docs: [],
_id: fakeId(),
name: newFolderName
}
fetchMock.post(matcher, response)
fireCreateFolder(newFolderName)
const lastCallBody = JSON.parse(fetchMock.lastCall(matcher)[1].body)
expect(lastCallBody.name).to.equal(newFolderName)
expect(lastCallBody.parent_folder_id).to.equal('root-folder-id')
window._ide.socket.socketClient.emit('reciveNewFolder', 'root-folder-id', {
_id: fakeId(),
name: newFolderName,
docs: [],
fileRefs: [],
folders: []
})
await screen.findByRole('treeitem', { name: newFolderName })
})
it('add to folder from folder', async function() {
const rootFolder = [
{
_id: 'root-folder-id',
docs: [],
folders: [
{
_id: '789ghi',
name: 'thefolder',
docs: [],
fileRefs: [],
folders: []
}
],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
rootDocId="789ghi"
onSelect={onSelect}
onInit={onInit}
/>
)
const expandButton = screen.getByRole('button', { name: 'Expand' })
fireEvent.click(expandButton)
const newFolderName = 'Foo Bar In thefolder'
const matcher = /\/project\/\w+\/folder/
const response = {
folders: [],
fileRefs: [],
docs: [],
_id: fakeId(),
name: newFolderName
}
fetchMock.post(matcher, response)
fireCreateFolder(newFolderName)
const lastCallBody = JSON.parse(fetchMock.lastCall(matcher)[1].body)
expect(lastCallBody.name).to.equal(newFolderName)
expect(lastCallBody.parent_folder_id).to.equal('789ghi')
window._ide.socket.socketClient.emit('reciveNewFolder', '789ghi', {
_id: fakeId(),
name: newFolderName,
docs: [],
fileRefs: [],
folders: []
})
// find the created folder
await screen.findByRole('treeitem', { name: newFolderName })
// collapse the parent folder; created folder should not be rendered anymore
fireEvent.click(expandButton)
expect(screen.queryByRole('treeitem', { name: newFolderName })).to.not.exist
})
it('add to folder from child', async function() {
const rootFolder = [
{
_id: 'root-folder-id',
docs: [],
folders: [
{
_id: '789ghi',
name: 'thefolder',
docs: [],
fileRefs: [{ _id: '456def', name: 'sub.tex' }],
folders: []
}
],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId="123abc"
hasWritePermissions
rootDocId="456def"
onSelect={onSelect}
onInit={onInit}
/>
)
const expandButton = screen.getByRole('button', { name: 'Expand' })
fireEvent.click(expandButton)
const newFolderName = 'Foo Bar In thefolder'
const matcher = /\/project\/\w+\/folder/
const response = {
folders: [],
fileRefs: [],
docs: [],
_id: fakeId(),
name: newFolderName
}
fetchMock.post(matcher, response)
fireCreateFolder(newFolderName)
const lastCallBody = JSON.parse(fetchMock.lastCall(matcher)[1].body)
expect(lastCallBody.name).to.equal(newFolderName)
expect(lastCallBody.parent_folder_id).to.equal('789ghi')
window._ide.socket.socketClient.emit('reciveNewFolder', '789ghi', {
_id: fakeId(),
name: newFolderName,
docs: [],
fileRefs: [],
folders: []
})
// find the created folder
await screen.findByRole('treeitem', { name: newFolderName })
// collapse the parent folder; created folder should not be rendered anymore
fireEvent.click(expandButton)
expect(screen.queryByRole('treeitem', { name: newFolderName })).to.not.exist
})
function fireCreateFolder(name) {
const createFolderButton = screen.getByRole('button', {
name: 'New Folder'
})
fireEvent.click(createFolderButton)
const input = screen.getByRole('textbox', {
hidden: true // FIXME: modal should not be hidden but it has the aria-hidden label due to a react-bootstrap bug
})
fireEvent.change(input, { target: { value: name } })
const modalCreateButton = getModalCreateButton()
fireEvent.click(modalCreateButton)
}
function fakeId() {
return Math.random()
.toString(16)
.replace(/0\./, 'random-test-id-')
}
function getModalCreateButton() {
return screen.getAllByRole('button', {
name: 'Create',
hidden: true // FIXME: modal should not be hidden but it has the aria-hidden label due to a react-bootstrap bug
})[0] // the first matched button is the toolbar button
}
})
@@ -0,0 +1,188 @@
import { expect } from 'chai'
import React from 'react'
import sinon from 'sinon'
import { screen, render, fireEvent, waitFor } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import MockedSocket from 'socket.io-mock'
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
describe('FileTree Delete Entity Flow', function() {
const onSelect = sinon.stub()
const onInit = sinon.stub()
beforeEach(function() {
window._ide = {
socket: new MockedSocket()
}
})
afterEach(function() {
fetchMock.restore()
onSelect.reset()
onInit.reset()
delete window._ide
})
describe('single entity', function() {
beforeEach(function() {
const rootFolder = [
{
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId={'123abc'}
hasWritePermissions
onSelect={onSelect}
onInit={onInit}
/>
)
const treeitem = screen.getByRole('treeitem', { name: 'main.tex' })
fireEvent.click(treeitem)
const deleteButton = screen.getByRole('menuitem', { name: 'Delete' })
fireEvent.click(deleteButton)
})
it('removes item', async function() {
const fetchMatcher = /\/project\/\w+\/doc\/\w+/
fetchMock.delete(fetchMatcher, 204)
const modalDeleteButton = getModalDeleteButton()
fireEvent.click(modalDeleteButton)
window._ide.socket.socketClient.emit('removeEntity', '456def')
await waitFor(() => {
expect(
screen.queryByRole('treeitem', {
name: 'main.tex',
hidden: true // treeitem might be hidden behind the modal
})
).to.not.exist
expect(
screen.queryByRole('treeitem', {
name: 'main.tex'
})
).to.not.exist
})
const [lastFetchPath] = fetchMock.lastCall(fetchMatcher)
expect(lastFetchPath).to.equal('/project/123abc/doc/456def')
})
it('continues delete on 404s', async function() {
fetchMock.delete(/\/project\/\w+\/doc\/\w+/, 404)
const modalDeleteButton = getModalDeleteButton()
fireEvent.click(modalDeleteButton)
window._ide.socket.socketClient.emit('removeEntity', '456def')
await waitFor(() => {
expect(
screen.queryByRole('treeitem', {
name: 'main.tex',
hidden: true // treeitem might be hidden behind the modal
})
).to.not.exist
expect(
screen.queryByRole('treeitem', {
name: 'main.tex'
})
).to.not.exist
})
})
it('aborts delete on error', async function() {
const fetchMatcher = /\/project\/\w+\/doc\/\w+/
fetchMock.delete(fetchMatcher, 500)
const modalDeleteButton = getModalDeleteButton()
fireEvent.click(modalDeleteButton)
// The modal should still be open, but the file should not be deleted
await screen.findByRole('treeitem', { name: 'main.tex', hidden: true })
})
})
describe('multiple entities', function() {
beforeEach(function() {
const rootFolder = [
{
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: [{ _id: '789ghi', name: 'my.bib' }]
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId={'123abc'}
hasWritePermissions
onSelect={onSelect}
onInit={onInit}
/>
)
const treeitemDoc = screen.getByRole('treeitem', { name: 'main.tex' })
fireEvent.click(treeitemDoc)
const treeitemFile = screen.getByRole('treeitem', { name: 'my.bib' })
fireEvent.click(treeitemFile, { ctrlKey: true })
const deleteButton = screen.getAllByRole('menuitem', {
name: 'Delete'
})[0]
fireEvent.click(deleteButton)
})
it('removes all items', async function() {
const fetchMatcher = /\/project\/\w+\/(doc|file)\/\w+/
fetchMock.delete(fetchMatcher, 204)
const modalDeleteButton = getModalDeleteButton()
fireEvent.click(modalDeleteButton)
window._ide.socket.socketClient.emit('removeEntity', '456def')
window._ide.socket.socketClient.emit('removeEntity', '789ghi')
await waitFor(() => {
for (const name of ['main.tex', 'my.bib']) {
expect(
screen.queryByRole('treeitem', {
name,
hidden: true // treeitem might be hidden behind the modal
})
).to.not.exist
expect(
screen.queryByRole('treeitem', {
name
})
).to.not.exist
}
})
const [firstFetchPath, secondFetchPath] = fetchMock
.calls()
.map(([url]) => url)
expect(firstFetchPath).to.equal('/project/123abc/doc/456def')
expect(secondFetchPath).to.equal('/project/123abc/file/789ghi')
})
})
function getModalDeleteButton() {
return screen.getAllByRole('button', {
name: 'Delete',
hidden: true // FIXME: modal should not be hidden but it has the aria-hidden label due to a react-bootstrap bug
})[1] // the first matched button is the toolbar button
}
})
@@ -0,0 +1,140 @@
import { expect } from 'chai'
import React from 'react'
import sinon from 'sinon'
import { screen, render, fireEvent } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import MockedSocket from 'socket.io-mock'
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
describe('FileTree Rename Entity Flow', function() {
const onSelect = sinon.stub()
const onInit = sinon.stub()
beforeEach(function() {
window._ide = {
socket: new MockedSocket()
}
global.requestAnimationFrame = sinon.stub()
})
afterEach(function() {
delete global.requestAnimationFrame
fetchMock.restore()
onSelect.reset()
onInit.reset()
delete window._ide
})
beforeEach(function() {
const rootFolder = [
{
docs: [{ _id: '456def', name: 'a.tex' }],
folders: [
{
_id: '987jkl',
name: 'folder',
docs: [],
fileRefs: [{ _id: '789ghi', name: 'c.tex' }],
folders: []
}
],
fileRefs: []
}
]
render(
<FileTreeRoot
rootFolder={rootFolder}
projectId={'123abc'}
hasWritePermissions
onSelect={onSelect}
onInit={onInit}
/>
)
})
it('renames doc', function() {
const fetchMatcher = /\/project\/\w+\/doc\/\w+\/rename/
fetchMock.post(fetchMatcher, 204)
const input = initItemRename('a.tex')
fireEvent.change(input, { target: { value: 'b.tex' } })
fireEvent.keyDown(input, { key: 'Enter' })
screen.getByRole('treeitem', { name: 'b.tex' })
const lastFetchBody = getLastFetchBody(fetchMatcher)
expect(lastFetchBody.name).to.equal('b.tex')
})
it('renames folder', function() {
const fetchMatcher = /\/project\/\w+\/folder\/\w+\/rename/
fetchMock.post(fetchMatcher, 204)
const input = initItemRename('folder')
fireEvent.change(input, { target: { value: 'new folder name' } })
fireEvent.keyDown(input, { key: 'Enter' })
screen.getByRole('treeitem', { name: 'new folder name' })
const lastFetchBody = getLastFetchBody(fetchMatcher)
expect(lastFetchBody.name).to.equal('new folder name')
})
it('renames file in subfolder', function() {
const fetchMatcher = /\/project\/\w+\/file\/\w+\/rename/
fetchMock.post(fetchMatcher, 204)
const expandButton = screen.getByRole('button', { name: 'Expand' })
fireEvent.click(expandButton)
const input = initItemRename('c.tex')
fireEvent.change(input, { target: { value: 'd.tex' } })
fireEvent.keyDown(input, { key: 'Enter' })
screen.getByRole('treeitem', { name: 'folder' })
screen.getByRole('treeitem', { name: 'd.tex' })
const lastFetchBody = getLastFetchBody(fetchMatcher)
expect(lastFetchBody.name).to.equal('d.tex')
})
it('reverts rename on error', async function() {
const fetchMatcher = /\/project\/\w+\/doc\/\w+\/rename/
fetchMock.post(fetchMatcher, 500)
const input = initItemRename('a.tex')
fireEvent.change(input, { target: { value: 'b.tex' } })
fireEvent.keyDown(input, { key: 'Enter' })
screen.getByRole('treeitem', { name: 'b.tex' })
})
describe('via socket event', function() {
it('renames doc', function() {
screen.getByRole('treeitem', { name: 'a.tex' })
window._ide.socket.socketClient.emit(
'reciveEntityRename',
'456def',
'socket.tex'
)
screen.getByRole('treeitem', { name: 'socket.tex' })
})
})
function initItemRename(treeitemName) {
const treeitem = screen.getByRole('treeitem', { name: treeitemName })
fireEvent.click(treeitem)
const renameButton = screen.getByRole('menuitem', { name: 'Rename' })
fireEvent.click(renameButton)
return screen.getByRole('textbox')
}
function getLastFetchBody(matcher) {
const [, { body }] = fetchMock.lastCall(matcher)
return JSON.parse(body)
}
})
@@ -0,0 +1,18 @@
import React from 'react'
import { render } from '@testing-library/react'
import FileTreeContext from '../../../../../frontend/js/features/file-tree/components/file-tree-context'
export default (children, options = {}) => {
let { contextProps = {}, ...renderOptions } = options
contextProps = {
projectId: '123abc',
rootFolder: [{}],
hasWritePermissions: true,
onSelect: () => {},
...contextProps
}
return render(
<FileTreeContext {...contextProps}>{children}</FileTreeContext>,
renderOptions
)
}
@@ -0,0 +1,21 @@
import { expect } from 'chai'
import iconTypeFromName from '../../../../../frontend/js/features/file-tree/util/icon-type-from-name'
describe('iconTypeFromName', function() {
it('returns correct icon type', function() {
expect(iconTypeFromName('main.tex')).to.equal('file')
expect(iconTypeFromName('main.png')).to.equal('image')
expect(iconTypeFromName('main.csv')).to.equal('table')
expect(iconTypeFromName('main.py')).to.equal('file-text')
expect(iconTypeFromName('main.bib')).to.equal('book')
})
it('handles missing extensions', function() {
expect(iconTypeFromName('main')).to.equal('file')
})
it('lowercases extension', function() {
expect(iconTypeFromName('ZOTERO.BIB')).to.equal('book')
})
})
@@ -43,4 +43,16 @@ describe('<Icon />', function() {
)
expect(element).to.exist
})
it('renders children', function() {
const { container } = render(
<Icon type="angle-down">
<Icon type="angle-up" />
</Icon>
)
const element = container.querySelector(
'i.fa.fa-angle-down > i.fa.fa-angle-up'
)
expect(element).to.exist
})
})