(frontend) leave a document feature

When we were visiting a public document, it was part of
our list of documents, and we couldn't leave it.
Now, we can leave a public document, and it will
be removed from our list of documents.
We coupled it with remove access to a document if
you had access to it, and you can also leave a
document without having to pass through the share
modal, which is a better user experience.
This commit is contained in:
Anthony LC
2026-06-11 16:49:09 +02:00
parent 5aa1402aa3
commit 4c3b17a541
12 changed files with 527 additions and 105 deletions
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to
### Added ### Added
- ✨(frontend) leave a document #2410
- ✨(frontend) add top parent on sub docs search #1952 - ✨(frontend) add top parent on sub docs search #1952
- ✨(frontend) unauthenticated users can search #2407 - ✨(frontend) unauthenticated users can search #2407
@@ -4,6 +4,7 @@ import {
clickInEditorShareButton, clickInEditorShareButton,
createDoc, createDoc,
getGridRow, getGridRow,
getOtherBrowserName,
verifyDocName, verifyDocName,
} from './utils-common'; } from './utils-common';
import { addNewMember, connectOtherUserToDoc } from './utils-share'; import { addNewMember, connectOtherUserToDoc } from './utils-share';
@@ -166,50 +167,85 @@ test.describe('Document grid item options', () => {
await expect(page.getByText(docTitle)).toBeHidden(); await expect(page.getByText(docTitle)).toBeHidden();
}); });
test("it checks if the delete option is disabled if we don't have the destroy capability", async ({ test('it checks the leave feature', async ({ page, browserName }) => {
page, const [docTitle] = await createDoc(page, `leave doc`, browserName);
}) => {
await page.route(/.*\/api\/v1.0\/documents\/\?page=1/, async (route) => {
await route.fulfill({
json: {
results: [
{
id: 'mocked-document-id',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
link_reach: 'restricted',
created_at: '2021-09-01T09:00:00Z',
user_roles: ['editor'],
user_role: 'editor',
},
],
},
});
});
await page.goto('/'); await page.goto('/');
const button = page // Assert the document is visible in the grid results
.getByTestId(`docs-grid-actions-button-mocked-document-id`) await expect(
.first(); page.getByLabel('Documents grid').getByText(docTitle),
await expect(button).toBeVisible(); ).toBeVisible();
await button.click();
const removeButton = page const row = await getGridRow(page, docTitle);
.getByTestId(`docs-grid-actions-remove-mocked-document-id`) await row.getByRole('button', { name: /Open the menu of actions/ }).click();
.first();
await expect(removeButton).toBeVisible(); await page.getByRole('menuitem', { name: 'Leave' }).click();
await removeButton.isDisabled();
const modal = page.getByRole('dialog', {
name: 'Confirmation to leave the document',
});
await expect(
modal.getByRole('heading', { name: 'Leave a doc' }),
).toBeVisible();
// Check the message when the user is the unique owner
await expect(
modal.getByText(
'You cannot leave this document because you are the unique owner.',
),
).toBeVisible();
await expect(
modal.getByRole('button', {
name: 'Confirm leaving the document',
}),
).toBeHidden();
await modal.getByRole('button', { name: 'Close the leave modal' }).click();
// Assert the document is visible in the search results
await page.getByRole('button', { name: 'Search docs' }).click();
await page.getByPlaceholder('Type the name of a document').fill(docTitle);
await page.getByRole('option').getByText(docTitle).click();
// We share the doc with another user with owner role
const otherBrowserName = getOtherBrowserName(browserName);
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Owner', otherBrowserName);
await expect(
page
.getByRole('listbox', { name: 'Suggestions' })
.getByText(new RegExp(otherBrowserName)),
).toBeVisible();
await page.getByRole('button', { name: 'Close the share modal' }).click();
// Leave the document
await page
.getByRole('button', { name: /Open the document options/ })
.click();
await page.getByRole('menuitem', { name: 'Leave' }).click();
// Check the message
await expect(
modal.getByText(
'This document and all the sub-documents will no longer be visible',
),
).toBeVisible();
await page
.getByRole('button', { name: 'Confirm leaving the document' })
.click();
// We are on the grid page and the document is not visible in the grid results
await expect(page.getByLabel('Documents grid')).toBeVisible();
await expect(
page.getByLabel('Documents grid').getByText(docTitle),
).toBeHidden();
// We search the document and it's not visible in the search results either
await page.getByRole('button', { name: 'Search docs' }).click();
await page.getByPlaceholder('Type the name of a document').fill(docTitle);
await expect(page.getByRole('option').getByText(docTitle)).toBeHidden();
}); });
}); });
@@ -0,0 +1,10 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.6663 1.33337C13.1968 1.33337 13.7053 1.54424 14.0804 1.91931C14.4555 2.29438 14.6663 2.80294 14.6663 3.33337V4.00004C14.6663 4.36823 14.3679 4.66671 13.9997 4.66671C13.6315 4.66671 13.333 4.36823 13.333 4.00004V3.33337C13.333 3.15656 13.2627 2.98704 13.1377 2.86202C13.0127 2.737 12.8432 2.66671 12.6663 2.66671H3.33301C3.1562 2.66671 2.98668 2.737 2.86165 2.86202C2.73663 2.98704 2.66634 3.15656 2.66634 3.33337V12.6667C2.66634 12.8435 2.73663 13.013 2.86165 13.1381C2.98668 13.2631 3.1562 13.3334 3.33301 13.3334H12.6663C12.8432 13.3334 13.0127 13.2631 13.1377 13.1381C13.2627 13.013 13.333 12.8435 13.333 12.6667V12C13.333 11.6319 13.6315 11.3334 13.9997 11.3334C14.3679 11.3334 14.6663 11.6319 14.6663 12V12.6667C14.6663 13.1971 14.4555 13.7057 14.0804 14.0808C13.7053 14.4558 13.1968 14.6667 12.6663 14.6667H3.33301C2.80257 14.6667 2.29402 14.4558 1.91895 14.0808C1.54387 13.7057 1.33301 13.1971 1.33301 12.6667V3.33337C1.33301 2.80294 1.54387 2.29438 1.91895 1.91931C2.29402 1.54424 2.80257 1.33337 3.33301 1.33337H12.6663Z"
fill="currentColor"
/>
<path
d="M10.195 4.86202C10.4553 4.60167 10.8773 4.60167 11.1377 4.86202L13.8044 7.52869C13.8362 7.56056 13.8647 7.59574 13.8896 7.6335C13.9061 7.65849 13.9205 7.68445 13.9333 7.71098C13.9478 7.7411 13.9605 7.77229 13.9704 7.80473C13.9745 7.81825 13.9775 7.83203 13.9808 7.84574C13.9926 7.89533 13.9997 7.94685 13.9997 8.00004C13.9997 8.0376 13.9952 8.0742 13.9893 8.11007C13.9845 8.13855 13.9788 8.16691 13.9704 8.1947C13.9605 8.22716 13.9478 8.25831 13.9333 8.28845C13.9287 8.29789 13.924 8.30719 13.9189 8.31645C13.9003 8.35083 13.8786 8.38305 13.8545 8.41345C13.8388 8.43325 13.8227 8.45309 13.8044 8.4714L11.1377 11.1381C10.8773 11.3984 10.4553 11.3984 10.195 11.1381C9.93464 10.8777 9.93464 10.4557 10.195 10.1954L11.7236 8.66671H5.99967C5.63148 8.66671 5.33301 8.36823 5.33301 8.00004C5.33301 7.63185 5.63148 7.33337 5.99967 7.33337H11.7236L10.195 5.80473C9.93464 5.54438 9.93464 5.12237 10.195 4.86202Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,4 +1,4 @@
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react'; import { Button } from '@gouvfr-lasuite/cunningham-react';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuItem, DropdownMenuItem,
@@ -18,6 +18,7 @@ import SharedSVG from '@/assets/icons/ui-kit/group.svg';
import HistorySVG from '@/assets/icons/ui-kit/history.svg'; import HistorySVG from '@/assets/icons/ui-kit/history.svg';
import KeepSVG from '@/assets/icons/ui-kit/keep.svg'; import KeepSVG from '@/assets/icons/ui-kit/keep.svg';
import KeepOffSVG from '@/assets/icons/ui-kit/keep_off.svg'; import KeepOffSVG from '@/assets/icons/ui-kit/keep_off.svg';
import LeaveSVG from '@/assets/icons/ui-kit/leave.svg';
import MarkdownCopySVG from '@/assets/icons/ui-kit/markdown_copy.svg'; import MarkdownCopySVG from '@/assets/icons/ui-kit/markdown_copy.svg';
import MoreSVG from '@/assets/icons/ui-kit/more_horiz.svg'; import MoreSVG from '@/assets/icons/ui-kit/more_horiz.svg';
import { import {
@@ -60,6 +61,16 @@ const DocShareModal = dynamic(
{ ssr: false }, { ssr: false },
); );
const ConfirmationLeaveModal = dynamic(
() =>
import('@/docs/doc-share/components/ConfirmationLeaveModal').then(
(mod) => ({
default: mod.ConfirmationLeaveModal,
}),
),
{ ssr: false },
);
const ModalExport = const ModalExport =
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false' process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false'
? dynamic( ? dynamic(
@@ -88,16 +99,18 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const treeContext = useTreeContext<Doc>(); const treeContext = useTreeContext<Doc>();
const router = useRouter(); const router = useRouter();
const { isTopRoot } = useDocUtils(doc); const { isTopRoot } = useDocUtils(doc);
const isTopParent = doc.id === treeContext?.root?.id; // it can be a child but not for the current user
const { authenticated } = useAuth(); const { authenticated } = useAuth();
const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard(); const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard();
const [openDropdown, setOpenDropdown] = useState(false); const [openDropdown, setOpenDropdown] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalExportOpen, setIsModalExportOpen] = useState(false); const [isModalExportOpen, setIsModalExportOpen] = useState(false);
const shareModal = useModal(); const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalHistoryOpen, setIsModalHistoryOpen] = useState(false);
const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false);
const [isPresenterOpen, setIsPresenterOpen] = useState(false); const [isPresenterOpen, setIsPresenterOpen] = useState(false);
const selectHistoryModal = useModal();
const { restoreFocus } = useFocusStore(); const { restoreFocus, addLastFocus } = useFocusStore();
const { isMobile } = useResponsiveStore(); const { isMobile } = useResponsiveStore();
const copyDocLink = useCopyDocLink(doc.id); const copyDocLink = useCopyDocLink(doc.id);
const { mutate: duplicateDoc } = useDuplicateDoc({ const { mutate: duplicateDoc } = useDuplicateDoc({
@@ -151,7 +164,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
label: t('Share'), label: t('Share'),
icon: <SharedSVG width={24} height={24} aria-hidden="true" />, icon: <SharedSVG width={24} height={24} aria-hidden="true" />,
callback: () => { callback: () => {
shareModal.open(); setIsModalShareOpen(true);
}, },
isHidden: !isTopRoot || !authenticated, isHidden: !isTopRoot || !authenticated,
}, },
@@ -176,7 +189,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
icon: <HistorySVG width={24} height={24} aria-hidden="true" />, icon: <HistorySVG width={24} height={24} aria-hidden="true" />,
isDisabled: !doc.abilities.versions_list, isDisabled: !doc.abilities.versions_list,
callback: () => { callback: () => {
selectHistoryModal.open(); setIsModalHistoryOpen(true);
}, },
isHidden: isMobile || !doc.abilities.versions_list, isHidden: isMobile || !doc.abilities.versions_list,
showSeparator: true, showSeparator: true,
@@ -195,6 +208,19 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
isHidden: !doc.abilities.duplicate, isHidden: !doc.abilities.duplicate,
showSeparator: true, showSeparator: true,
}, },
{
label: t('Leave'),
icon: <LeaveSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
setIsModalLeaveOpen(true);
},
/**
* A user can only leave a top parent because we cannot
* leave a child if the parent is not left.
* ⚠️ This doc can still be a child for other users.
*/
isHidden: !isTopParent,
},
{ {
label: t('Delete'), label: t('Delete'),
icon: <DeleteSVG width={24} height={24} aria-hidden="true" />, icon: <DeleteSVG width={24} height={24} aria-hidden="true" />,
@@ -223,6 +249,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
setOpenDropdown((o) => !o); setOpenDropdown((o) => !o);
addLastFocus(e.currentTarget);
}} }}
/> />
</DropdownMenu> </DropdownMenu>
@@ -244,7 +271,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}} }}
doc={doc} doc={doc}
onSuccess={() => { onSuccess={() => {
const isTopParent = doc.id === treeContext?.root?.id;
const parentId = const parentId =
treeContext?.treeData.getParentId(doc.id) || treeContext?.treeData.getParentId(doc.id) ||
treeContext?.root?.id; treeContext?.root?.id;
@@ -261,26 +287,34 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}} }}
/> />
)} )}
{selectHistoryModal.isOpen && ( {isModalHistoryOpen && (
<ModalSelectVersion <ModalSelectVersion
onClose={() => { onClose={() => {
selectHistoryModal.close(); setIsModalHistoryOpen(false);
restoreFocus(); restoreFocus();
}} }}
doc={doc} doc={doc}
/> />
)} )}
{shareModal.isOpen && ( {isModalShareOpen && (
<DocShareModal <DocShareModal
onClose={() => { onClose={() => {
shareModal.close(); setIsModalShareOpen(false);
restoreFocus();
}}
doc={doc}
isRootDoc={isTopParent}
/>
)}
{isModalLeaveOpen && (
<ConfirmationLeaveModal
onClose={() => {
setIsModalLeaveOpen(false);
restoreFocus(); restoreFocus();
}} }}
doc={doc} doc={doc}
isRootDoc={treeContext?.root?.id === doc.id}
/> />
)} )}
{isPresenterOpen && ( {isPresenterOpen && (
<PresenterOverlay <PresenterOverlay
doc={doc} doc={doc}
@@ -88,7 +88,9 @@ export interface Doc {
favorite: boolean; favorite: boolean;
formatted_content: boolean; formatted_content: boolean;
invite_owner: boolean; invite_owner: boolean;
leave: boolean;
link_configuration: boolean; link_configuration: boolean;
link_select_options: LinkSelectOption;
media_auth: boolean; media_auth: boolean;
move: boolean; move: boolean;
partial_update: boolean; partial_update: boolean;
@@ -99,7 +101,6 @@ export interface Doc {
versions_destroy: boolean; versions_destroy: boolean;
versions_list: boolean; versions_list: boolean;
versions_retrieve: boolean; versions_retrieve: boolean;
link_select_options: LinkSelectOption;
}; };
} }
@@ -164,7 +164,7 @@ const DocSearchModalGlobal = ({
$align="center" $align="center"
$justify="center" $justify="center"
> >
<Image width={320} src={EmptySearchIcon} alt="" /> <Image width={320} src={EmptySearchIcon} alt="" priority />
</Box> </Box>
)} )}
{search && ( {search && (
@@ -0,0 +1,45 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_DOC } from '@/docs/doc-management/api';
interface LeaveDocProps {
docId: string;
}
export const leaveDoc = async ({ docId }: LeaveDocProps): Promise<void> => {
const response = await fetchAPI(`documents/${docId}/leave/`, {
method: 'POST',
});
if (!response.ok) {
throw new APIError(
'Failed to leave the document',
await errorCauses(response),
);
}
};
type UseLeaveDocOptions = UseMutationOptions<void, APIError, LeaveDocProps>;
export const useLeaveDoc = (options?: UseLeaveDocOptions) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, LeaveDocProps>({
mutationFn: leaveDoc,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, onMutateResult, context);
}
},
});
};
@@ -0,0 +1,257 @@
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { useRouter } from 'next/router';
import { useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { Box, ButtonCloseModal, Text } from '@/components';
import { useAuth } from '@/features/auth';
import { Doc } from '../../doc-management';
import { useDeleteDocAccess, useDocAccesses } from '../api';
import { useLeaveDoc } from '../api/useLeaveDoc';
import { useWhoAmI } from '../hooks/useWhoAmI';
const ModalStyle = createGlobalStyle`
.c__modal__footer {
margin-top: 0;
}
`;
interface ConfirmationLeaveModalProps {
doc: Doc;
onClose: () => void;
}
export const ConfirmationLeaveModal = ({
doc,
onClose,
}: ConfirmationLeaveModalProps) => {
const { t } = useTranslation();
const router = useRouter();
const { mutate: leaveDoc, isPending: isLeavePending } = useLeaveDoc({
onSuccess: () => {
if (router.pathname !== `/`) {
void router.push('/');
} else {
onClose();
}
},
});
return (
<Modal
isOpen
closeOnClickOutside
hideCloseButton
onClose={onClose}
// TODO: add a fix on the Modal component on Cunningham side
// If aria-label is not set the modal add by default [object Object]
aria-label={t('Confirmation to leave the document')}
aria-labelledby="modal-leave-doc-title"
aria-describedby="modal-leave-doc-desc"
rightActions={
<>
{doc.abilities.leave ? (
<ButtonsLeaveDoc
leave={() => leaveDoc({ docId: doc.id })}
isPending={isLeavePending}
onClose={onClose}
/>
) : (
<ButtonsLeaveMemberDoc
doc={doc}
leave={() => leaveDoc({ docId: doc.id })}
isLeavePending={isLeavePending}
onClose={onClose}
/>
)}
</>
}
size={ModalSize.MEDIUM}
title={
<>
<Text
$size="h6"
as="h2"
id="modal-leave-doc-title"
$margin="0"
$align="flex-start"
>
{t('Leave a doc')}
</Text>
<Box $position="absolute" $css="top: 4px; right: 4px;">
<ButtonCloseModal
aria-label={t('Close the leave modal')}
onClick={onClose}
/>
</Box>
</>
}
>
<ModalStyle />
<Text
id="modal-leave-doc-desc"
className="--docs--modal-leave-doc"
$size="sm"
$variation="secondary"
$display="inline-block"
as="p"
$css={css`
line-height: 1.6;
`}
>
{doc.abilities.leave ? (
<TextModal />
) : (
<TextModalMember docId={doc.id} />
)}
</Text>
</Modal>
);
};
const TextModal = () => {
const { t } = useTranslation();
return (
<Trans t={t}>
This document and <strong>all the sub-documents</strong> will no longer be
visible in your document list and in your search results. The rights that
were given to you on this document will be removed.
</Trans>
);
};
const TextModalMember = ({ docId }: { docId: string }) => {
const { t } = useTranslation();
const { access, isLoading, isError } = useGetMyAccess(docId);
const { isLastOwner } = useWhoAmI(access);
if (isLoading) {
return null;
}
if (isError) {
return (
<Trans t={t}>
Unable to verify your permissions on this document.{' '}
<strong>Leaving has been disabled</strong> until your access level can
be confirmed. Please try again later.
</Trans>
);
}
if (isLastOwner) {
return (
<Trans t={t}>
You cannot leave this document{' '}
<strong>because you are the unique owner</strong>. Add another user with
the owner role to ensure you can transfer ownership before leaving the
document.
</Trans>
);
}
return <TextModal />;
};
/**
* Simple button to leave a doc
* The user is not a member of the doc, he can just leave the doc
*/
const ButtonsLeaveDoc = ({
leave,
isPending,
onClose,
}: {
leave: () => void;
isPending: boolean;
onClose: () => void;
}) => {
const { t } = useTranslation();
return (
<>
<Button
aria-label={t('Cancel leaving the document')}
variant="secondary"
fullWidth
autoFocus
onClick={onClose}
disabled={isPending}
>
{t('Cancel')}
</Button>
<Button
aria-label={t('Confirm leaving the document')}
color="error"
fullWidth
onClick={leave}
disabled={isPending}
>
{t('Leave')}
</Button>
</>
);
};
/**
* The user is a member of the doc, he can leave the doc but
* if he is the last owner, he need to transfer the ownership
* before leaving the doc
*/
const ButtonsLeaveMemberDoc = ({
doc,
leave,
isLeavePending,
onClose,
}: {
doc: Doc;
leave: () => void;
isLeavePending: boolean;
onClose: () => void;
}) => {
const { mutateAsync: deleteDocAccess, isPending: isDeletePending } =
useDeleteDocAccess();
const { access, isLoading, isError } = useGetMyAccess(doc.id);
const { isLastOwner } = useWhoAmI(access);
/**
* If the user is the last owner, or ownership cannot be verified (loading or
* error), we don't display the leave button to avoid failing open.
*/
if (isLastOwner || isLoading || isError) {
return null;
}
return (
<ButtonsLeaveDoc
leave={async () => {
if (access) {
await deleteDocAccess({ docId: doc.id, accessId: access.id });
leave();
} else {
leave();
}
}}
isPending={isDeletePending || isLeavePending}
onClose={onClose}
/>
);
};
const useGetMyAccess = (docId: string) => {
const { user } = useAuth();
const {
data: accesses,
isLoading,
isError,
} = useDocAccesses({
docId,
});
const access = useMemo(() => {
return accesses?.find((access) => access.user.id === user?.id);
}, [accesses, user]);
return { access, isLoading, isError };
};
@@ -1,9 +1,17 @@
import { Access, Role } from '@/docs/doc-management'; import { Access, Role } from '@/docs/doc-management';
import { useAuth } from '@/features/auth'; import { useAuth } from '@/features/auth';
export const useWhoAmI = (access: Access) => { export const useWhoAmI = (access?: Access) => {
const { user } = useAuth(); const { user } = useAuth();
if (!access) {
return {
isLastOwner: false,
isOtherOwner: false,
isMyself: false,
};
}
const isMyself = user?.id === access.user.id; const isMyself = user?.id === access.user.id;
const rolesAllowed = access.abilities.set_role_to; const rolesAllowed = access.abilities.set_role_to;
@@ -1,7 +1,8 @@
import { useModal } from '@gouvfr-lasuite/cunningham-react'; import { Button } from '@gouvfr-lasuite/cunningham-react';
import { DropdownMenu, DropdownMenuItem } from '@gouvfr-lasuite/ui-kit';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import ContentCopySVG from '@/assets/icons/ui-kit/content_copy.svg'; import ContentCopySVG from '@/assets/icons/ui-kit/content_copy.svg';
import DeleteSVG from '@/assets/icons/ui-kit/delete.svg'; import DeleteSVG from '@/assets/icons/ui-kit/delete.svg';
@@ -9,8 +10,8 @@ import DocMoveInSVG from '@/assets/icons/ui-kit/doc-move-in.svg';
import GroupSVG from '@/assets/icons/ui-kit/group.svg'; import GroupSVG from '@/assets/icons/ui-kit/group.svg';
import KeepSVG from '@/assets/icons/ui-kit/keep.svg'; import KeepSVG from '@/assets/icons/ui-kit/keep.svg';
import KeepOffSVG from '@/assets/icons/ui-kit/keep_off.svg'; import KeepOffSVG from '@/assets/icons/ui-kit/keep_off.svg';
import MoreHorizSVG from '@/assets/icons/ui-kit/more_horiz.svg'; import LeaveSVG from '@/assets/icons/ui-kit/leave.svg';
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components'; import MoreSVG from '@/assets/icons/ui-kit/more_horiz.svg';
import { import {
Doc, Doc,
KEY_LIST_DOC, KEY_LIST_DOC,
@@ -41,17 +42,28 @@ const ModalRemoveDoc = dynamic(
{ ssr: false }, { ssr: false },
); );
const ConfirmationLeaveModal = dynamic(
() =>
import('@/docs/doc-share/components/ConfirmationLeaveModal').then(
(mod) => ({
default: mod.ConfirmationLeaveModal,
}),
),
{ ssr: false },
);
interface DocsGridActionsProps { interface DocsGridActionsProps {
doc: Doc; doc: Doc;
} }
export const DocsGridActions = ({ doc }: DocsGridActionsProps) => { export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const restoreFocus = useFocusStore((state) => state.restoreFocus); const { restoreFocus, addLastFocus } = useFocusStore();
const [openDropdown, setOpenDropdown] = useState(false);
const deleteModal = useModal(); const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const shareModal = useModal(); const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false);
const importModal = useModal(); const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalMoveOpen, setIsModalMoveOpen] = useState(false);
const { untitledDocument } = useTrans(); const { untitledDocument } = useTrans();
const { mutate: duplicateDoc } = useDuplicateDoc({ const { mutate: duplicateDoc } = useDuplicateDoc({
@@ -69,7 +81,7 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_FAVORITE_DOC], listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_FAVORITE_DOC],
}); });
const options: DropdownMenuOption[] = [ const options: DropdownMenuItem[] = [
{ {
label: doc.is_favorite ? t('Unpin') : t('Pin'), label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: doc.is_favorite ? ( icon: doc.is_favorite ? (
@@ -91,7 +103,7 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
label: t('Share'), label: t('Share'),
icon: <GroupSVG width={24} height={24} aria-hidden="true" />, icon: <GroupSVG width={24} height={24} aria-hidden="true" />,
callback: () => { callback: () => {
shareModal.open(); setIsModalShareOpen(true);
}, },
testId: `docs-grid-actions-share-${doc.id}`, testId: `docs-grid-actions-share-${doc.id}`,
@@ -100,15 +112,15 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
label: t('Move into a doc'), label: t('Move into a doc'),
icon: <DocMoveInSVG width={24} height={24} aria-hidden="true" />, icon: <DocMoveInSVG width={24} height={24} aria-hidden="true" />,
callback: () => { callback: () => {
importModal.open(); setIsModalMoveOpen(true);
}, },
testId: `docs-grid-actions-import-${doc.id}`, testId: `docs-grid-actions-move-${doc.id}`,
show: doc.abilities.move, isHidden: !doc.abilities.move,
}, },
{ {
label: t('Duplicate'), label: t('Duplicate'),
icon: <ContentCopySVG width={24} height={24} aria-hidden="true" />, icon: <ContentCopySVG width={24} height={24} aria-hidden="true" />,
disabled: !doc.abilities.duplicate, isDisabled: !doc.abilities.duplicate,
callback: () => { callback: () => {
duplicateDoc({ duplicateDoc({
docId: doc.id, docId: doc.id,
@@ -118,73 +130,88 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
}, },
showSeparator: true, showSeparator: true,
}, },
{
label: t('Leave'),
icon: <LeaveSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
setIsModalLeaveOpen(true);
},
},
{ {
label: t('Delete'), label: t('Delete'),
icon: <DeleteSVG width={24} height={24} aria-hidden="true" />, icon: <DeleteSVG width={24} height={24} aria-hidden="true" />,
callback: () => deleteModal.open(), callback: () => {
disabled: !doc.abilities.destroy, setIsModalRemoveOpen(true);
},
isHidden: !doc.abilities.destroy,
testId: `docs-grid-actions-remove-${doc.id}`, testId: `docs-grid-actions-remove-${doc.id}`,
}, },
]; ];
const documentTitle = doc.title || untitledDocument;
const menuLabel = t('Open the menu of actions for the document: {{title}}', {
title: documentTitle,
});
return ( return (
<> <>
<DropdownMenu <DropdownMenu
options={options} options={options}
label={menuLabel} isOpen={openDropdown}
aria-label={t('More options')} shouldCloseOnInteractOutside={() => true}
buttonCss={css` onOpenChange={setOpenDropdown}
&:hover {
background-color: unset;
}
`}
> >
<Icon <Button
data-testid={`docs-grid-actions-button-${doc.id}`} data-testid={`docs-grid-actions-button-${doc.id}`}
icon={<MoreHorizSVG width={16} height={16} aria-hidden="true" />} aria-label={t(
$theme="brand" 'Open the menu of actions for the document: {{title}}',
$variation="secondary" {
$css={css` title: doc.title || untitledDocument,
cursor: pointer; },
&:hover { )}
opacity: 0.8; size="small"
} icon={<MoreSVG width={16} height={16} aria-hidden="true" />}
`} color="neutral"
aria-hidden="true" variant="tertiary"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setOpenDropdown((o) => !o);
addLastFocus(e.currentTarget);
}}
/> />
</DropdownMenu> </DropdownMenu>
{deleteModal.isOpen && ( {isModalRemoveOpen && (
<ModalRemoveDoc <ModalRemoveDoc
onClose={() => { onClose={() => {
deleteModal.onClose(); setIsModalRemoveOpen(false);
restoreFocus(); restoreFocus();
}} }}
doc={doc} doc={doc}
/> />
)} )}
{shareModal.isOpen && ( {isModalShareOpen && (
<DocShareModal <DocShareModal
doc={doc} doc={doc}
onClose={() => { onClose={() => {
shareModal.close(); setIsModalShareOpen(false);
restoreFocus(); restoreFocus();
}} }}
/> />
)} )}
{importModal.isOpen && ( {isModalLeaveOpen && (
<ConfirmationLeaveModal
onClose={() => {
setIsModalLeaveOpen(false);
restoreFocus();
}}
doc={doc}
/>
)}
{isModalMoveOpen && (
<DocMoveModal <DocMoveModal
doc={doc} doc={doc}
onClose={() => { onClose={() => {
importModal.close(); setIsModalMoveOpen(false);
restoreFocus(); restoreFocus();
}} }}
isOpen={importModal.isOpen} isOpen={isModalMoveOpen}
/> />
)} )}
</> </>
@@ -278,6 +278,7 @@ export class ApiPlugin implements WorkboxPlugin {
formatted_content: true, formatted_content: true,
invite_owner: true, invite_owner: true,
link_configuration: true, link_configuration: true,
leave: true,
media_auth: true, media_auth: true,
move: true, move: true,
partial_update: true, partial_update: true,
@@ -1480,6 +1480,8 @@
"The export failed": "Lexportation a échoué", "The export failed": "Lexportation a échoué",
"The link sharing rules differ from the parent document": "Les règles de partage du lien diffèrent du document parent", "The link sharing rules differ from the parent document": "Les règles de partage du lien diffèrent du document parent",
"Thinking...": "Réflexion en cours...", "Thinking...": "Réflexion en cours...",
"You cannot leave this document <strong>because you are the unique owner</strong>. Add another user with the owner role to ensure you can transfer ownership before leaving the document.": "Vous ne pouvez pas quitter ce document <strong>parce que vous êtes le propriétaire unique</strong>. Ajoutez un autre utilisateur avec le rôle de propriétaire pour vous assurer de pouvoir transférer la propriété avant de quitter le document.",
"This document and <strong>all the sub-documents</strong> will no longer be visible in your document list and in your search results. The rights that were given to you on this document will be removed.": "Ce document et <strong>tous les sous-documents</strong> ne seront plus visibles dans votre liste de documents et dans vos résultats de recherche. Les droits qui vous ont été accordés sur ce document seront supprimés.",
"This document and <strong>any sub-documents</strong> will be placed in the trashbin. You can restore it within {{days}} days.": "Ce document et <strong>tous les sous-documents</strong> seront placés dans la corbeille. Vous pouvez le restaurer dans {{days}} jours.", "This document and <strong>any sub-documents</strong> will be placed in the trashbin. You can restore it within {{days}} days.": "Ce document et <strong>tous les sous-documents</strong> seront placés dans la corbeille. Vous pouvez le restaurer dans {{days}} jours.",
"This document will be placed in the trashbin. You can restore it within {{days}} days.": "Ce document sera placé dans la corbeille. Vous pouvez le restaurer sous {{days}} jours.", "This document will be placed in the trashbin. You can restore it within {{days}} days.": "Ce document sera placé dans la corbeille. Vous pouvez le restaurer sous {{days}} jours.",
"This file is flagged as unsafe.": "Ce fichier est marqué comme non sûr.", "This file is flagged as unsafe.": "Ce fichier est marqué comme non sûr.",