diff --git a/CHANGELOG.md b/CHANGELOG.md index ea821ad1..289e1a9d 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 - 📝(project) add troubleshoot doc #1066 - 📝(project) add system-requirement doc #1066 - 🔧(front) configure x-frame-options to DENY in nginx conf #1084 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 be1bfcad..0eec2455 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,35 @@ 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.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-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 49c257ee..aaaeab5e 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,4 +1,9 @@ -import { Button, useModal } from '@openfun/cunningham-react'; +import { + Button, + VariantType, + useModal, + useToastProvider, +} from '@openfun/cunningham-react'; import { useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -21,6 +26,7 @@ import { useCopyDocLink, useCreateFavoriteDoc, useDeleteFavoriteDoc, + useDuplicateDoc, } from '@/docs/doc-management'; import { DocShareModal } from '@/docs/doc-share'; import { @@ -42,6 +48,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { const { t } = useTranslation(); const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view; const queryClient = useQueryClient(); + const { toast } = useToastProvider(); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); @@ -52,6 +59,18 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { 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], @@ -114,7 +133,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { }, show: isDesktop, }, - { label: t('Copy as {{format}}', { format: 'Markdown' }), icon: 'content_copy', @@ -130,6 +148,18 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { }, show: isFeatureFlagActivated('CopyAsHTML'), }, + { + label: t('Duplicate'), + icon: 'call_split', + disabled: !doc.abilities.duplicate, + callback: () => { + duplicateDoc({ + docId: doc.id, + with_accesses: false, + canSave: doc.abilities.partial_update, + }); + }, + }, { label: t('Delete document'), icon: 'delete', diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts index 11123c6b..db663a11 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts @@ -1,8 +1,9 @@ export * from './useCreateDoc'; +export * from './useCreateFavoriteDoc'; export * from './useDeleteFavoriteDoc'; export * from './useDoc'; export * from './useDocOptions'; export * from './useDocs'; -export * from './useCreateFavoriteDoc'; +export * from './useDuplicateDoc'; export * from './useUpdateDoc'; export * from './useUpdateDocLink'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx new file mode 100644 index 00000000..3e04c229 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx @@ -0,0 +1,91 @@ +import { + UseMutationOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; +import * as Y from 'yjs'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { toBase64 } from '@/docs/doc-editor'; +import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning'; + +import { useProviderStore } from '../stores'; +import { Doc } from '../types'; + +import { KEY_LIST_DOC } from './useDocs'; +import { useUpdateDoc } from './useUpdateDoc'; + +interface DuplicateDocPayload { + docId: string; + with_accesses?: boolean; +} + +type DuplicateDocResponse = Pick; + +export const duplicateDoc = async ({ + docId, + with_accesses, +}: DuplicateDocPayload): Promise => { + const response = await fetchAPI(`documents/${docId}/duplicate/`, { + method: 'POST', + body: JSON.stringify({ with_accesses }), + }); + + if (!response.ok) { + throw new APIError( + 'Failed to duplicate the doc', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +type DuplicateDocParams = DuplicateDocPayload & { + canSave: boolean; +}; + +type DuplicateDocOptions = UseMutationOptions< + DuplicateDocResponse, + APIError, + DuplicateDocParams +>; + +export function useDuplicateDoc(options: DuplicateDocOptions) { + const queryClient = useQueryClient(); + + const { provider } = useProviderStore(); + + const { mutateAsync: updateDoc } = useUpdateDoc({ + listInvalideQueries: [KEY_LIST_DOC_VERSIONS], + }); + + return useMutation({ + mutationFn: async (variables) => { + try { + // Save the document if we can first, to ensure the latest state is duplicated + if ( + variables.canSave && + provider && + provider.document.guid === variables.docId + ) { + await updateDoc({ + id: variables.docId, + content: toBase64(Y.encodeStateAsUpdate(provider.document)), + }); + } + + return await duplicateDoc(variables); + } catch (error) { + // If save fails, throw the error to prevent duplication + throw error; + } + }, + onSuccess: (data, variables, context) => { + void queryClient.resetQueries({ + queryKey: [KEY_LIST_DOC], + }); + void options.onSuccess?.(data, variables, context); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx index 2cc2b6c1..29680401 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx @@ -56,6 +56,7 @@ export interface Doc { children_list: boolean; collaboration_auth: boolean; destroy: boolean; + duplicate: boolean; favorite: boolean; invite_owner: boolean; link_configuration: boolean; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx index 726ecec9..a4c13ad8 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx @@ -1,4 +1,8 @@ -import { useModal } from '@openfun/cunningham-react'; +import { + VariantType, + useModal, + useToastProvider, +} from '@openfun/cunningham-react'; import { useTranslation } from 'react-i18next'; import { DropdownMenu, DropdownMenuOption, Icon } from '@/components'; @@ -8,6 +12,7 @@ import { ModalRemoveDoc, useCreateFavoriteDoc, useDeleteFavoriteDoc, + useDuplicateDoc, } from '@/docs/doc-management'; interface DocsGridActionsProps { @@ -20,8 +25,21 @@ export const DocsGridActions = ({ openShareModal, }: DocsGridActionsProps) => { const { t } = useTranslation(); + const { toast } = useToastProvider(); const deleteModal = useModal(); + 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 removeFavoriteDoc = useDeleteFavoriteDoc({ listInvalideQueries: [KEY_LIST_DOC], @@ -52,7 +70,18 @@ export const DocsGridActions = ({ testId: `docs-grid-actions-share-${doc.id}`, }, - + { + label: t('Duplicate'), + icon: 'call_split', + disabled: !doc.abilities.duplicate, + callback: () => { + duplicateDoc({ + docId: doc.id, + with_accesses: false, + canSave: false, + }); + }, + }, { label: t('Remove'), icon: 'delete', diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LefPanelTargetFilters.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LefPanelTargetFilters.tsx index 3daca863..cc8f6712 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LefPanelTargetFilters.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LefPanelTargetFilters.tsx @@ -4,7 +4,7 @@ import { css } from 'styled-components'; import { Box, BoxButton, Icon, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { DocDefaultFilter } from '@/features/docs'; +import { DocDefaultFilter } from '@/docs/doc-management'; import { useLeftPanelStore } from '@/features/left-panel'; export const LeftPanelTargetFilters = () => { diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index 49485277..661f01a1 100644 --- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -186,6 +186,7 @@ export class ApiPlugin implements WorkboxPlugin { children_list: true, collaboration_auth: true, destroy: true, + duplicate: true, favorite: true, invite_owner: true, link_configuration: true,