diff --git a/CHANGELOG.md b/CHANGELOG.md index 083ec79e99..5c8245d68b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - ✨(frontend) add customization for translations #857 +- ✨(frontend) Duplicate a doc #1078 ### Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts index be1bfcad18..873e45ec80 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts @@ -424,7 +424,7 @@ test.describe('Doc Header', () => { }); test('it pins a document', async ({ page, browserName }) => { - const [docTitle] = await createDoc(page, `Favorite doc`, browserName); + const [docTitle] = await createDoc(page, `Pin doc`, browserName); await page.getByLabel('Open the document options').click(); @@ -456,6 +456,37 @@ test.describe('Doc Header', () => { await expect(row.getByLabel('Pin document icon')).toBeHidden(); await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden(); }); + + test('it duplicates a document', async ({ page, browserName }) => { + const [docTitle] = await createDoc(page, `Duplicate doc`, browserName); + + const editor = page.locator('.ProseMirror'); + await editor.click(); + await editor.fill('Hello Duplicated World'); + + await page.reload(); + + await page.getByLabel('Open the document options').click(); + + await page.getByRole('menuitem', { name: 'Duplicate' }).click(); + await expect( + page.getByText('Document duplicated successfully!'), + ).toBeVisible(); + + await page.goto('/'); + + const duplicateTitle = 'Copy of ' + docTitle; + + const row = await getGridRow(page, duplicateTitle); + + await expect(row.getByText(duplicateTitle)).toBeVisible(); + + await row.getByText(`more_horiz`).click(); + await page.getByRole('menuitem', { name: 'Duplicate' }).click(); + const duplicateDuplicateTitle = 'Copy of ' + duplicateTitle; + await page.getByText(duplicateDuplicateTitle).click(); + await expect(page.getByText('Hello Duplicated World')).toBeVisible(); + }); }); test.describe('Documents Header mobile', () => { diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/hooks/__tests__/useModuleExportAGPL.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/__tests__/useModuleExportAGPL.test.tsx new file mode 100644 index 0000000000..dcca32036a --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/__tests__/useModuleExportAGPL.test.tsx @@ -0,0 +1,42 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { AppWrapper } from '@/tests/utils'; + +// Mock the environment variable +const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT; + +// Mock the libAGPL module +jest.mock( + '../../libAGPL', + () => ({ + exportToPdf: jest.fn(), + exportToDocx: jest.fn(), + }), + { virtual: true }, +); + +describe('useModuleExport', () => { + afterAll(() => { + process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = originalEnv; + }); + + it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => { + process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false'; + const { useModuleExport } = await import('../useModuleExport'); + + const { result } = renderHook(() => useModuleExport(), { + wrapper: AppWrapper, + }); + + // Initial state should be undefined + expect(result.current).toBeUndefined(); + + // After effects run, it should contain the modules from libAGPL + await waitFor(() => { + expect(result.current).toBeDefined(); + }); + + expect(result.current).toHaveProperty('exportToPdf'); + expect(result.current).toHaveProperty('exportToDocx'); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/hooks/__tests__/useModuleExportMIT.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/__tests__/useModuleExportMIT.test.tsx new file mode 100644 index 0000000000..b354c45161 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/__tests__/useModuleExportMIT.test.tsx @@ -0,0 +1,26 @@ +import { renderHook } from '@testing-library/react'; + +import { AppWrapper } from '@/tests/utils'; +import { sleep } from '@/utils'; + +const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT; + +describe('useModuleExport', () => { + afterAll(() => { + process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = originalEnv; + }); + + it('should return null when NEXT_PUBLIC_PUBLISH_AS_MIT is true', async () => { + const { useModuleExport } = await import('../useModuleExport'); + + const { result } = renderHook(() => useModuleExport(), { + wrapper: AppWrapper, + }); + + expect(result.current).toBeUndefined(); + + await sleep(1000); + + expect(result.current).toBeUndefined(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useModuleExport.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useModuleExport.tsx new file mode 100644 index 0000000000..37b630f7b9 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useModuleExport.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; + +const modulesAGPL = + process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false' + ? import('../libAGPL') + : Promise.resolve(null); + +export const useModuleExport = () => { + const [modules, setModules] = useState>(); + + useEffect(() => { + const resolveModule = async () => { + const resolvedModules = await modulesAGPL; + if (!resolvedModules) { + return; + } + setModules(resolvedModules); + }; + void resolveModule(); + }, []); + + return modules; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/index.ts index 527c58f05c..096860441d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-export/index.ts @@ -1,3 +1,3 @@ export * from './api'; -export * from './components'; +export * from './hooks/useModuleExport'; export * from './utils'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/libAGPL.ts b/src/frontend/apps/impress/src/features/docs/doc-export/libAGPL.ts new file mode 100644 index 0000000000..5cf1a6baf0 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/libAGPL.ts @@ -0,0 +1 @@ +export { ModalExport } from './components'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBox.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBox.spec.tsx index 35faa9a837..22647b11b3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBox.spec.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBox.spec.tsx @@ -30,6 +30,10 @@ class TestAnalytic extends AbstractAnalytic { jest.mock('@/features/docs/doc-export/', () => ({ ModalExport: () => ModalExport, + useModuleExport: () => ({ + exportToPdf: jest.fn(), + exportToDocx: jest.fn(), + }), })); const doc = { diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxAGPL.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxAGPL.spec.tsx deleted file mode 100644 index 2624889bf7..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxAGPL.spec.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; - -import { AppWrapper } from '@/tests/utils'; - -import { DocToolBox } from '../components/DocToolBox'; - -const doc = { - nb_accesses: 1, - abilities: { - versions_list: true, - destroy: true, - }, -}; - -jest.mock('@/features/docs/doc-export/', () => ({ - ModalExport: () => ModalExport, -})); - -it('DocToolBox dynamic import: loads DocToolBox when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => { - process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false'; - - render(, { - wrapper: AppWrapper, - }); - - expect(await screen.findByText('download')).toBeInTheDocument(); -}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxMIT.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxMIT.spec.tsx deleted file mode 100644 index b7901f2def..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxMIT.spec.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import React from 'react'; - -import { AppWrapper } from '@/tests/utils'; - -const doc = { - nb_accesses: 1, - abilities: { - versions_list: true, - destroy: true, - }, -}; - -jest.mock('@/features/docs/doc-export/', () => ({ - ModalExport: () => ModalExport, -})); - -it('DocToolBox dynamic import: loads DocToolBox when NEXT_PUBLIC_PUBLISH_AS_MIT is true', async () => { - process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true'; - - const { DocToolBox } = await import('../components/DocToolBox'); - - render(, { - wrapper: AppWrapper, - }); - - await waitFor( - () => { - expect(screen.queryByText('download')).not.toBeInTheDocument(); - }, - { - timeout: 1000, - }, - ); -}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 3cdadfabb7..49386fbd47 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -1,47 +1,174 @@ -import { Button, useModal } from '@openfun/cunningham-react'; +import { + Button, + VariantType, + useModal, + useToastProvider, +} from '@openfun/cunningham-react'; import { useQueryClient } from '@tanstack/react-query'; -import dynamic from 'next/dynamic'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; -import { Box, Icon } from '@/components'; +import { + Box, + DropdownMenu, + DropdownMenuOption, + Icon, + IconOptions, +} from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Doc } from '@/docs/doc-management'; -import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning'; +import { useModuleExport } from '@/docs/doc-export/'; +import { + Doc, + KEY_DOC, + KEY_LIST_DOC, + ModalRemoveDoc, + useCopyDocLink, + useCreateFavoriteDoc, + useDeleteFavoriteDoc, + useDuplicateDoc, +} from '@/docs/doc-management'; +import { DocShareModal } from '@/docs/doc-share'; +import { + KEY_LIST_DOC_VERSIONS, + ModalSelectVersion, +} from '@/docs/doc-versioning'; +import { useAnalytics } from '@/libs'; import { useResponsiveStore } from '@/stores'; +import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard'; + interface DocToolBoxProps { doc: Doc; } -const DocToolBoxLicence = dynamic(() => - process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false' - ? import('./DocToolBoxLicenceAGPL').then((mod) => mod.DocToolBoxLicenceAGPL) - : import('./DocToolBoxLicenceMIT').then((mod) => mod.DocToolBoxLicenceMIT), -); - export const DocToolBox = ({ doc }: DocToolBoxProps) => { const { t } = useTranslation(); const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view; const queryClient = useQueryClient(); + const modulesExport = useModuleExport(); + const { toast } = useToastProvider(); - const { spacingsTokens } = useCunninghamTheme(); + const { spacingsTokens, colorsTokens } = useCunninghamTheme(); - const modalHistory = useModal(); + const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); + const [isModalExportOpen, setIsModalExportOpen] = useState(false); + const selectHistoryModal = useModal(); const modalShare = useModal(); - const { isSmallMobile } = useResponsiveStore(); + const { isSmallMobile, isDesktop } = useResponsiveStore(); + const copyDocLink = useCopyDocLink(doc.id); + const { mutate: duplicateDoc } = useDuplicateDoc({ + onSuccess: () => { + toast(t('Document duplicated successfully!'), VariantType.SUCCESS, { + duration: 3000, + }); + }, + onError: () => { + toast(t('Failed to duplicate the document...'), VariantType.ERROR, { + duration: 3000, + }); + }, + }); + const { isFeatureFlagActivated } = useAnalytics(); + const removeFavoriteDoc = useDeleteFavoriteDoc({ + listInvalideQueries: [KEY_LIST_DOC, KEY_DOC], + }); + const makeFavoriteDoc = useCreateFavoriteDoc({ + listInvalideQueries: [KEY_LIST_DOC, KEY_DOC], + }); useEffect(() => { - if (modalHistory.isOpen) { + if (selectHistoryModal.isOpen) { return; } void queryClient.resetQueries({ queryKey: [KEY_LIST_DOC_VERSIONS], }); - }, [modalHistory.isOpen, queryClient]); + }, [selectHistoryModal.isOpen, queryClient]); + + const options: DropdownMenuOption[] = [ + ...(isSmallMobile + ? [ + { + label: t('Share'), + icon: 'group', + callback: modalShare.open, + }, + { + label: t('Export'), + icon: 'download', + callback: () => { + setIsModalExportOpen(true); + }, + show: !!modulesExport, + }, + { + label: t('Copy link'), + icon: 'add_link', + callback: copyDocLink, + }, + ] + : []), + { + label: doc.is_favorite ? t('Unpin') : t('Pin'), + icon: 'push_pin', + callback: () => { + if (doc.is_favorite) { + removeFavoriteDoc.mutate({ id: doc.id }); + } else { + makeFavoriteDoc.mutate({ id: doc.id }); + } + }, + testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`, + }, + { + label: t('Version history'), + icon: 'history', + disabled: !doc.abilities.versions_list, + callback: () => { + selectHistoryModal.open(); + }, + show: isDesktop, + }, + { + label: t('Copy as {{format}}', { format: 'Markdown' }), + icon: 'content_copy', + callback: () => { + void copyCurrentEditorToClipboard('markdown'); + }, + }, + { + label: t('Copy as {{format}}', { format: 'HTML' }), + icon: 'content_copy', + callback: () => { + void copyCurrentEditorToClipboard('html'); + }, + show: isFeatureFlagActivated('CopyAsHTML'), + }, + { + label: t('Duplicate'), + icon: 'call_split', + disabled: !doc.abilities.duplicate, + callback: () => { + duplicateDoc({ + docId: doc.id, + with_accesses: true, + }); + }, + }, + { + label: t('Delete document'), + icon: 'delete', + disabled: !doc.abilities.destroy, + callback: () => { + setIsModalRemoveOpen(true); + }, + }, + ]; + + const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard(); return ( { )} - + } + onClick={() => { + setIsModalExportOpen(true); + }} + size={isSmallMobile ? 'small' : 'medium'} + /> + )} + + + + + + {modalShare.isOpen && ( + modalShare.close()} doc={doc} /> + )} + {isModalExportOpen && modulesExport?.ModalExport && ( + setIsModalExportOpen(false)} doc={doc} - modalHistory={modalHistory} - modalShare={modalShare} /> - + )} + {isModalRemoveOpen && ( + setIsModalRemoveOpen(false)} doc={doc} /> + )} + {selectHistoryModal.isOpen && ( + selectHistoryModal.close()} + doc={doc} + /> + )} ); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBoxLicenceAGPL.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBoxLicenceAGPL.tsx deleted file mode 100644 index c8ded02aaa..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBoxLicenceAGPL.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { Button, useModal } from '@openfun/cunningham-react'; -import { useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { css } from 'styled-components'; - -import { - DropdownMenu, - DropdownMenuOption, - Icon, - IconOptions, -} from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; -import { ModalExport } from '@/docs/doc-export/'; -import { - Doc, - KEY_DOC, - KEY_LIST_DOC, - ModalRemoveDoc, - useCopyDocLink, - useCreateFavoriteDoc, - useDeleteFavoriteDoc, -} from '@/docs/doc-management'; -import { - KEY_LIST_DOC_VERSIONS, - ModalSelectVersion, -} from '@/docs/doc-versioning'; -import { useAnalytics } from '@/libs'; -import { useResponsiveStore } from '@/stores'; - -import { DocShareModal } from '../../doc-share'; -import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard'; - -type ModalType = ReturnType; - -interface DocToolBoxLicenceProps { - doc: Doc; - modalHistory: ModalType; - modalShare: ModalType; -} - -export const DocToolBoxLicenceAGPL = ({ - doc, - modalHistory, - modalShare, -}: DocToolBoxLicenceProps) => { - const { t } = useTranslation(); - const queryClient = useQueryClient(); - - const { colorsTokens } = useCunninghamTheme(); - - const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); - const [isModalExportOpen, setIsModalExportOpen] = useState(false); - - const { isSmallMobile, isDesktop } = useResponsiveStore(); - const copyDocLink = useCopyDocLink(doc.id); - const { isFeatureFlagActivated } = useAnalytics(); - const removeFavoriteDoc = useDeleteFavoriteDoc({ - listInvalideQueries: [KEY_LIST_DOC, KEY_DOC], - }); - const makeFavoriteDoc = useCreateFavoriteDoc({ - listInvalideQueries: [KEY_LIST_DOC, KEY_DOC], - }); - const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard(); - - const options: DropdownMenuOption[] = [ - ...(isSmallMobile - ? [ - { - label: t('Share'), - icon: 'group', - callback: modalShare.open, - }, - { - label: t('Export'), - icon: 'download', - callback: () => { - setIsModalExportOpen(true); - }, - }, - { - label: t('Copy link'), - icon: 'add_link', - callback: copyDocLink, - }, - ] - : []), - { - label: doc.is_favorite ? t('Unpin') : t('Pin'), - icon: 'push_pin', - callback: () => { - if (doc.is_favorite) { - removeFavoriteDoc.mutate({ id: doc.id }); - } else { - makeFavoriteDoc.mutate({ id: doc.id }); - } - }, - testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`, - }, - { - label: t('Version history'), - icon: 'history', - disabled: !doc.abilities.versions_list, - callback: () => { - modalHistory.open(); - }, - show: isDesktop, - }, - - { - label: t('Copy as {{format}}', { format: 'Markdown' }), - icon: 'content_copy', - callback: () => { - void copyCurrentEditorToClipboard('markdown'); - }, - }, - { - label: t('Copy as {{format}}', { format: 'HTML' }), - icon: 'content_copy', - callback: () => { - void copyCurrentEditorToClipboard('html'); - }, - show: isFeatureFlagActivated('CopyAsHTML'), - }, - { - label: t('Delete document'), - icon: 'delete', - disabled: !doc.abilities.destroy, - callback: () => { - setIsModalRemoveOpen(true); - }, - }, - ]; - - useEffect(() => { - if (modalHistory.isOpen) { - return; - } - - void queryClient.resetQueries({ - queryKey: [KEY_LIST_DOC_VERSIONS], - }); - }, [modalHistory.isOpen, queryClient]); - - return ( - <> - {!isSmallMobile && ( -