Merge pull request #3965 from overleaf/ae-file-tree-popup

Use custom overlay for file tree dropdown menu

GitOrigin-RevId: 261b21953f9331427d6d368716662d7eaec65477
This commit is contained in:
Shane Kilkelly 2021-04-29 09:16:42 +01:00 committed by Copybot
parent 6893cce6c9
commit f1f8c4e152
9 changed files with 80 additions and 32 deletions

View file

@ -1,9 +1,10 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { findDOMNode } from 'react-dom'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import withoutPropagation from '../../../../infrastructure/without-propagation' import withoutPropagation from '../../../../infrastructure/without-propagation'
import { Dropdown } from 'react-bootstrap' import { Dropdown, Overlay } from 'react-bootstrap'
import Icon from '../../../../shared/components/icon' import Icon from '../../../../shared/components/icon'
import FileTreeItemMenuItems from './file-tree-item-menu-items' import FileTreeItemMenuItems from './file-tree-item-menu-items'
@ -12,6 +13,7 @@ function FileTreeItemMenu({ id }) {
const { t } = useTranslation() const { t } = useTranslation()
const [dropdownOpen, setDropdownOpen] = useState(false) const [dropdownOpen, setDropdownOpen] = useState(false)
const [dropdownTarget, setDropdownTarget] = useState()
function handleToggle(wantOpen) { function handleToggle(wantOpen) {
setDropdownOpen(wantOpen) setDropdownOpen(wantOpen)
@ -21,6 +23,13 @@ function FileTreeItemMenu({ id }) {
handleToggle(false) handleToggle(false)
} }
const toggleRef = component => {
if (component) {
// eslint-disable-next-line react/no-find-dom-node
setDropdownTarget(findDOMNode(component))
}
}
return ( return (
<Dropdown <Dropdown
onClick={withoutPropagation(handleClick)} onClick={withoutPropagation(handleClick)}
@ -33,12 +42,18 @@ function FileTreeItemMenu({ id }) {
noCaret noCaret
className="dropdown-toggle-no-background entity-menu-toggle" className="dropdown-toggle-no-background entity-menu-toggle"
onClick={withoutPropagation()} onClick={withoutPropagation()}
ref={toggleRef}
> >
<Icon type="ellipsis-v" accessibilityLabel={t('menu')} /> <Icon type="ellipsis-v" accessibilityLabel={t('menu')} />
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu> <Overlay
<FileTreeItemMenuItems /> bsRole="menu"
</Dropdown.Menu> show={dropdownOpen}
target={dropdownTarget}
container={document.body}
>
<Menu dropdownId={`dropdown-${id}`} />
</Overlay>
</Dropdown> </Dropdown>
) )
} }
@ -47,4 +62,20 @@ FileTreeItemMenu.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
} }
function Menu({ dropdownId, style, className }) {
return (
<div className={`dropdown open ${className}`} style={style}>
<ul className="dropdown-menu" role="menu" aria-labelledby={dropdownId}>
<FileTreeItemMenuItems />
</ul>
</div>
)
}
Menu.propTypes = {
dropdownId: PropTypes.string.isRequired,
style: PropTypes.object,
className: PropTypes.string,
}
export default FileTreeItemMenu export default FileTreeItemMenu

View file

