✨(frontend) Duplicate a doc
We can duplicate a document from the tool options.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user