🛂(frontend) add confirmation modal on move modal

If the document has more than 1 direct access,we want to
display a confirmation modal before moving the document.
This is to prevent users from accidentally moving a document
that is shared with multiple people.
The accesses and invitations will be removed from the
document.
This commit is contained in:
Anthony LC
2026-02-20 17:23:39 +01:00
parent 2718321fbe
commit 3411df09ae
10 changed files with 211 additions and 108 deletions

View File

@@ -3,10 +3,12 @@ import { expect, test } from '@playwright/test';
import { import {
createDoc, createDoc,
getGridRow, getGridRow,
getOtherBrowserName,
mockedListDocs, mockedListDocs,
toggleHeaderMenu, toggleHeaderMenu,
verifyDocName, verifyDocName,
} from './utils-common'; } from './utils-common';
import { addNewMember } from './utils-share';
import { createRootSubPage } from './utils-sub-pages'; import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc grid move', () => { test.describe('Doc grid move', () => {
@@ -178,6 +180,15 @@ test.describe('Doc grid move', () => {
await page.goto('/'); await page.goto('/');
const [titleDoc1] = await createDoc(page, 'Draggable doc', browserName, 1); 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(); await page.getByRole('button', { name: 'Back to homepage' }).click();
const [titleDoc2] = await createDoc(page, 'Droppable doc', browserName, 1); const [titleDoc2] = await createDoc(page, 'Droppable doc', browserName, 1);
@@ -209,6 +220,18 @@ test.describe('Doc grid move', () => {
// Validate the move action // Validate the move action
await page.keyboard.press('Enter'); 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 expect(docsGrid.getByText(titleDoc1)).toBeHidden();
await docsGrid await docsGrid
.getByRole('link', { name: `Open document ${titleDoc2}` }) .getByRole('link', { name: `Open document ${titleDoc2}` })

View File

@@ -1,5 +1,5 @@
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { ReactNode } from 'react'; import { ReactNode, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box } from '../Box'; import { Box } from '../Box';
@@ -25,6 +25,22 @@ export const AlertModal = ({
title, title,
}: AlertModalProps) => { }: AlertModalProps) => {
const { t } = useTranslation(); 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 ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
@@ -43,12 +59,13 @@ export const AlertModal = ({
</Text> </Text>
} }
rightActions={ rightActions={
<> <Box $direction="row" $gap="small">
<Button <Button
aria-label={`${t('Cancel')} - ${title}`} aria-label={`${t('Cancel')} - ${title}`}
variant="secondary" variant="secondary"
fullWidth fullWidth
onClick={() => onClose()} autoFocus
onClick={onClose}
> >
{cancelLabel ?? t('Cancel')} {cancelLabel ?? t('Cancel')}
</Button> </Button>
@@ -59,7 +76,7 @@ export const AlertModal = ({
> >
{confirmLabel ?? t('Confirm')} {confirmLabel ?? t('Confirm')}
</Button> </Button>
</> </Box>
} }
> >
<Box className="--docs--alert-modal"> <Box className="--docs--alert-modal">

View File

@@ -7,6 +7,7 @@ export * from './useDocOptions';
export * from './useDocs'; export * from './useDocs';
export * from './useDocsFavorite'; export * from './useDocsFavorite';
export * from './useDuplicateDoc'; export * from './useDuplicateDoc';
export * from './useMoveDoc';
export * from './useRestoreDoc'; export * from './useRestoreDoc';
export * from './useSubDocs'; export * from './useSubDocs';
export * from './useUpdateDoc'; export * from './useUpdateDoc';

View File

@@ -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<void> => {
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<void>;
};
export function useMoveDoc(deleteAccessOnMove = false) {
const queryClient = useQueryClient();
const { mutate: handleDeleteInvitation } = useDeleteDocInvitation();
const { mutate: handleDeleteAccess } = useDeleteDocAccess();
return useMutation<void, APIError, MoveDocParam>({
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,
}),
),
]);
},
});
}

View File

@@ -1,3 +1,2 @@
export * from './useDocChildren'; export * from './useDocChildren';
export * from './useDocTree'; export * from './useDocTree';
export * from './useMove';

View File

@@ -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<void> => {
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<void>;
};
export function useMoveDoc() {
return useMutation<void, APIError, MoveDocParam>({
mutationFn: moveDoc,
});
}

View File