@ -35,11 +35,6 @@ describe('<FileTreeDoc/>', function () {
fireEvent.click(treeitem) fireEvent.click(treeitem)
screen.getByRole('treeitem', { selected: true }) 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 () { it('renders as linked file', function () {

View file

@ -50,13 +50,6 @@ describe('<FileTreeFolder/>', function () {
const treeitem = screen.getByRole('treeitem', { selected: false }) const treeitem = screen.getByRole('treeitem', { selected: false })
fireEvent.click(treeitem) 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 }) screen.getByRole('treeitem', { selected: true })
expect(screen.queryByRole('tree')).to.not.exist expect(screen.queryByRole('tree')).to.not.exist
}) })

View file

@ -5,6 +5,7 @@ import { screen, fireEvent } from '@testing-library/react'
import renderWithContext from '../../helpers/render-with-context' import renderWithContext from '../../helpers/render-with-context'
import FileTreeitemInner from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner' 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 () { describe('<FileTreeitemInner />', function () {
const setContextMenuCoords = sinon.stub() const setContextMenuCoords = sinon.stub()
@ -41,18 +42,22 @@ describe('<FileTreeitemInner />', function () {
it('open / close', function () { it('open / close', function () {
const { container } = renderWithContext( const { container } = renderWithContext(
<>
<FileTreeitemInner id="123abc" name="bar.tex" isSelected /> <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') const entityElement = container.querySelector('div.entity')
screen.getByRole('menu', { visible: false })
fireEvent.contextMenu(entityElement) fireEvent.contextMenu(entityElement)
screen.getByRole('menu', { visible: true }) screen.getByRole('menu', { visible: true })
fireEvent.contextMenu(entityElement) // close the context menu
screen.getByRole('menu', { visible: false }) fireEvent.click(entityElement)
expect(screen.queryByRole('menu')).to.be.null
}) })
}) })
@ -83,7 +88,8 @@ describe('<FileTreeitemInner />', function () {
}, },
} }
) )
const toggleButton = screen.getByRole('button', { name: 'Menu' })
fireEvent.click(toggleButton)
const renameButton = screen.getByRole('menuitem', { name: 'Rename' }) const renameButton = screen.getByRole('menuitem', { name: 'Rename' })
fireEvent.click(renameButton) fireEvent.click(renameButton)
expect(screen.queryByRole('button', { name: 'bar.tex' })).to.not.exist expect(screen.queryByRole('button', { name: 'bar.tex' })).to.not.exist

View file

@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import sinon from 'sinon' import sinon from 'sinon'
import { expect } from 'chai'
import { screen, fireEvent } from '@testing-library/react' import { screen, fireEvent } from '@testing-library/react'
import renderWithContext from '../../helpers/render-with-context' import renderWithContext from '../../helpers/render-with-context'
@ -20,7 +21,9 @@ describe('<FileTreeitemMenu />', function () {
/> />
) )
screen.getByRole('button', { name: 'Menu' }) const toggleButton = screen.getByRole('button', { name: 'Menu' })
fireEvent.click(toggleButton)
screen.getByRole('menu') screen.getByRole('menu')
}) })
@ -32,9 +35,9 @@ describe('<FileTreeitemMenu />', function () {
/> />
) )
const toggleButton = screen.getByRole('button', { name: 'Menu' }) expect(screen.queryByRole('menu')).to.be.null
screen.getByRole('menu', { visible: false }) const toggleButton = screen.getByRole('button', { name: 'Menu' })
fireEvent.click(toggleButton) fireEvent.click(toggleButton)
screen.getByRole('menu', { visible: true }) screen.getByRole('menu', { visible: true })

View file

