Просмотр исходного кода

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
M Fahru 3 лет назад
Родитель
Сommit
b85ae6e58e

+ 1 - 0
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": "",

+ 40 - 0
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 (
+    <>
+      <LeftMenuButton
+        onClick={() => setShowModal(true)}
+        icon={{
+          type: 'copy',
+          fw: true,
+        }}
+      >
+        {t('copy_project')}
+      </LeftMenuButton>
+      <EditorCloneProjectModalWrapper
+        show={showModal}
+        handleHide={() => setShowModal(false)}
+        openProject={openProject}
+      />
+    </>
+  )
+}

+ 27 - 0
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 (
+    <>
+      <h4>{t('actions')}</h4>
+      <ul className="list-unstyled nav">
+        <li>
+          <ActionsCopyProject />
+        </li>
+        <li>
+          <ActionsWordCount />
+        </li>
+      </ul>
+    </>
+  )
+}

+ 53 - 0
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 ? (
+        <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)} />
+    </>
+  )
+}

+ 2 - 0
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 })}
       >
         <DownloadMenu />
+        <ActionsMenu />
       </aside>
       {leftMenuShown ? (
         // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions

+ 39 - 0
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<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>
+  )
+}

+ 57 - 0
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 (
+    <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>
+  )
+}

+ 27 - 0
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,

+ 18 - 0
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('<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')
+  })
+})

+ 85 - 0
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('<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)
+    })
+  })
+})

+ 60 - 0
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('<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)
+  })
+})