✨(frontend) Duplicate a doc
We can duplicate a document from the tool options.
This commit is contained in:
@@ -11,6 +11,7 @@ and this project adheres to
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(frontend) add customization for translations #857
|
- ✨(frontend) add customization for translations #857
|
||||||
|
- ✨(frontend) Duplicate a doc #1078
|
||||||
- 📝(project) add troubleshoot doc #1066
|
- 📝(project) add troubleshoot doc #1066
|
||||||
- 📝(project) add system-requirement doc #1066
|
- 📝(project) add system-requirement doc #1066
|
||||||
- 🔧(front) configure x-frame-options to DENY in nginx conf #1084
|
- 🔧(front) configure x-frame-options to DENY in nginx conf #1084
|
||||||
|
|||||||
@@ -424,7 +424,7 @@ test.describe('Doc Header', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('it pins a document', async ({ page, browserName }) => {
|
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();
|
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(row.getByLabel('Pin document icon')).toBeHidden();
|
||||||
await expect(leftPanelFavorites.getByText(docTitle)).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', () => {
|
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 { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -21,6 +26,7 @@ import {
|
|||||||
useCopyDocLink,
|
useCopyDocLink,
|
||||||
useCreateFavoriteDoc,
|
useCreateFavoriteDoc,
|
||||||
useDeleteFavoriteDoc,
|
useDeleteFavoriteDoc,
|
||||||
|
useDuplicateDoc,
|
||||||
} from '@/docs/doc-management';
|
} from '@/docs/doc-management';
|
||||||
import { DocShareModal } from '@/docs/doc-share';
|
import { DocShareModal } from '@/docs/doc-share';
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +48,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view;
|
const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToastProvider();
|
||||||
|
|
||||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||||
|
|
||||||
@@ -52,6 +59,18 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
|||||||
|
|
||||||
const { isSmallMobile, isDesktop } = useResponsiveStore();
|
const { isSmallMobile, isDesktop } = useResponsiveStore();
|
||||||
const copyDocLink = useCopyDocLink(doc.id);
|
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 { isFeatureFlagActivated } = useAnalytics();
|
||||||
const removeFavoriteDoc = useDeleteFavoriteDoc({
|
const removeFavoriteDoc = useDeleteFavoriteDoc({
|
||||||
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
|
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
|
||||||
@@ -114,7 +133,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
|||||||
},
|
},
|
||||||
show: isDesktop,
|
show: isDesktop,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
label: t('Copy as {{format}}', { format: 'Markdown' }),
|
label: t('Copy as {{format}}', { format: 'Markdown' }),
|
||||||
icon: 'content_copy',
|
icon: 'content_copy',
|
||||||
@@ -130,6 +148,18 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
|||||||
},
|
},
|
||||||
show: isFeatureFlagActivated('CopyAsHTML'),
|
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'),
|
label: t('Delete document'),
|
||||||
icon: 'delete',
|
icon: 'delete',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
export * from './useCreateDoc';
|
export * from './useCreateDoc';
|
||||||
|
export * from './useCreateFavoriteDoc';
|
||||||
export * from './useDeleteFavoriteDoc';
|
export * from './useDeleteFavoriteDoc';
|
||||||
export * from './useDoc';
|
export * from './useDoc';
|
||||||
export * from './useDocOptions';
|
export * from './useDocOptions';
|
||||||
export * from './useDocs';
|
export * from './useDocs';
|
||||||
export * from './useCreateFavoriteDoc';
|
export * from './useDuplicateDoc';
|
||||||
export * from './useUpdateDoc';
|
export * from './useUpdateDoc';
|
||||||
export * from './useUpdateDocLink';
|
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;
|
children_list: boolean;
|
||||||
collaboration_auth: boolean;
|
collaboration_auth: boolean;
|
||||||
destroy: boolean;
|
destroy: boolean;
|
||||||
|
duplicate: boolean;
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
invite_owner: boolean;
|
invite_owner: boolean;
|
||||||
link_configuration: 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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
|
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
|
||||||
@@ -8,6 +12,7 @@ import {
|
|||||||
ModalRemoveDoc,
|
ModalRemoveDoc,
|
||||||
useCreateFavoriteDoc,
|
useCreateFavoriteDoc,
|
||||||
useDeleteFavoriteDoc,
|
useDeleteFavoriteDoc,
|
||||||
|
useDuplicateDoc,
|
||||||
} from '@/docs/doc-management';
|
} from '@/docs/doc-management';
|
||||||
|
|
||||||
interface DocsGridActionsProps {
|
interface DocsGridActionsProps {
|
||||||
@@ -20,8 +25,21 @@ export const DocsGridActions = ({
|
|||||||
openShareModal,
|
openShareModal,
|
||||||
}: DocsGridActionsProps) => {
|
}: DocsGridActionsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { toast } = useToastProvider();
|
||||||
|
|
||||||
const deleteModal = useModal();
|
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({
|
const removeFavoriteDoc = useDeleteFavoriteDoc({
|
||||||
listInvalideQueries: [KEY_LIST_DOC],
|
listInvalideQueries: [KEY_LIST_DOC],
|
||||||
@@ -52,7 +70,18 @@ export const DocsGridActions = ({
|
|||||||
|
|
||||||
testId: `docs-grid-actions-share-${doc.id}`,
|
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'),
|
label: t('Remove'),
|
||||||
icon: 'delete',
|
icon: 'delete',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { css } from 'styled-components';
|
|||||||
|
|
||||||
import { Box, BoxButton, Icon, Text } from '@/components';
|
import { Box, BoxButton, Icon, Text } from '@/components';
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
import { DocDefaultFilter } from '@/features/docs';
|
import { DocDefaultFilter } from '@/docs/doc-management';
|
||||||
import { useLeftPanelStore } from '@/features/left-panel';
|
import { useLeftPanelStore } from '@/features/left-panel';
|
||||||
|
|
||||||
export const LeftPanelTargetFilters = () => {
|
export const LeftPanelTargetFilters = () => {
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export class ApiPlugin implements WorkboxPlugin {
|
|||||||
children_list: true,
|
children_list: true,
|
||||||
collaboration_auth: true,
|
collaboration_auth: true,
|
||||||
destroy: true,
|
destroy: true,
|
||||||
|
duplicate: true,
|
||||||
favorite: true,
|
favorite: true,
|
||||||
invite_owner: true,
|
invite_owner: true,
|
||||||
link_configuration: true,
|
link_configuration: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user