(frontend) Duplicate a doc

We can duplicate a document from the
tool options.
This commit is contained in:
Anthony LC
2025-06-26 11:14:32 +02:00
parent 2b2e81f042
commit fc1678d0c2
9 changed files with 190 additions and 7 deletions

View File

@@ -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

View File

@@ -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', () => {

View File

@@ -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',

View File

@@ -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';

View File

@@ -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<Doc, 'id'>;
export const duplicateDoc = async ({
docId,
with_accesses,
}: DuplicateDocPayload): Promise<DuplicateDocResponse> => {
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<DuplicateDocResponse>;
};
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<DuplicateDocResponse, APIError, DuplicateDocParams>({
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);
},
});
}

View File

@@ -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;

View File

@@ -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',

View File

@@ -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 = () => {

View File

@@ -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,