From 6b28a4ee5a98da721213c55ee944d3f20d868c80 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 11 May 2026 12:30:04 +0100 Subject: [PATCH] Merge pull request #33560 from overleaf/mj-conversion-cleanup [clsi+web] Small cleanups and improvements to conversions / exports GitOrigin-RevId: 300adfbb91e89f754ee7f835db792ccb50b27613 --- .../test/unit/js/ConversionManager.test.js | 587 +++++++----------- .../web/frontend/extracted-translations.json | 3 + .../components/toolbar/download-project.tsx | 75 --- .../toolbar/export-document-toasts.tsx | 61 ++ .../export-project-with-conversion-button.tsx | 51 ++ .../components/toolbar/project-title.tsx | 22 +- .../ide-react/hooks/use-convert-project.ts | 4 + .../import-document-feedback-toast.tsx | 8 +- services/web/locales/en.json | 3 + 9 files changed, 381 insertions(+), 433 deletions(-) create mode 100644 services/web/frontend/js/features/ide-react/components/toolbar/export-project-with-conversion-button.tsx diff --git a/services/clsi/test/unit/js/ConversionManager.test.js b/services/clsi/test/unit/js/ConversionManager.test.js index c16669124f..bd01c6cae3 100644 --- a/services/clsi/test/unit/js/ConversionManager.test.js +++ b/services/clsi/test/unit/js/ConversionManager.test.js @@ -6,6 +6,79 @@ const MODULE_PATH = Path.join( '../../../app/js/ConversionManager' ) +const CONVERT_TO_LATEX_CASES = [ + { + type: 'docx', + inputFilename: 'input.docx', + pandocArgs: [ + 'pandoc', + 'input.docx', + '--output', + 'main.tex', + '--to', + 'latex', + '--standalone', + '--extract-media=.', + '--from', + 'docx+citations', + '--citeproc', + ], + }, + { + type: 'markdown', + inputFilename: 'input.md', + pandocArgs: [ + 'pandoc', + 'input.md', + '--output', + 'main.tex', + '--to', + 'latex', + '--standalone', + '--from', + 'markdown', + ], + }, +] + +const LATEX_TO_DOCUMENT_CASES = [ + { + type: 'docx', + extension: 'docx', + compressOutput: false, + pandocArgs: outputId => [ + 'pandoc', + 'main.tex', + '--output', + `${outputId}.docx`, + '--from', + 'latex', + '--to', + 'docx', + '--citeproc', + '--number-sections', + '--resource-path=.', + ], + }, + { + type: 'markdown', + extension: 'md', + compressOutput: true, + pandocArgs: outputId => [ + 'pandoc', + 'main.tex', + '--output', + Path.join(outputId, 'main.md'), + '--from', + 'latex', + '--to', + 'markdown', + '--resource-path=.', + `--extract-media=${outputId}`, + ], + }, +] + describe('ConversionManager', function () { beforeEach(async function (ctx) { ctx.CommandRunner = { @@ -65,12 +138,41 @@ describe('ConversionManager', function () { }) describe('convertToLaTeXWithLock', function () { - describe('with conversionType=docx', function () { + describe('per conversion type', function () { + CONVERT_TO_LATEX_CASES.forEach(({ type, inputFilename, pandocArgs }) => { + describe(`type=${type}`, function () { + beforeEach(async function (ctx) { + ctx.inputPath = `/path/to/${inputFilename}` + await ctx.ConversionManager.promises.convertToLaTeXWithLock( + ctx.conversionId, + ctx.inputPath, + type + ) + }) + + it('should copy the input file to the conversion directory under the type-specific filename', function (ctx) { + sinon.assert.calledWith( + ctx.fs.copyFile, + ctx.inputPath, + Path.join(ctx.conversionDir, inputFilename) + ) + }) + + it('should run pandoc with the type-specific args', function (ctx) { + expect(ctx.CommandRunner.promises.run.firstCall.args[1]).toEqual( + pandocArgs + ) + }) + }) + }) + }) + + describe('with conversionType=docx (representative)', function () { beforeEach(function (ctx) { ctx.inputPath = '/path/to/input.docx' }) - describe('file setup and pandoc args', function () { + describe('successful conversion', function () { beforeEach(async function (ctx) { ctx.result = await ctx.ConversionManager.promises.convertToLaTeXWithLock( @@ -80,78 +182,29 @@ describe('ConversionManager', function () { ) }) - it('should acquire a lock', async function (ctx) { + it('should acquire a lock on the conversion directory', function (ctx) { sinon.assert.calledWith(ctx.LockManager.acquire, ctx.conversionDir) }) - it('should copy the input file to the conversion directory with docx filename', async function (ctx) { + it('should create the conversion directory', function (ctx) { sinon.assert.calledWith(ctx.fs.mkdir, ctx.conversionDir, { recursive: true, }) - sinon.assert.calledWith( - ctx.fs.copyFile, - ctx.inputPath, - Path.join(ctx.conversionDir, 'input.docx') - ) }) - it('should convert conversion timeout to milliseconds', async function (ctx) { + it('should run pandoc then zip with the conversion timeout in milliseconds', function (ctx) { + expect(ctx.CommandRunner.promises.run.callCount).toBe(2) + expect(ctx.CommandRunner.promises.run.secondCall.args[1]).toEqual([ + 'zip', + '-r', + 'output-uuid.zip', + '.', + ]) expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000) expect(ctx.CommandRunner.promises.run.secondCall.args[4]).toBe(60_000) }) - it('should run pandoc with docx args followed by zip', function (ctx) { - expect(ctx.CommandRunner.promises.run.callCount).toBe(2) - expect(ctx.CommandRunner.promises.run.firstCall.args).toEqual([ - ctx.conversionId, - [ - 'pandoc', - 'input.docx', - '--output', - 'main.tex', - '--to', - 'latex', - '--standalone', - '--extract-media=.', - '--from', - 'docx+citations', - '--citeproc', - ], - ctx.conversionDir, - ctx.Settings.pandocImage, - 60_000, - {}, - 'conversions', - ]) - expect(ctx.CommandRunner.promises.run.secondCall.args).toEqual([ - ctx.conversionId, - ['zip', '-r', 'output-uuid.zip', '.'], - ctx.conversionDir, - ctx.Settings.pandocImage, - 60_000, - {}, - 'conversions', - ]) - }) - }) - - describe('successful conversion', function () { - beforeEach(async function (ctx) { - ctx.CommandRunner.promises.run.resolves({ - stdout: 'mock-stdout', - stderr: 'mock-stderr', - exitCode: 0, - }) - - ctx.result = - await ctx.ConversionManager.promises.convertToLaTeXWithLock( - ctx.conversionId, - ctx.inputPath, - 'docx' - ) - }) - - it('should remove the source document after conversion', async function (ctx) { + it('should remove the source document after conversion', function (ctx) { sinon.assert.calledWith( ctx.fs.unlink, Path.join(ctx.conversionDir, 'input.docx') @@ -167,14 +220,13 @@ describe('ConversionManager', function () { }) }) - describe('unsuccessful conversion (exitcode)', function () { + describe('unsuccessful conversion (pandoc exit code)', function () { beforeEach(async function (ctx) { ctx.CommandRunner.promises.run.resolves({ - stdout: 'mock-stdout', - stderr: 'mock-stderr', + stdout: '', + stderr: '', exitCode: 63, }) - await expect( ctx.ConversionManager.promises.convertToLaTeXWithLock( ctx.conversionId, @@ -184,7 +236,7 @@ describe('ConversionManager', function () { ).to.be.rejectedWith('pandoc conversion failed') }) - it('should remove the entire conversion directory', async function (ctx) { + it('should remove the entire conversion directory', function (ctx) { sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, { force: true, recursive: true, @@ -196,22 +248,13 @@ describe('ConversionManager', function () { }) }) - describe('unsuccessful compression (exitcode)', function () { + describe('unsuccessful compression (zip exit code)', function () { beforeEach(async function (ctx) { ctx.CommandRunner.promises.run .onFirstCall() - .resolves({ - stdout: 'mock-pandoc-stdout', - stderr: 'mock-pandoc-stderr', - exitCode: 0, - }) + .resolves({ stdout: '', stderr: '', exitCode: 0 }) .onSecondCall() - .resolves({ - stdout: 'mock-zip-stdout', - stderr: 'mock-zip-stderr', - exitCode: 12, - }) - + .resolves({ stdout: '', stderr: '', exitCode: 12 }) await expect( ctx.ConversionManager.promises.convertToLaTeXWithLock( ctx.conversionId, @@ -221,7 +264,7 @@ describe('ConversionManager', function () { ).to.be.rejectedWith('pandoc conversion failed') }) - it('should remove the entire conversion directory', async function (ctx) { + it('should remove the entire conversion directory', function (ctx) { sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, { force: true, recursive: true, @@ -247,7 +290,7 @@ describe('ConversionManager', function () { ).to.be.rejectedWith('pandoc conversion failed') }) - it('should remove the entire conversion directory', async function (ctx) { + it('should remove the entire conversion directory', function (ctx) { sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, { force: true, recursive: true, @@ -260,303 +303,153 @@ describe('ConversionManager', function () { }) }) - describe('with conversionType=markdown', function () { - beforeEach(function (ctx) { - ctx.inputPath = '/path/to/input.md' - }) - - describe('file setup and pandoc args', function () { - beforeEach(async function (ctx) { - ctx.result = - await ctx.ConversionManager.promises.convertToLaTeXWithLock( - ctx.conversionId, - ctx.inputPath, - 'markdown' - ) - }) - - it('should copy the input file to the conversion directory with md filename', async function (ctx) { - sinon.assert.calledWith(ctx.fs.mkdir, ctx.conversionDir, { - recursive: true, - }) - sinon.assert.calledWith( - ctx.fs.copyFile, - ctx.inputPath, - Path.join(ctx.conversionDir, 'input.md') - ) - }) - - it('should run pandoc with markdown args followed by zip', function (ctx) { - expect(ctx.CommandRunner.promises.run.callCount).toBe(2) - expect(ctx.CommandRunner.promises.run.firstCall.args).toEqual([ + describe('with an unsupported conversion type', function () { + it('should reject with an unsupported conversion type error', async function (ctx) { + await expect( + ctx.ConversionManager.promises.convertToLaTeXWithLock( ctx.conversionId, - [ - 'pandoc', - 'input.md', - '--output', - 'main.tex', - '--to', - 'latex', - '--standalone', - '--from', - 'markdown', - ], - ctx.conversionDir, - ctx.Settings.pandocImage, - 60_000, - {}, - 'conversions', - ]) - expect(ctx.CommandRunner.promises.run.secondCall.args).toEqual([ - ctx.conversionId, - ['zip', '-r', 'output-uuid.zip', '.'], - ctx.conversionDir, - ctx.Settings.pandocImage, - 60_000, - {}, - 'conversions', - ]) - }) - }) - - describe('successful conversion', function () { - beforeEach(async function (ctx) { - ctx.CommandRunner.promises.run.resolves({ - stdout: 'mock-stdout', - stderr: 'mock-stderr', - exitCode: 0, - }) - - ctx.result = - await ctx.ConversionManager.promises.convertToLaTeXWithLock( - ctx.conversionId, - ctx.inputPath, - 'markdown' - ) - }) - - it('should remove the source document after conversion', async function (ctx) { - sinon.assert.calledWith( - ctx.fs.unlink, - Path.join(ctx.conversionDir, 'input.md') + '/path/to/input.txt', + 'not-a-real-type' ) - }) - - it('should return the output zip path', function (ctx) { - expect(ctx.result).toBe(ctx.outputPath) - }) + ).to.be.rejectedWith('unsupported conversion type') }) }) }) describe('convertLaTeXToDocumentInDirWithLock', function () { - describe('successfully', function () { - beforeEach(async function (ctx) { - ctx.compileDir = '/compiles/test-compile-dir' - ctx.rootDocPath = 'main.tex' - ctx.type = 'docx' - ctx.extension = 'docx' + beforeEach(function (ctx) { + ctx.compileDir = '/compiles/test-compile-dir' + ctx.rootDocPath = 'main.tex' + }) - ctx.result = + describe('pandoc args per conversion type', function () { + LATEX_TO_DOCUMENT_CASES.forEach(({ type, pandocArgs }) => { + it(`should run pandoc with the type-specific args for type=${type}`, async function (ctx) { await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( ctx.conversionId, ctx.compileDir, ctx.rootDocPath, - ctx.type + type ) - }) - - it('should acquire a lock on the compile dir', function (ctx) { - sinon.assert.calledWith(ctx.LockManager.acquire, ctx.compileDir) - }) - - it('should release the lock', function (ctx) { - sinon.assert.called(ctx.lock.release) - }) - - it('should run pandoc with correct arguments', function (ctx) { - expect(ctx.CommandRunner.promises.run.callCount).toBe(1) - expect(ctx.CommandRunner.promises.run.firstCall.args).toEqual([ - ctx.conversionId, - [ - 'pandoc', - ctx.rootDocPath, - '--output', - `output-uuid.${ctx.extension}`, - '--from', - 'latex', - '--to', - ctx.type, - '--citeproc', - '--number-sections', - '--resource-path=.', - ], - ctx.compileDir, - ctx.Settings.pandocImage, - 60_000, - {}, - 'conversions', - ]) - }) - - it('should convert conversion timeout to milliseconds', function (ctx) { - expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000) - }) - - it('should return path to the output document', function (ctx) { - expect(ctx.result).toBe( - Path.join(ctx.compileDir, `output-uuid.${ctx.extension}`) - ) + expect(ctx.CommandRunner.promises.run.firstCall.args[1]).toEqual( + pandocArgs('output-uuid') + ) + }) }) }) - describe('when pandoc fails (non-zero exit code)', function () { - it('should reject with an error and release the lock', async function (ctx) { - ctx.compileDir = '/compiles/test-compile-dir' - - ctx.CommandRunner.promises.run.resolves({ - stdout: 'mock-stdout', - stderr: 'mock-stderr', - exitCode: 1, + describe('with type=docx (representative non-compressing type)', function () { + describe('successful conversion', function () { + beforeEach(async function (ctx) { + ctx.result = + await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( + ctx.conversionId, + ctx.compileDir, + ctx.rootDocPath, + 'docx' + ) }) - await expect( - ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( - ctx.conversionId, - ctx.compileDir, - 'main.tex', - 'docx' - ) - ).to.be.rejectedWith('pandoc latex-to-document conversion failed') - - sinon.assert.called(ctx.lock.release) - }) - }) - }) - - describe('convertLaTeXToDocumentInDirWithLock (type=markdown)', function () { - describe('successfully', function () { - beforeEach(async function (ctx) { - ctx.compileDir = '/compiles/test-compile-dir' - ctx.rootDocPath = 'main.tex' - - ctx.result = - await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( - ctx.conversionId, - ctx.compileDir, - ctx.rootDocPath, - 'markdown' - ) - }) - - it('should acquire a lock on the compile dir', function (ctx) { - sinon.assert.calledWith(ctx.LockManager.acquire, ctx.compileDir) - }) - - it('should release the lock', function (ctx) { - sinon.assert.called(ctx.lock.release) - }) - - it('should create a UUID-named subdirectory', function (ctx) { - sinon.assert.calledWith( - ctx.fs.mkdir, - Path.join(ctx.compileDir, 'output-uuid'), - { recursive: true } - ) - }) - - it('should run pandoc then zip (two commands total)', function (ctx) { - expect(ctx.CommandRunner.promises.run.callCount).toBe(2) - }) - - it('should run pandoc outputting main.md into the UUID-named subdir', function (ctx) { - expect(ctx.CommandRunner.promises.run.firstCall.args).toEqual([ - ctx.conversionId, - [ - 'pandoc', - ctx.rootDocPath, - '--output', - Path.join('output-uuid', 'main.md'), - '--from', - 'latex', - '--to', - 'markdown', - '--resource-path=.', - '--extract-media=output-uuid', - ], - ctx.compileDir, - ctx.Settings.pandocImage, - 60_000, - {}, - 'conversions', - ]) - }) - - it('should zip the project-named subdirectory', function (ctx) { - expect(ctx.CommandRunner.promises.run.secondCall.args).toEqual([ - ctx.conversionId, - ['sh', '-c', 'cd output-uuid && zip -r ../output-uuid.zip .'], - ctx.compileDir, - ctx.Settings.pandocImage, - 60_000, - {}, - 'conversions', - ]) - }) - - it('should return the path to the zip file', function (ctx) { - expect(ctx.result).toBe(Path.join(ctx.compileDir, 'output-uuid.zip')) - }) - - it('should convert conversion timeout to milliseconds', function (ctx) { - expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000) - expect(ctx.CommandRunner.promises.run.secondCall.args[4]).toBe(60_000) - }) - }) - - describe('when pandoc fails (non-zero exit code)', function () { - it('should reject with an error and release the lock', async function (ctx) { - ctx.compileDir = '/compiles/test-compile-dir' - - ctx.CommandRunner.promises.run.resolves({ - stdout: 'mock-stdout', - stderr: 'mock-stderr', - exitCode: 1, + it('should acquire a lock on the compile dir', function (ctx) { + sinon.assert.calledWith(ctx.LockManager.acquire, ctx.compileDir) }) - await expect( - ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( - ctx.conversionId, - ctx.compileDir, - 'main.tex', - 'markdown' - ) - ).to.be.rejectedWith('pandoc latex-to-document conversion failed') + it('should release the lock', function (ctx) { + sinon.assert.called(ctx.lock.release) + }) - sinon.assert.called(ctx.lock.release) + it('should pass the conversion timeout in milliseconds', function (ctx) { + expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000) + }) + + it('should not create a subdirectory or run zip and should return the document path directly', function (ctx) { + sinon.assert.notCalled(ctx.fs.mkdir) + expect(ctx.CommandRunner.promises.run.callCount).toBe(1) + expect(ctx.result).toBe(Path.join(ctx.compileDir, 'output-uuid.docx')) + }) + }) + + describe('when pandoc fails (non-zero exit code)', function () { + it('should reject with an error and release the lock', async function (ctx) { + ctx.CommandRunner.promises.run.resolves({ + stdout: '', + stderr: '', + exitCode: 1, + }) + await expect( + ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( + ctx.conversionId, + ctx.compileDir, + ctx.rootDocPath, + 'docx' + ) + ).to.be.rejectedWith('pandoc latex-to-document conversion failed') + sinon.assert.called(ctx.lock.release) + }) }) }) - describe('when zip fails (non-zero exit code)', function () { - it('should reject with an error and release the lock', async function (ctx) { - ctx.compileDir = '/compiles/test-compile-dir' + describe('with type=markdown (representative compressing type)', function () { + describe('successful conversion', function () { + beforeEach(async function (ctx) { + ctx.result = + await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( + ctx.conversionId, + ctx.compileDir, + ctx.rootDocPath, + 'markdown' + ) + }) - ctx.CommandRunner.promises.run - .onFirstCall() - .resolves({ stdout: '', stderr: '', exitCode: 0 }) - .onSecondCall() - .resolves({ stdout: '', stderr: 'zip error', exitCode: 1 }) + it('should create a UUID-named subdirectory for the output', function (ctx) { + sinon.assert.calledWith( + ctx.fs.mkdir, + Path.join(ctx.compileDir, 'output-uuid'), + { recursive: true } + ) + }) + it('should run pandoc then zip the subdirectory and return the zip path', function (ctx) { + expect(ctx.CommandRunner.promises.run.callCount).toBe(2) + expect(ctx.CommandRunner.promises.run.secondCall.args[1]).toEqual([ + 'sh', + '-c', + 'cd output-uuid && zip -r ../output-uuid.zip .', + ]) + expect(ctx.result).toBe(Path.join(ctx.compileDir, 'output-uuid.zip')) + }) + }) + + describe('when zip fails (non-zero exit code)', function () { + it('should reject with an error and release the lock', async function (ctx) { + ctx.CommandRunner.promises.run + .onFirstCall() + .resolves({ stdout: '', stderr: '', exitCode: 0 }) + .onSecondCall() + .resolves({ stdout: '', stderr: 'zip error', exitCode: 1 }) + await expect( + ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( + ctx.conversionId, + ctx.compileDir, + ctx.rootDocPath, + 'markdown' + ) + ).to.be.rejectedWith('zip compression of export failed') + sinon.assert.called(ctx.lock.release) + }) + }) + }) + + describe('with an unsupported conversion type', function () { + it('should reject with an unsupported conversion type error', async function (ctx) { await expect( ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( ctx.conversionId, ctx.compileDir, - 'main.tex', - 'markdown' + ctx.rootDocPath, + 'not-a-real-type' ) - ).to.be.rejectedWith('zip compression of export failed') - - sinon.assert.called(ctx.lock.release) + ).to.be.rejectedWith('unsupported conversion type') }) }) }) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 3b801d4baf..75aed5fc31 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -493,6 +493,7 @@ "document_updated_externally": "", "document_updated_externally_detail": "", "documentation": "", + "docx_export_feedback_message": "", "docx_import_feedback_message": "", "doesnt_match": "", "doing_this_allow_log_in_through_institution": "", @@ -720,6 +721,7 @@ "generate_latex_from_prompts_and_images": "", "generate_token": "", "generating": "", + "generic_export_feedback_message": "", "generic_if_problem_continues_contact_us": "", "generic_linked_file_compile_error": "", "generic_something_went_wrong": "", @@ -1151,6 +1153,7 @@ "manager": "", "managers_management": "", "managing_your_subscription": "", + "markdown_export_feedback_message": "", "markdown_import_feedback_message": "", "marked_as_resolved": "", "math": "", diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/download-project.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/download-project.tsx index 139eb78ff3..16cb17774e 100644 --- a/services/web/frontend/js/features/ide-react/components/toolbar/download-project.tsx +++ b/services/web/frontend/js/features/ide-react/components/toolbar/download-project.tsx @@ -7,9 +7,6 @@ import { useProjectContext } from '@/shared/context/project-context' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' -import getMeta from '@/utils/meta' -import { useFeatureFlag } from '@/shared/context/split-test-context' -import useConvertProject from '../../hooks/use-convert-project' export const DownloadProjectZip = () => { const { t } = useTranslation() @@ -103,75 +100,3 @@ export const DownloadProjectPDF = () => { return button } } - -export const ExportProjectDocx = () => { - const { t } = useTranslation() - const exportDocxEnabled = useFeatureFlag('export-docx') - const enablePandocConversions = - getMeta('ol-ExposedSettings')?.enablePandocConversions - const anonymous = getMeta('ol-anonymous') - const downloadConversion = useConvertProject('docx') - - const showExportDocx = - exportDocxEnabled && enablePandocConversions && !anonymous - - useCommandProvider( - () => - showExportDocx - ? [ - { - id: 'export-as-docx', - handler: downloadConversion, - label: t('export_as_docx'), - }, - ] - : [], - [t, showExportDocx, downloadConversion] - ) - - if (!showExportDocx) { - return null - } - - return ( - - {t('export_as_docx')} - - ) -} - -export const ExportProjectMarkdown = () => { - const { t } = useTranslation() - const exportMarkdownEnabled = useFeatureFlag('export-markdown') - const enablePandocConversions = - getMeta('ol-ExposedSettings')?.enablePandocConversions - const anonymous = getMeta('ol-anonymous') - const downloadConversion = useConvertProject('markdown') - - const showExportMarkdown = - exportMarkdownEnabled && enablePandocConversions && !anonymous - - useCommandProvider( - () => - showExportMarkdown - ? [ - { - id: 'export-as-markdown', - handler: downloadConversion, - label: t('export_as_markdown'), - }, - ] - : [], - [t, showExportMarkdown, downloadConversion] - ) - - if (!showExportMarkdown) { - return null - } - - return ( - - {t('export_as_markdown')} - - ) -} diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/export-document-toasts.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/export-document-toasts.tsx index 34ab19187d..bf9c57b33e 100644 --- a/services/web/frontend/js/features/ide-react/components/toolbar/export-document-toasts.tsx +++ b/services/web/frontend/js/features/ide-react/components/toolbar/export-document-toasts.tsx @@ -24,6 +24,49 @@ const ExportDocumentErrorToast = () => { ) } +const ExportDocumentSuccessToast = ({ data }: { data?: any }) => { + const type = data?.type + if (type === 'docx') { + return ( + , + ]} + /> + ) + } else if (type === 'markdown') { + return ( + , + ]} + /> + ) + } else { + return ( + , + ]} + /> + ) + } +} + const generators: GlobalToastGeneratorEntry[] = [ { key: 'export-document:error', @@ -44,6 +87,16 @@ const generators: GlobalToastGeneratorEntry[] = [ isDismissible: true, }), }, + { + key: 'export-document:success', + generator: (data: any) => ({ + content: , + type: 'success', + autoHide: true, + delay: 45000, + isDismissible: true, + }), + }, ] export default generators @@ -73,3 +126,11 @@ export const hidePreparingExportToast = (handle: string) => { }) ) } + +export const showExportDocumentSuccess = (type: 'docx' | 'markdown') => { + window.dispatchEvent( + new CustomEvent('ide:show-toast', { + detail: { key: 'export-document:success', type }, + }) + ) +} diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/export-project-with-conversion-button.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/export-project-with-conversion-button.tsx new file mode 100644 index 0000000000..9cc84e405f --- /dev/null +++ b/services/web/frontend/js/features/ide-react/components/toolbar/export-project-with-conversion-button.tsx @@ -0,0 +1,51 @@ +import getMeta from '@/utils/meta' +import { isSplitTestEnabled } from '@/utils/splitTestUtils' +import { FC } from 'react' +import useConvertProject from '../../hooks/use-convert-project' +import { useCommandProvider } from '../../hooks/use-command-provider' +import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item' + +type ExportProjectWithConversionProps = { + featureFlag?: string + conversionType: 'docx' | 'markdown' + label: string + menuBarId: string +} +export const ExportProjectWithConversionButton: FC< + ExportProjectWithConversionProps +> = ({ featureFlag, conversionType, label, menuBarId }) => { + const splitTestEnabledIfNeeded = featureFlag + ? isSplitTestEnabled(featureFlag) + : true + const enablePandocConversions = + getMeta('ol-ExposedSettings')?.enablePandocConversions + const anonymous = getMeta('ol-anonymous') + const downloadConversion = useConvertProject(conversionType) + + const showExportButton = + splitTestEnabledIfNeeded && enablePandocConversions && !anonymous + + useCommandProvider( + () => + showExportButton + ? [ + { + id: menuBarId, + handler: downloadConversion, + label, + }, + ] + : [], + [showExportButton, downloadConversion, label, menuBarId] + ) + + if (!showExportButton) { + return null + } + + return ( + + {label} + + ) +} diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/project-title.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/project-title.tsx index 45bd5974c0..e1272b5439 100644 --- a/services/web/frontend/js/features/ide-react/components/toolbar/project-title.tsx +++ b/services/web/frontend/js/features/ide-react/components/toolbar/project-title.tsx @@ -10,16 +10,12 @@ import { useTranslation } from 'react-i18next' import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' import { useEditorContext } from '@/shared/context/editor-context' import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' -import { - DownloadProjectPDF, - DownloadProjectZip, - ExportProjectDocx, - ExportProjectMarkdown, -} from './download-project' +import { DownloadProjectPDF, DownloadProjectZip } from './download-project' import { useCallback, useState } from 'react' import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item' import EditableLabel from './editable-label' import { DuplicateProject } from './duplicate-project' +import { ExportProjectWithConversionButton } from './export-project-with-conversion-button' const [publishModalModules] = importOverleafModules('publishModal') const SubmitProjectButton = publishModalModules?.import.NewPublishDropdownButton @@ -81,8 +77,18 @@ export const ToolbarProjectTitle = () => { )} - - + + ( components={[ /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */ , @@ -39,7 +39,8 @@ const generators: GlobalToastGeneratorEntry[] = [ generator: () => ({ content: , type: 'info', - autoHide: false, + autoHide: true, + delay: 45000, isDismissible: true, }), }, @@ -48,7 +49,8 @@ const generators: GlobalToastGeneratorEntry[] = [ generator: () => ({ content: , type: 'info', - autoHide: false, + autoHide: true, + delay: 45000, isDismissible: true, }), }, diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 49610a3194..4dda8d4e5d 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -653,6 +653,7 @@ "document_updated_externally_detail": "This document was just updated externally. Any recent changes you have made may have been overwritten. To see previous versions, please look in the history.", "documentation": "Documentation", "documentation_articles": "Documentation and articles", + "docx_export_feedback_message": "Exporting Word docs is a new feature. <0>Let us know what you think", "docx_import_feedback_message": "Importing Word docs is a new feature. <0>Let us know what you think", "does_not_contain_or_significantly_match_your_email": "does not contain or significantly match your email", "doesnt_match": "Doesn’t match", @@ -959,6 +960,7 @@ "generate_latex_from_prompts_and_images": "Generate LaTeX from prompts and images", "generate_token": "Generate token", "generating": "Generating", + "generic_export_feedback_message": "This is a new feature. <0>Let us know what you think", "generic_if_problem_continues_contact_us": "If the problem continues please contact us", "generic_linked_file_compile_error": "This project’s output files are not available because it failed to compile. Please open the project to see the compilation error details.", "generic_something_went_wrong": "Sorry, something went wrong", @@ -1520,6 +1522,7 @@ "managers_management": "Managers management", "managing_your_subscription": "Managing your subscription", "march": "March", + "markdown_export_feedback_message": "Exporting as Markdown is a new feature. <0>Let us know what you think", "markdown_import_feedback_message": "Importing Markdown files is a new feature. <0>Let us know what you think", "marked_as_resolved": "Marked as resolved", "math": "Math",