) {
+ 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)
+ })
+})