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:
M Fahru 2022-10-27 12:19:50 -04:00 committed by Copybot
parent d98e32f38a
commit b85ae6e58e
11 changed files with 409 additions and 0 deletions

View file

@ -446,6 +446,7 @@
"please_check_your_inbox": "",
"please_check_your_inbox_to_confirm": "",
"please_compile_pdf_before_download": "",
"please_compile_pdf_before_word_count": "",
"please_confirm_email": "",
"please_confirm_your_email_before_making_it_default": "",
"please_link_before_making_primary": "",

View file

@ -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}
/>
</>
)
}

View file

@ -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>
</>
)
}

View file

@ -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)} />
</>
)
}

View file

@ -1,4 +1,5 @@
import DownloadMenu from './download-menu'
import ActionsMenu from './actions-menu'
import { useLayoutContext } from '../../../shared/context/layout-context'
import classNames from 'classnames'
@ -12,6 +13,7 @@ export default function EditorLeftMenu() {
className={classNames('full-size', { shown: leftMenuShown })}
>
<DownloadMenu />
<ActionsMenu />
</aside>
{leftMenuShown ? (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -32,6 +32,33 @@
}
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 {
cursor: pointer;
&:hover,

View file

@ -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')
})
})

View file

@ -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)
})
})
})

View file

@ -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)
})
})