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_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": "",
|
||||
|
|
|
@ -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 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
|
||||
|
|
|
@ -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 {
|
||||
.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,
|
||||
|
|
|
@ -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