From b85ae6e58e7bf8d35b5209dab4a3aa68d57a6ac7 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Thu, 27 Oct 2022 12:19:50 -0400 Subject: [PATCH] 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 --- .../web/frontend/extracted-translations.json | 1 + .../components/actions-copy-project.tsx | 40 +++++++++ .../components/actions-menu.tsx | 27 ++++++ .../components/actions-word-count.tsx | 53 ++++++++++++ .../components/editor-left-menu.tsx | 2 + .../components/left-menu-button.tsx | 39 +++++++++ .../editor-left-menu/actions-menu.stories.tsx | 57 +++++++++++++ .../stylesheets/app/editor/left-menu.less | 27 ++++++ .../components/actions-copy-project.test.js | 18 ++++ .../components/actions-menu.test.js | 85 +++++++++++++++++++ .../components/actions-word-count.test.js | 60 +++++++++++++ 11 files changed, 409 insertions(+) create mode 100644 services/web/frontend/js/features/editor-left-menu/components/actions-copy-project.tsx create mode 100644 services/web/frontend/js/features/editor-left-menu/components/actions-menu.tsx create mode 100644 services/web/frontend/js/features/editor-left-menu/components/actions-word-count.tsx create mode 100644 services/web/frontend/js/features/editor-left-menu/components/left-menu-button.tsx create mode 100644 services/web/frontend/stories/editor-left-menu/actions-menu.stories.tsx create mode 100644 services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.js create mode 100644 services/web/test/frontend/features/editor-left-menu/components/actions-menu.test.js create mode 100644 services/web/test/frontend/features/editor-left-menu/components/actions-word-count.test.js diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index a1f90a936f..3cc1ba1ffe 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/editor-left-menu/components/actions-copy-project.tsx b/services/web/frontend/js/features/editor-left-menu/components/actions-copy-project.tsx new file mode 100644 index 0000000000..0bea62be06 --- /dev/null +++ b/services/web/frontend/js/features/editor-left-menu/components/actions-copy-project.tsx @@ -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 ( + <> + setShowModal(true)} + icon={{ + type: 'copy', + fw: true, + }} + > + {t('copy_project')} + + setShowModal(false)} + openProject={openProject} + /> + + ) +} diff --git a/services/web/frontend/js/features/editor-left-menu/components/actions-menu.tsx b/services/web/frontend/js/features/editor-left-menu/components/actions-menu.tsx new file mode 100644 index 0000000000..f6937ba2ff --- /dev/null +++ b/services/web/frontend/js/features/editor-left-menu/components/actions-menu.tsx @@ -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 ( + <> +

{t('actions')}

+ + + ) +} diff --git a/services/web/frontend/js/features/editor-left-menu/components/actions-word-count.tsx b/services/web/frontend/js/features/editor-left-menu/components/actions-word-count.tsx new file mode 100644 index 0000000000..de95bd49e5 --- /dev/null +++ b/services/web/frontend/js/features/editor-left-menu/components/actions-word-count.tsx @@ -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 ? ( + setShowModal(true)} + icon={{ + type: 'eye', + fw: true, + }} + > + {t('word_count')} + + ) : ( + + {/* OverlayTrigger won't fire unless the child is a non-react html element (e.g div, span) */} +
+ + {t('word_count')} + +
+
+ )} + setShowModal(false)} /> + + ) +} diff --git a/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu.tsx b/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu.tsx index 235a6a9205..1283ee9f11 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu.tsx @@ -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 })} > + {leftMenuShown ? ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions diff --git a/services/web/frontend/js/features/editor-left-menu/components/left-menu-button.tsx b/services/web/frontend/js/features/editor-left-menu/components/left-menu-button.tsx new file mode 100644 index 0000000000..7ae74989bc --- /dev/null +++ b/services/web/frontend/js/features/editor-left-menu/components/left-menu-button.tsx @@ -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) { + if (disabled) { + return ( +
+ + {children} + {disabledAccesibilityText ? ( + {disabledAccesibilityText} + ) : null} +
+ ) + } + + return ( + + ) +} diff --git a/services/web/frontend/stories/editor-left-menu/actions-menu.stories.tsx b/services/web/frontend/stories/editor-left-menu/actions-menu.stories.tsx new file mode 100644 index 0000000000..2511125d24 --- /dev/null +++ b/services/web/frontend/stories/editor-left-menu/actions-menu.stories.tsx @@ -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 ( +
+ +
+ ) +} + +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 ( +
+ +
+ ) +} diff --git a/services/web/frontend/stylesheets/app/editor/left-menu.less b/services/web/frontend/stylesheets/app/editor/left-menu.less index 7a6f607d0b..978c9223f8 100644 --- a/services/web/frontend/stylesheets/app/editor/left-menu.less +++ b/services/web/frontend/stylesheets/app/editor/left-menu.less @@ -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, diff --git a/services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.js b/services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.js new file mode 100644 index 0000000000..8154b4d0a4 --- /dev/null +++ b/services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.js @@ -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('', function () { + afterEach(function () { + fetchMock.reset() + }) + + it('shows correct modal when clicked', async function () { + renderWithEditorContext() + + fireEvent.click(screen.getByRole('button', { name: 'Copy Project' })) + + screen.getByPlaceholderText('New Project Name') + }) +}) diff --git a/services/web/test/frontend/features/editor-left-menu/components/actions-menu.test.js b/services/web/test/frontend/features/editor-left-menu/components/actions-menu.test.js new file mode 100644 index 0000000000..21781dc024 --- /dev/null +++ b/services/web/test/frontend/features/editor-left-menu/components/actions-menu.test.js @@ -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('', 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(, { + 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(, { + 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) + }) + }) +}) diff --git a/services/web/test/frontend/features/editor-left-menu/components/actions-word-count.test.js b/services/web/test/frontend/features/editor-left-menu/components/actions-word-count.test.js new file mode 100644 index 0000000000..dc79d50b46 --- /dev/null +++ b/services/web/test/frontend/features/editor-left-menu/components/actions-word-count.test.js @@ -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('', 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(, { + 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) + }) +})