mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Migrate actions menu in editor left menu to react (#10102)
* Migrate actions menu in editor left menu to react * Move margin from inline style to css file * remove focus selector to avoid "highlighting" the button after closing modal and regain focus * Add disabled state on word count button when the compiling is loading or failed * Use div instead of button for disabled word count button * Add accessibility text props when LeftMenuButton is disabled * Add actions menu test cases and storybook components * use util assign function and wrap function prop in usecallback GitOrigin-RevId: 81ab104be21fbcf5dfbc72c07d29eeb32976c61f
This commit is contained in:
parent
d98e32f38a
commit
b85ae6e58e
11 changed files with 409 additions and 0 deletions
|
@ -446,6 +446,7 @@
|
||||||
"please_check_your_inbox": "",
|
"please_check_your_inbox": "",
|
||||||
"please_check_your_inbox_to_confirm": "",
|
"please_check_your_inbox_to_confirm": "",
|
||||||
"please_compile_pdf_before_download": "",
|
"please_compile_pdf_before_download": "",
|
||||||
|
"please_compile_pdf_before_word_count": "",
|
||||||
"please_confirm_email": "",
|
"please_confirm_email": "",
|
||||||
"please_confirm_your_email_before_making_it_default": "",
|
"please_confirm_your_email_before_making_it_default": "",
|
||||||
"please_link_before_making_primary": "",
|
"please_link_before_making_primary": "",
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { assign } from '../../../shared/components/location'
|
||||||
|
import EditorCloneProjectModalWrapper from '../../clone-project-modal/components/editor-clone-project-modal-wrapper'
|
||||||
|
import LeftMenuButton from './left-menu-button'
|
||||||
|
|
||||||
|
type ProjectCopyResponse = {
|
||||||
|
project_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActionsCopyProject() {
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const openProject = useCallback(
|
||||||
|
({ project_id: projectId }: ProjectCopyResponse) => {
|
||||||
|
assign(`/project/${projectId}`)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LeftMenuButton
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
icon={{
|
||||||
|
type: 'copy',
|
||||||
|
fw: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('copy_project')}
|
||||||
|
</LeftMenuButton>
|
||||||
|
<EditorCloneProjectModalWrapper
|
||||||
|
show={showModal}
|
||||||
|
handleHide={() => setShowModal(false)}
|
||||||
|
openProject={openProject}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import getMeta from '../../../utils/meta'
|
||||||
|
import ActionsCopyProject from './actions-copy-project'
|
||||||
|
import ActionsWordCount from './actions-word-count'
|
||||||
|
|
||||||
|
export default function ActionsMenu() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const anonymous = getMeta('ol-anonymous') as boolean | undefined
|
||||||
|
|
||||||
|
if (anonymous === true || anonymous === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h4>{t('actions')}</h4>
|
||||||
|
<ul className="list-unstyled nav">
|
||||||
|
<li>
|
||||||
|
<ActionsCopyProject />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<ActionsWordCount />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Tooltip from '../../../shared/components/tooltip'
|
||||||
|
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
|
||||||
|
import WordCountModal from '../../word-count-modal/components/word-count-modal'
|
||||||
|
import LeftMenuButton from './left-menu-button'
|
||||||
|
|
||||||
|
export default function ActionsWordCount() {
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const { pdfUrl } = useCompileContext()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pdfUrl ? (
|
||||||
|
<LeftMenuButton
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
icon={{
|
||||||
|
type: 'eye',
|
||||||
|
fw: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('word_count')}
|
||||||
|
</LeftMenuButton>
|
||||||
|
) : (
|
||||||
|
<Tooltip
|
||||||
|
id="disabled-word-count"
|
||||||
|
description={t('please_compile_pdf_before_word_count')}
|
||||||
|
overlayProps={{
|
||||||
|
placement: 'top',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* OverlayTrigger won't fire unless the child is a non-react html element (e.g div, span) */}
|
||||||
|
<div>
|
||||||
|
<LeftMenuButton
|
||||||
|
icon={{
|
||||||
|
type: 'eye',
|
||||||
|
fw: true,
|
||||||
|
}}
|
||||||
|
disabled
|
||||||
|
disabledAccesibilityText={t(
|
||||||
|
'please_compile_pdf_before_word_count'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('word_count')}
|
||||||
|
</LeftMenuButton>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<WordCountModal show={showModal} handleHide={() => setShowModal(false)} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import DownloadMenu from './download-menu'
|
import DownloadMenu from './download-menu'
|
||||||
|
import ActionsMenu from './actions-menu'
|
||||||
import { useLayoutContext } from '../../../shared/context/layout-context'
|
import { useLayoutContext } from '../../../shared/context/layout-context'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
@ -12,6 +13,7 @@ export default function EditorLeftMenu() {
|
||||||
className={classNames('full-size', { shown: leftMenuShown })}
|
className={classNames('full-size', { shown: leftMenuShown })}
|
||||||
>
|
>
|
||||||
<DownloadMenu />
|
<DownloadMenu />
|
||||||
|
<ActionsMenu />
|
||||||
</aside>
|
</aside>
|
||||||
{leftMenuShown ? (
|
{leftMenuShown ? (
|
||||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { PropsWithChildren } from 'react'
|
||||||
|
import Icon from '../../../shared/components/icon'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClick?: () => void
|
||||||
|
icon: {
|
||||||
|
type: string
|
||||||
|
fw?: boolean
|
||||||
|
}
|
||||||
|
disabled?: boolean
|
||||||
|
disabledAccesibilityText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeftMenuButton({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
disabled = false,
|
||||||
|
disabledAccesibilityText,
|
||||||
|
}: PropsWithChildren<Props>) {
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<div className="left-menu-button link-disabled">
|
||||||
|
<Icon type={icon.type} fw={icon.fw} />
|
||||||
|
<span>{children}</span>
|
||||||
|
{disabledAccesibilityText ? (
|
||||||
|
<span className="sr-only">{disabledAccesibilityText}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className="left-menu-button">
|
||||||
|
<Icon type={icon.type} fw={icon.fw} />
|
||||||
|
<span>{children}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
import ActionsMenu from '../../js/features/editor-left-menu/components/actions-menu'
|
||||||
|
import { ScopeDecorator } from '../decorators/scope'
|
||||||
|
import { mockCompile, mockCompileError } from '../fixtures/compile'
|
||||||
|
import { document, mockDocument } from '../fixtures/document'
|
||||||
|
import useFetchMock from '../hooks/use-fetch-mock'
|
||||||
|
import { useScope } from '../hooks/use-scope'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Editor / Left Menu / Actions Menu',
|
||||||
|
component: ActionsMenu,
|
||||||
|
decorators: [
|
||||||
|
(Story: any) => ScopeDecorator(Story, { mockCompileOnLoad: false }),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotCompiled = () => {
|
||||||
|
window.metaAttributesCache.set('ol-anonymous', false)
|
||||||
|
|
||||||
|
useFetchMock(fetchMock => {
|
||||||
|
mockCompileError(fetchMock, 'failure')
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="left-menu" className="shown">
|
||||||
|
<ActionsMenu />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CompileSuccess = () => {
|
||||||
|
window.metaAttributesCache.set('ol-anonymous', false)
|
||||||
|
|
||||||
|
useScope({
|
||||||
|
editor: {
|
||||||
|
sharejs_doc: mockDocument(document.tex),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useFetchMock(fetchMock => {
|
||||||
|
mockCompile(fetchMock)
|
||||||
|
fetchMock.get('express:/project/:projectId/wordcount', {
|
||||||
|
texcount: {
|
||||||
|
encode: 'ascii',
|
||||||
|
textWords: 10,
|
||||||
|
headers: 11,
|
||||||
|
mathInline: 12,
|
||||||
|
mathDisplay: 13,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="left-menu" className="shown">
|
||||||
|
<ActionsMenu />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -32,6 +32,33 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.nav {
|
ul.nav {
|
||||||
|
.left-menu-button {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: (@line-height-computed / 4);
|
||||||
|
font-weight: 700;
|
||||||
|
color: @link-color;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
background-color: inherit;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
i {
|
||||||
|
margin-right: @margin-sm;
|
||||||
|
color: @gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active {
|
||||||
|
background-color: @link-color;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
&:hover,
|
&:hover,
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { fireEvent, screen } from '@testing-library/dom'
|
||||||
|
import fetchMock from 'fetch-mock'
|
||||||
|
import ActionsCopyProject from '../../../../../frontend/js/features/editor-left-menu/components/actions-copy-project'
|
||||||
|
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||||
|
|
||||||
|
describe('<ActionsCopyProject />', function () {
|
||||||
|
afterEach(function () {
|
||||||
|
fetchMock.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows correct modal when clicked', async function () {
|
||||||
|
renderWithEditorContext(<ActionsCopyProject />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Copy Project' }))
|
||||||
|
|
||||||
|
screen.getByPlaceholderText('New Project Name')
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { screen, waitFor } from '@testing-library/dom'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import fetchMock from 'fetch-mock'
|
||||||
|
import ActionsMenu from '../../../../../frontend/js/features/editor-left-menu/components/actions-menu'
|
||||||
|
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||||
|
|
||||||
|
describe('<ActionsMenu />', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
fetchMock.post('express:/project/:projectId/compile', {
|
||||||
|
status: 'success',
|
||||||
|
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
|
||||||
|
outputFiles: [
|
||||||
|
{
|
||||||
|
path: 'output.pdf',
|
||||||
|
build: 'build-123',
|
||||||
|
url: '/build/build-123/output.pdf',
|
||||||
|
type: 'pdf',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
fetchMock.reset()
|
||||||
|
window.metaAttributesCache = new Map()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows correct menu for non-anonymous users', async function () {
|
||||||
|
window.metaAttributesCache.set('ol-anonymous', false)
|
||||||
|
|
||||||
|
renderWithEditorContext(<ActionsMenu />, {
|
||||||
|
projectId: '123abc',
|
||||||
|
scope: {
|
||||||
|
editor: {
|
||||||
|
sharejs_doc: {
|
||||||
|
doc_id: 'test-doc',
|
||||||
|
getSnapshot: () => 'some doc content',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
screen.getByText('Actions')
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'Copy Project',
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'Word Count',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show anything for anonymous users', async function () {
|
||||||
|
window.metaAttributesCache.set('ol-anonymous', true)
|
||||||
|
|
||||||
|
renderWithEditorContext(<ActionsMenu />, {
|
||||||
|
projectId: '123abc',
|
||||||
|
scope: {
|
||||||
|
editor: {
|
||||||
|
sharejs_doc: {
|
||||||
|
doc_id: 'test-doc',
|
||||||
|
getSnapshot: () => 'some doc content',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByText('Actions')).to.equal(null)
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', {
|
||||||
|
name: 'Copy Project',
|
||||||
|
})
|
||||||
|
).to.equal(null)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', {
|
||||||
|
name: 'Word Count',
|
||||||
|
})
|
||||||
|
).to.equal(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/dom'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import fetchMock from 'fetch-mock'
|
||||||
|
import ActionsWordCount from '../../../../../frontend/js/features/editor-left-menu/components/actions-word-count'
|
||||||
|
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||||
|
|
||||||
|
describe('<ActionsWordCount />', function () {
|
||||||
|
afterEach(function () {
|
||||||
|
fetchMock.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows correct modal when clicked after document is compiled', async function () {
|
||||||
|
const compileEndpoint = 'express:/project/:projectId/compile'
|
||||||
|
const wordcountEndpoint = 'express:/project/:projectId/wordcount'
|
||||||
|
|
||||||
|
fetchMock.post(compileEndpoint, {
|
||||||
|
status: 'success',
|
||||||
|
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
|
||||||
|
outputFiles: [
|
||||||
|
{
|
||||||
|
path: 'output.pdf',
|
||||||
|
build: 'build-123',
|
||||||
|
url: '/build/build-123/output.pdf',
|
||||||
|
type: 'pdf',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
fetchMock.get(wordcountEndpoint, {
|
||||||
|
texcount: {
|
||||||
|
encode: 'ascii',
|
||||||
|
textWords: 0,
|
||||||
|
headers: 0,
|
||||||
|
mathInline: 0,
|
||||||
|
mathDisplay: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithEditorContext(<ActionsWordCount />, {
|
||||||
|
projectId: '123abc',
|
||||||
|
scope: {
|
||||||
|
editor: {
|
||||||
|
sharejs_doc: {
|
||||||
|
doc_id: 'test-doc',
|
||||||
|
getSnapshot: () => 'some doc content',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// when loading, we don't render the "Word Count" as button yet
|
||||||
|
expect(screen.queryByRole('button', { name: 'Word Count' })).to.equal(null)
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchMock.called(compileEndpoint)).to.be.true)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Word Count' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchMock.called(wordcountEndpoint)).to.be.true)
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in a new issue