@@ -12,10 +12,14 @@ import { css } from 'styled-components';
import { Box, Overlayer, StyledLink } from '@/components'; import { Box, Overlayer, StyledLink } from '@/components';
import { useCunninghamTheme } from '@/cunningham'; 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 { KEY_DOC_TREE, useDocTree } from '../api/useDocTree';
import { useMoveDoc } from '../api/useMove';
import { findIndexInTree } from '../utils'; import { findIndexInTree } from '../utils';
import { DocSubPageItem } from './DocSubPageItem'; import { DocSubPageItem } from './DocSubPageItem';
@@ -28,6 +32,7 @@ type DocTreeProps = {
export const DocTree = ({ currentDoc }: DocTreeProps) => { export const DocTree = ({ currentDoc }: DocTreeProps) => {
const { spacingsTokens } = useCunninghamTheme(); const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsive(); const { isDesktop } = useResponsive();
const { untitledDocument } = useTrans();
const [treeRoot, setTreeRoot] = useState<HTMLElement | null>(null); const [treeRoot, setTreeRoot] = useState<HTMLElement | null>(null);
const treeContext = useTreeContext<Doc | null>(); const treeContext = useTreeContext<Doc | null>();
const router = useRouter(); const router = useRouter();
@@ -265,7 +270,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
ref={rootItemRef} ref={rootItemRef}
data-testid="doc-tree-root-item" data-testid="doc-tree-root-item"
role="treeitem" 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} aria-selected={rootIsSelected}
tabIndex={0} tabIndex={0}
onFocus={handleRootFocus} onFocus={handleRootFocus}
@@ -325,7 +330,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
); );
router.push(`/docs/${treeContext?.root?.id}`); 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 tabIndex={-1} // avoid double tabstop
> >
<Box $direction="row" $align="center" $width="100%"> <Box $direction="row" $align="center" $width="100%">

View File