@ -89,6 +89,8 @@ describe('<FileTreeRoot/>', function () {
// selected) This is needed to make sure the test fail. // selected) This is needed to make sure the test fail.
const treeitemFile = screen.getByRole('treeitem', { name: 'main.tex' }) const treeitemFile = screen.getByRole('treeitem', { name: 'main.tex' })
fireEvent.click(treeitemFile, { ctrlKey: true }) fireEvent.click(treeitemFile, { ctrlKey: true })
const toggleButton = screen.getByRole('button', { name: 'Menu' })
fireEvent.click(toggleButton)
const deleteButton = screen.getByRole('menuitem', { name: 'Delete' }) const deleteButton = screen.getByRole('menuitem', { name: 'Delete' })
fireEvent.click(deleteButton) fireEvent.click(deleteButton)
await waitFor(() => screen.getByRole('button', { name: 'Cancel' })) await waitFor(() => screen.getByRole('button', { name: 'Cancel' }))

View file

@ -36,11 +36,11 @@ describe('FileTree Context Menu Flow', function () {
) )
const treeitem = screen.getByRole('button', { name: 'main.tex' }) const treeitem = screen.getByRole('button', { name: 'main.tex' })
expect(screen.getAllByRole('menu').length).to.equal(1) // toolbar expect(screen.queryByRole('menu')).to.be.null
fireEvent.contextMenu(treeitem) fireEvent.contextMenu(treeitem)
expect(screen.getAllByRole('menu').length).to.equal(2) // toolbar + menu screen.getByRole('menu')
}) })
it("doesn't open in read only mode", async function () { it("doesn't open in read only mode", async function () {

View file

@ -53,6 +53,9 @@ describe('FileTree Delete Entity Flow', function () {
const treeitem = screen.getByRole('treeitem', { name: 'main.tex' }) const treeitem = screen.getByRole('treeitem', { name: 'main.tex' })
fireEvent.click(treeitem) fireEvent.click(treeitem)
const toggleButton = screen.getByRole('button', { name: 'Menu' })
fireEvent.click(toggleButton)
const deleteButton = screen.getByRole('menuitem', { name: 'Delete' }) const deleteButton = screen.getByRole('menuitem', { name: 'Delete' })
fireEvent.click(deleteButton) fireEvent.click(deleteButton)
}) })
@ -186,6 +189,8 @@ describe('FileTree Delete Entity Flow', function () {
// as a proxy to check that the child entity has been unselect we start // 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 // a delete and ensure the modal is displayed (the cancel button can be
// selected) This is needed to make sure the test fail. // 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' }) const deleteButton = screen.getByRole('menuitem', { name: 'Delete' })
fireEvent.click(deleteButton) fireEvent.click(deleteButton)
await waitFor(() => screen.getByRole('button', { name: 'Cancel' })) await waitFor(() => screen.getByRole('button', { name: 'Cancel' }))
@ -193,7 +198,7 @@ describe('FileTree Delete Entity Flow', function () {
}) })
describe('multiple entities', function () { describe('multiple entities', function () {
beforeEach(function () { beforeEach(async function () {
const rootFolder = [ const rootFolder = [
{ {
_id: 'root-folder-id', _id: 'root-folder-id',
@ -202,6 +207,7 @@ describe('FileTree Delete Entity Flow', function () {
fileRefs: [{ _id: '789ghi', name: 'my.bib' }], fileRefs: [{ _id: '789ghi', name: 'my.bib' }],
}, },
] ]
render( render(
<FileTreeRoot <FileTreeRoot
rootFolder={rootFolder} rootFolder={rootFolder}
@ -218,14 +224,23 @@ describe('FileTree Delete Entity Flow', function () {
/> />
) )
// select two files
const treeitemDoc = screen.getByRole('treeitem', { name: 'main.tex' }) const treeitemDoc = screen.getByRole('treeitem', { name: 'main.tex' })
fireEvent.click(treeitemDoc) fireEvent.click(treeitemDoc)
const treeitemFile = screen.getByRole('treeitem', { name: 'my.bib' }) const treeitemFile = screen.getByRole('treeitem', { name: 'my.bib' })
fireEvent.click(treeitemFile, { ctrlKey: true }) fireEvent.click(treeitemFile, { ctrlKey: true })
const deleteButton = screen.getAllByRole('menuitem', { // open the context menu
name: 'Delete', const treeitemButton = screen.getByRole('button', { name: 'my.bib' })
})[0] 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) fireEvent.click(deleteButton)
}) })

View file

@ -185,6 +185,9 @@ describe('FileTree Rename Entity Flow', function () {
const treeitem = screen.getByRole('treeitem', { name: treeitemName }) const treeitem = screen.getByRole('treeitem', { name: treeitemName })
fireEvent.click(treeitem) fireEvent.click(treeitem)
const toggleButton = screen.getByRole('button', { name: 'Menu' })
fireEvent.click(toggleButton)
const renameButton = screen.getByRole('menuitem', { name: 'Rename' }) const renameButton = screen.getByRole('menuitem', { name: 'Rename' })
fireEvent.click(renameButton) fireEvent.click(renameButton)