🛂(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:
@@ -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}` })
|
||||
|
||||
@@ -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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
@@ -43,12 +59,13 @@ export const AlertModal = ({
|
||||
</Text>
|
||||
}
|
||||
rightActions={
|
||||
<>
|
||||
<Box $direction="row" $gap="small">
|
||||
<Button
|
||||
aria-label={`${t('Cancel')} - ${title}`}
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => onClose()}
|
||||
autoFocus
|
||||
onClick={onClose}
|
||||
>
|
||||
{cancelLabel ?? t('Cancel')}
|
||||
</Button>
|
||||
@@ -59,7 +76,7 @@ export const AlertModal = ({
|
||||
>
|
||||
{confirmLabel ?? t('Confirm')}
|
||||
</Button>
|
||||
</>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box className="--docs--alert-modal">
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './useDocChildren';
|
||||
export * from './useDocTree';
|
||||
export * from './useMove';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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<HTMLElement | null>(null);
|
||||
const treeContext = useTreeContext<Doc | null>();
|
||||
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
|
||||
>
|
||||
<Box $direction="row" $align="center" $width="100%">
|
||||
|
||||
@@ -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<DocDragEndData | null>(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 = ({
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
{modalConfirmation.isOpen && (
|
||||
<AlertModal
|
||||
{...modalConfirmation}
|
||||
title={t('Move 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:
|
||||
onDragData.current?.target.title ?? untitledDocument,
|
||||
}}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</Text>
|
||||
<ModalConfirmationMoveDoc
|
||||
isOpen={modalConfirmation.isOpen}
|
||||
onClose={modalConfirmation.onClose}
|
||||
onConfirm={handleMoveDoc}
|
||||
targetDocumentTitle={
|
||||
onDragData.current?.target.title || untitledDocument
|
||||
}
|
||||
confirmLabel={t('Move')}
|
||||
onConfirm={() => {
|
||||
void handleMoveDoc();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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<Doc>();
|
||||
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 = ({
|
||||
</QuickSearch>
|
||||
</Box>
|
||||
</Modal>
|
||||
{modalConfirmation.isOpen && (
|
||||
<ModalConfirmationMoveDoc
|
||||
isOpen={modalConfirmation.isOpen}
|
||||
onClose={modalConfirmation.onClose}
|
||||
onConfirm={handleMoveDoc}
|
||||
targetDocumentTitle={docTargetTitle}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user