@@ -2,19 +2,11 @@ import { DndContext, DragOverlay, Modifier } from '@dnd-kit/core';
import { getEventCoordinates } from '@dnd-kit/utilities'; import { getEventCoordinates } from '@dnd-kit/utilities';
import { useModal } from '@gouvfr-lasuite/cunningham-react'; import { useModal } from '@gouvfr-lasuite/cunningham-react';
import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit'; import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react'; 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 { Card, Text } from '@/components';
import { Doc, KEY_LIST_DOC, useTrans } from '@/docs/doc-management'; import { Doc, useMoveDoc, useTrans } from '@/docs/doc-management';
import {
getDocAccesses,
getDocInvitations,
useDeleteDocAccess,
useDeleteDocInvitation,
} from '@/docs/doc-share';
import { useMoveDoc } from '@/docs/doc-tree';
import { useResponsiveStore } from '@/stores/useResponsiveStore'; import { useResponsiveStore } from '@/stores/useResponsiveStore';
import { DocDragEndData, useDragAndDrop } from '../hooks/useDragAndDrop'; import { DocDragEndData, useDragAndDrop } from '../hooks/useDragAndDrop';
@@ -22,6 +14,7 @@ import { DocDragEndData, useDragAndDrop } from '../hooks/useDragAndDrop';
import { DocsGridItem } from './DocsGridItem'; import { DocsGridItem } from './DocsGridItem';
import { Draggable } from './Draggable'; import { Draggable } from './Draggable';
import { Droppable } from './Droppable'; import { Droppable } from './Droppable';
import { ModalConfirmationMoveDoc } from './ModalConfimationMoveDoc';
const snapToTopLeft: Modifier = ({ const snapToTopLeft: Modifier = ({
activatorEvent, activatorEvent,
@@ -55,11 +48,8 @@ type DocGridContentListProps = {
export const DraggableDocGridContentList = ({ export const DraggableDocGridContentList = ({
docs, docs,
}: DocGridContentListProps) => { }: DocGridContentListProps) => {
const { mutateAsync: handleMove, isError } = useMoveDoc(); const { mutateAsync: handleMove, isError } = useMoveDoc(true);
const queryClient = useQueryClient();
const modalConfirmation = useModal(); const modalConfirmation = useModal();
const { mutate: handleDeleteInvitation } = useDeleteDocInvitation();
const { mutate: handleDeleteAccess } = useDeleteDocAccess();
const onDragData = useRef<DocDragEndData | null>(null); const onDragData = useRef<DocDragEndData | null>(null);
const { untitledDocument } = useTrans(); const { untitledDocument } = useTrans();
@@ -82,35 +72,6 @@ export const DraggableDocGridContentList = ({
targetDocumentId, targetDocumentId,
position: TreeViewMoveModeEnum.FIRST_CHILD, 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 { } finally {
onDragData.current = null; onDragData.current = null;
} }
@@ -207,25 +168,13 @@ export const DraggableDocGridContentList = ({
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
{modalConfirmation.isOpen && ( {modalConfirmation.isOpen && (
<AlertModal <ModalConfirmationMoveDoc
{...modalConfirmation} isOpen={modalConfirmation.isOpen}
title={t('Move document')} onClose={modalConfirmation.onClose}
description={ onConfirm={handleMoveDoc}
<Text $display="inline"> targetDocumentTitle={
<Trans onDragData.current?.target.title || untitledDocument
i18nKey="By moving this document to <strong>{{targetDocumentTitle}}</strong>, it will lose its current access rights and inherit the permissions of that document. <strong>This access change cannot be undone.</strong>"
values={{
targetDocumentTitle:
onDragData.current?.target.title ?? untitledDocument,
}}
components={{ strong: <strong /> }}
/>
</Text>
} }
confirmLabel={t('Move')}
onConfirm={() => {
void handleMoveDoc();
}}
/> />
)} )}
</> </>

View File

@@ -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 { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
import Image from 'next/image'; import Image from 'next/image';
import { useState } from 'react'; import { useState } from 'react';
@@ -14,6 +19,7 @@ import EmptySearchIcon from '@/docs/doc-search/assets/illustration-docs-empty.pn
import { useResponsiveStore } from '@/stores'; import { useResponsiveStore } from '@/stores';
import { DocsGridItemDate, DocsGridItemTitle } from './DocsGridItem'; import { DocsGridItemDate, DocsGridItemTitle } from './DocsGridItem';
import { ModalConfirmationMoveDoc } from './ModalConfimationMoveDoc';
export const DocMoveModalStyle = createGlobalStyle` export const DocMoveModalStyle = createGlobalStyle`
.c__modal--full .c__modal__scroller { .c__modal--full .c__modal__scroller {
@@ -67,6 +73,8 @@ export const DocMoveModal = ({
const [docSelected, setDocSelected] = useState<Doc>(); const [docSelected, setDocSelected] = useState<Doc>();
const { untitledDocument } = useTrans(); const { untitledDocument } = useTrans();
const docTitle = doc.title || untitledDocument; const docTitle = doc.title || untitledDocument;
const docTargetTitle = docSelected?.title || untitledDocument;
const modalConfirmation = useModal();
const { mutate: moveDoc } = useMoveDoc(true); const { mutate: moveDoc } = useMoveDoc(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const { isDesktop } = useResponsiveStore(); const { isDesktop } = useResponsiveStore();
@@ -77,6 +85,7 @@ export const DocMoveModal = ({
}; };
const handleMoveDoc = () => { const handleMoveDoc = () => {
modalConfirmation.onClose();
if (!docSelected?.id) { if (!docSelected?.id) {
return; return;
} }
@@ -105,6 +114,11 @@ export const DocMoveModal = ({
variant="primary" variant="primary"
fullWidth fullWidth
onClick={() => { onClick={() => {
if (doc.nb_accesses_direct > 1) {
modalConfirmation.open();
return;
}
handleMoveDoc(); handleMoveDoc();
}} }}
disabled={!docSelected} disabled={!docSelected}
@@ -255,6 +269,14 @@ export const DocMoveModal = ({
</QuickSearch> </QuickSearch>
</Box> </Box>
</Modal> </Modal>
{modalConfirmation.isOpen && (
<ModalConfirmationMoveDoc
isOpen={modalConfirmation.isOpen}
onClose={modalConfirmation.onClose}
onConfirm={handleMoveDoc}
targetDocumentTitle={docTargetTitle}
/>
)}
</> </>
); );
}; };

View File

@@ -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 (
<AlertModal
onClose={onClose}
isOpen={isOpen}
title={t('Move document')}
aria-label={t('Modal confirmation for moving a document')}
description={
<Text $display="inline">
<Trans
i18nKey="By moving this document to <strong>{{targetDocumentTitle}}</strong>, it will lose its current access rights and inherit the permissions of that document. <strong>This access change cannot be undone.</strong>"
values={{
targetDocumentTitle,
}}
components={{ strong: <strong /> }}
/>
</Text>
}
confirmLabel={t('Move')}
onConfirm={onConfirm}
/>
);
};