diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts index ba61f4f6..822c1025 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts @@ -3,10 +3,12 @@ import { expect, test } from '@playwright/test'; import { createDoc, getGridRow, + getOtherBrowserName, mockedListDocs, toggleHeaderMenu, verifyDocName, } from './utils-common'; +import { addNewMember } from './utils-share'; import { createRootSubPage } from './utils-sub-pages'; test.describe('Doc grid move', () => { @@ -178,6 +180,15 @@ test.describe('Doc grid move', () => { await page.goto('/'); const [titleDoc1] = await createDoc(page, 'Draggable doc', browserName, 1); + + const otherBrowserName = getOtherBrowserName(browserName); + await page.getByRole('button', { name: 'Share' }).click(); + await addNewMember(page, 0, 'Administrator', otherBrowserName); + await page + .getByRole('dialog') + .getByRole('button', { name: 'close' }) + .click(); + await page.getByRole('button', { name: 'Back to homepage' }).click(); const [titleDoc2] = await createDoc(page, 'Droppable doc', browserName, 1); @@ -209,6 +220,18 @@ test.describe('Doc grid move', () => { // Validate the move action await page.keyboard.press('Enter'); + await expect( + page + .getByRole('dialog') + .getByText('it will lose its current access rights'), + ).toBeVisible(); + + await page + .getByRole('dialog') + .getByRole('button', { name: 'Move', exact: true }) + .first() + .click(); + await expect(docsGrid.getByText(titleDoc1)).toBeHidden(); await docsGrid .getByRole('link', { name: `Open document ${titleDoc2}` }) diff --git a/src/frontend/apps/impress/src/components/modal/AlertModal.tsx b/src/frontend/apps/impress/src/components/modal/AlertModal.tsx index 778c8ff8..1b0f88c8 100644 --- a/src/frontend/apps/impress/src/components/modal/AlertModal.tsx +++ b/src/frontend/apps/impress/src/components/modal/AlertModal.tsx @@ -1,5 +1,5 @@ import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; -import { ReactNode } from 'react'; +import { ReactNode, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Box } from '../Box'; @@ -25,6 +25,22 @@ export const AlertModal = ({ title, }: AlertModalProps) => { const { t } = useTranslation(); + + /** + * TODO: + * Remove this effect when Cunningham will have this patch released: + * https://github.com/suitenumerique/cunningham/pull/377 + */ + useEffect(() => { + const timeout = setTimeout(() => { + const contents = document.querySelectorAll('.c__modal__content'); + contents.forEach((content) => { + content.setAttribute('tabindex', '-1'); + }); + }, 100); + return () => clearTimeout(timeout); + }, []); + return ( } rightActions={ - <> + @@ -59,7 +76,7 @@ export const AlertModal = ({ > {confirmLabel ?? t('Confirm')} - + } > 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 88c4b028..d3f041e0 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 @@ -7,6 +7,7 @@ export * from './useDocOptions'; export * from './useDocs'; export * from './useDocsFavorite'; export * from './useDuplicateDoc'; +export * from './useMoveDoc'; export * from './useRestoreDoc'; export * from './useSubDocs'; export * from './useUpdateDoc'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useMoveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useMoveDoc.tsx new file mode 100644 index 00000000..dce4e5ed --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useMoveDoc.tsx @@ -0,0 +1,82 @@ +import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { + getDocAccesses, + getDocInvitations, + useDeleteDocAccess, + useDeleteDocInvitation, +} from '@/docs/doc-share'; + +import { KEY_LIST_DOC } from './useDocs'; + +export type MoveDocParam = { + sourceDocumentId: string; + targetDocumentId: string; + position: TreeViewMoveModeEnum; +}; + +export const moveDoc = async ({ + sourceDocumentId, + targetDocumentId, + position, +}: MoveDocParam): Promise => { + const response = await fetchAPI(`documents/${sourceDocumentId}/move/`, { + method: 'POST', + body: JSON.stringify({ + target_document_id: targetDocumentId, + position, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to move the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +export function useMoveDoc(deleteAccessOnMove = false) { + const queryClient = useQueryClient(); + const { mutate: handleDeleteInvitation } = useDeleteDocInvitation(); + const { mutate: handleDeleteAccess } = useDeleteDocAccess(); + + return useMutation({ + mutationFn: moveDoc, + async onSuccess(_data, variables, _onMutateResult, _context) { + if (!deleteAccessOnMove) { + return; + } + + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_DOC], + }); + const accesses = await getDocAccesses({ + docId: variables.sourceDocumentId, + }); + + const invitationsResponse = await getDocInvitations({ + docId: variables.sourceDocumentId, + page: 1, + }); + + const invitations = invitationsResponse.results; + + await Promise.all([ + ...invitations.map((invitation) => + handleDeleteInvitation({ + docId: variables.sourceDocumentId, + invitationId: invitation.id, + }), + ), + ...accesses.map((access) => + handleDeleteAccess({ + docId: variables.sourceDocumentId, + accessId: access.id, + }), + ), + ]); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts index 44949aaa..df8c10ef 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts @@ -1,3 +1,2 @@ export * from './useDocChildren'; export * from './useDocTree'; -export * from './useMove'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx deleted file mode 100644 index 1ba87df4..00000000 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit'; -import { useMutation } from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; - -export type MoveDocParam = { - sourceDocumentId: string; - targetDocumentId: string; - position: TreeViewMoveModeEnum; -}; - -export const moveDoc = async ({ - sourceDocumentId, - targetDocumentId, - position, -}: MoveDocParam): Promise => { - const response = await fetchAPI(`documents/${sourceDocumentId}/move/`, { - method: 'POST', - body: JSON.stringify({ - target_document_id: targetDocumentId, - position, - }), - }); - - if (!response.ok) { - throw new APIError('Failed to move the doc', await errorCauses(response)); - } - - return response.json() as Promise; -}; - -export function useMoveDoc() { - return useMutation({ - mutationFn: moveDoc, - }); -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx index 08fe8005..6c8daf05 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -12,10 +12,14 @@ import { css } from 'styled-components'; import { Box, Overlayer, StyledLink } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Doc, SimpleDocItem } from '@/docs/doc-management'; +import { + Doc, + SimpleDocItem, + useMoveDoc, + useTrans, +} from '@/docs/doc-management'; import { KEY_DOC_TREE, useDocTree } from '../api/useDocTree'; -import { useMoveDoc } from '../api/useMove'; import { findIndexInTree } from '../utils'; import { DocSubPageItem } from './DocSubPageItem'; @@ -28,6 +32,7 @@ type DocTreeProps = { export const DocTree = ({ currentDoc }: DocTreeProps) => { const { spacingsTokens } = useCunninghamTheme(); const { isDesktop } = useResponsive(); + const { untitledDocument } = useTrans(); const [treeRoot, setTreeRoot] = useState(null); const treeContext = useTreeContext(); const router = useRouter(); @@ -265,7 +270,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { ref={rootItemRef} data-testid="doc-tree-root-item" role="treeitem" - aria-label={`${t('Root document {{title}}', { title: treeContext.root?.title || t('Untitled document') })}`} + aria-label={`${t('Root document {{title}}', { title: treeContext.root?.title || untitledDocument })}`} aria-selected={rootIsSelected} tabIndex={0} onFocus={handleRootFocus} @@ -325,7 +330,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { ); router.push(`/docs/${treeContext?.root?.id}`); }} - aria-label={`${t('Open root document')}: ${treeContext.root?.title || t('Untitled document')}`} + aria-label={`${t('Open root document')}: ${treeContext.root?.title || untitledDocument}`} tabIndex={-1} // avoid double tabstop > diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx index ca369147..780d0906 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx @@ -2,19 +2,11 @@ import { DndContext, DragOverlay, Modifier } from '@dnd-kit/core'; import { getEventCoordinates } from '@dnd-kit/utilities'; import { useModal } from '@gouvfr-lasuite/cunningham-react'; import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit'; -import { useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { Trans, useTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; -import { AlertModal, Card, Text } from '@/components'; -import { Doc, KEY_LIST_DOC, useTrans } from '@/docs/doc-management'; -import { - getDocAccesses, - getDocInvitations, - useDeleteDocAccess, - useDeleteDocInvitation, -} from '@/docs/doc-share'; -import { useMoveDoc } from '@/docs/doc-tree'; +import { Card, Text } from '@/components'; +import { Doc, useMoveDoc, useTrans } from '@/docs/doc-management'; import { useResponsiveStore } from '@/stores/useResponsiveStore'; import { DocDragEndData, useDragAndDrop } from '../hooks/useDragAndDrop'; @@ -22,6 +14,7 @@ import { DocDragEndData, useDragAndDrop } from '../hooks/useDragAndDrop'; import { DocsGridItem } from './DocsGridItem'; import { Draggable } from './Draggable'; import { Droppable } from './Droppable'; +import { ModalConfirmationMoveDoc } from './ModalConfimationMoveDoc'; const snapToTopLeft: Modifier = ({ activatorEvent, @@ -55,11 +48,8 @@ type DocGridContentListProps = { export const DraggableDocGridContentList = ({ docs, }: DocGridContentListProps) => { - const { mutateAsync: handleMove, isError } = useMoveDoc(); - const queryClient = useQueryClient(); + const { mutateAsync: handleMove, isError } = useMoveDoc(true); const modalConfirmation = useModal(); - const { mutate: handleDeleteInvitation } = useDeleteDocInvitation(); - const { mutate: handleDeleteAccess } = useDeleteDocAccess(); const onDragData = useRef(null); const { untitledDocument } = useTrans(); @@ -82,35 +72,6 @@ export const DraggableDocGridContentList = ({ targetDocumentId, position: TreeViewMoveModeEnum.FIRST_CHILD, }); - - void queryClient.invalidateQueries({ - queryKey: [KEY_LIST_DOC], - }); - const accesses = await getDocAccesses({ - docId: sourceDocumentId, - }); - - const invitationsResponse = await getDocInvitations({ - docId: sourceDocumentId, - page: 1, - }); - - const invitations = invitationsResponse.results; - - await Promise.all([ - ...invitations.map((invitation) => - handleDeleteInvitation({ - docId: sourceDocumentId, - invitationId: invitation.id, - }), - ), - ...accesses.map((access) => - handleDeleteAccess({ - docId: sourceDocumentId, - accessId: access.id, - }), - ), - ]); } finally { onDragData.current = null; } @@ -207,25 +168,13 @@ export const DraggableDocGridContentList = ({ {modalConfirmation.isOpen && ( - - }} - /> - + { - void handleMoveDoc(); - }} /> )} diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocMoveModal.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocMoveModal.tsx index c05e2375..012c6880 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocMoveModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocMoveModal.tsx @@ -1,4 +1,9 @@ -import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; +import { + Button, + Modal, + ModalSize, + useModal, +} from '@gouvfr-lasuite/cunningham-react'; import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit'; import Image from 'next/image'; import { useState } from 'react'; @@ -14,6 +19,7 @@ import EmptySearchIcon from '@/docs/doc-search/assets/illustration-docs-empty.pn import { useResponsiveStore } from '@/stores'; import { DocsGridItemDate, DocsGridItemTitle } from './DocsGridItem'; +import { ModalConfirmationMoveDoc } from './ModalConfimationMoveDoc'; export const DocMoveModalStyle = createGlobalStyle` .c__modal--full .c__modal__scroller { @@ -67,6 +73,8 @@ export const DocMoveModal = ({ const [docSelected, setDocSelected] = useState(); const { untitledDocument } = useTrans(); const docTitle = doc.title || untitledDocument; + const docTargetTitle = docSelected?.title || untitledDocument; + const modalConfirmation = useModal(); const { mutate: moveDoc } = useMoveDoc(true); const [search, setSearch] = useState(''); const { isDesktop } = useResponsiveStore(); @@ -77,6 +85,7 @@ export const DocMoveModal = ({ }; const handleMoveDoc = () => { + modalConfirmation.onClose(); if (!docSelected?.id) { return; } @@ -105,6 +114,11 @@ export const DocMoveModal = ({ variant="primary" fullWidth onClick={() => { + if (doc.nb_accesses_direct > 1) { + modalConfirmation.open(); + return; + } + handleMoveDoc(); }} disabled={!docSelected} @@ -255,6 +269,14 @@ export const DocMoveModal = ({ + {modalConfirmation.isOpen && ( + + )} ); }; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/ModalConfimationMoveDoc.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/ModalConfimationMoveDoc.tsx new file mode 100644 index 00000000..aaf310c0 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/ModalConfimationMoveDoc.tsx @@ -0,0 +1,41 @@ +import { Trans, useTranslation } from 'react-i18next'; + +import { AlertModal, Text } from '@/components'; + +interface ModalConfirmationMoveDocProps { + targetDocumentTitle: string; + onConfirm: () => void; + onClose: () => void; + isOpen: boolean; +} + +export const ModalConfirmationMoveDoc = ({ + targetDocumentTitle, + onClose, + onConfirm, + isOpen, +}: ModalConfirmationMoveDocProps) => { + const { t } = useTranslation(); + + return ( + + }} + /> + + } + confirmLabel={t('Move')} + onConfirm={onConfirm} + /> + ); +};