mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-25 19:15:14 +02:00
save
This commit is contained in:
@@ -1338,10 +1338,12 @@ class DocumentViewSet(
|
||||
document=duplicated_document,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
|
||||
serializer = serializers.DocumentSerializer(
|
||||
duplicated_document, context=self.get_serializer_context()
|
||||
)
|
||||
|
||||
return drf_response.Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def _duplicate_document(
|
||||
self,
|
||||
document_to_duplicate,
|
||||
@@ -1369,7 +1371,7 @@ class DocumentViewSet(
|
||||
user_role = document_to_duplicate.get_role(user)
|
||||
is_owner_or_admin = user_role in models.PRIVILEGED_ROLES
|
||||
|
||||
base64_yjs_content = document_to_duplicate.content
|
||||
base64_yjs_content = document_to_duplicate.content or ""
|
||||
|
||||
# Duplicate the document instance
|
||||
link_kwargs = (
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import Link from 'next/link';
|
||||
import styled, { RuleSet } from 'styled-components';
|
||||
import { useRouter } from 'next/router';
|
||||
import { type ComponentProps } from 'react';
|
||||
import styled, { type RuleSet } from 'styled-components';
|
||||
|
||||
export interface LinkProps {
|
||||
$css?: string | RuleSet<object>;
|
||||
@@ -14,3 +17,35 @@ export const StyledLink = styled(Link)<LinkProps>`
|
||||
display: flex;
|
||||
${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)}
|
||||
`;
|
||||
|
||||
type ButtonLinkProps = ComponentProps<typeof Button> & {
|
||||
href: string;
|
||||
};
|
||||
|
||||
export const ButtonLink = ({
|
||||
children,
|
||||
onClick,
|
||||
ref,
|
||||
...props
|
||||
}: ButtonLinkProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
onClick={(e) => {
|
||||
if (!e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
||||
if (props.href) {
|
||||
void router.push(props.href);
|
||||
}
|
||||
}
|
||||
onClick?.(e as React.MouseEvent<HTMLButtonElement, MouseEvent>);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -57,6 +57,18 @@
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkbox
|
||||
*/
|
||||
.c__checkbox:focus-within {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.c__checkbox .c__checkbox__wrapper:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal
|
||||
*/
|
||||
|
||||
@@ -273,7 +273,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
>
|
||||
{t('Export')}
|
||||
</Text>
|
||||
<Box $position="absolute" $css="top: 4px; right: 4px;">
|
||||
<Box $position="absolute" $css="top: 8px; right: 8px;">
|
||||
<ButtonCloseModal
|
||||
aria-label={t('Close the download modal')}
|
||||
onClick={() => onClose()}
|
||||
|
||||
@@ -188,6 +188,11 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
$align="center"
|
||||
$gap="4px"
|
||||
$minHeight="40px"
|
||||
$css={css`
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{!isTopRoot && <DocTitleEmojiPicker doc={doc} />}
|
||||
{/**
|
||||
|
||||
@@ -22,7 +22,7 @@ import LeaveSVG from '@/assets/icons/ui-kit/leave.svg';
|
||||
import MarkdownCopySVG from '@/assets/icons/ui-kit/markdown_copy.svg';
|
||||
import MoreSVG from '@/assets/icons/ui-kit/more_horiz.svg';
|
||||
import {
|
||||
Doc,
|
||||
type Doc,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
KEY_LIST_FAVORITE_DOC,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
useDocUtils,
|
||||
useDuplicateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
import { ConfirmationDuplicateModal } from '@/docs/doc-management/components/ConfirmationDuplicateModal';
|
||||
import { usePresenterStore } from '@/docs/doc-presenter/stores';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import { useFocusStore, useResponsiveStore } from '@/stores';
|
||||
@@ -96,6 +97,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
const { authenticated } = useAuth();
|
||||
const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard();
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const [isModalDuplicateOpen, setIsModalDuplicateOpen] = useState(false);
|
||||
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
|
||||
const [isModalExportOpen, setIsModalExportOpen] = useState(false);
|
||||
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
|
||||
@@ -192,11 +194,14 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
icon: <ContentCopySVG width={24} height={24} aria-hidden="true" />,
|
||||
isDisabled: !doc.abilities.duplicate,
|
||||
callback: () => {
|
||||
duplicateDoc({
|
||||
docId: doc.id,
|
||||
with_accesses: false,
|
||||
canSave: doc.abilities.partial_update,
|
||||
});
|
||||
if (doc.numchild) {
|
||||
setIsModalDuplicateOpen(true);
|
||||
} else {
|
||||
duplicateDoc({
|
||||
docId: doc.id,
|
||||
canSave: doc.abilities.partial_update,
|
||||
});
|
||||
}
|
||||
},
|
||||
isHidden: !doc.abilities.duplicate,
|
||||
showSeparator: true,
|
||||
@@ -247,6 +252,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
/>
|
||||
</DropdownMenu>
|
||||
|
||||
{isModalDuplicateOpen && (
|
||||
<ConfirmationDuplicateModal
|
||||
onClose={() => {
|
||||
setIsModalDuplicateOpen(false);
|
||||
restoreFocus();
|
||||
}}
|
||||
doc={doc}
|
||||
/>
|
||||
)}
|
||||
{isModalExportOpen && ModalExport && (
|
||||
<ModalExport
|
||||
onClose={() => {
|
||||
|
||||
+37
-12
@@ -3,7 +3,8 @@ import {
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import {
|
||||
UseMutationOptions,
|
||||
type InfiniteData,
|
||||
type UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
@@ -18,22 +19,24 @@ import { useProviderStore } from '../stores';
|
||||
import { Doc } from '../types';
|
||||
|
||||
import { useDocContentUpdate } from './useDocContentUpdate';
|
||||
import { KEY_LIST_DOC } from './useDocs';
|
||||
import { DocsParams, DocsResponse, KEY_LIST_DOC } from './useDocs';
|
||||
|
||||
interface DuplicateDocPayload {
|
||||
docId: string;
|
||||
with_accesses?: boolean;
|
||||
with_descendants?: boolean;
|
||||
}
|
||||
|
||||
type DuplicateDocResponse = Pick<Doc, 'id'>;
|
||||
type DuplicateDocResponse = Doc;
|
||||
|
||||
export const duplicateDoc = async ({
|
||||
docId,
|
||||
with_accesses,
|
||||
with_accesses = false,
|
||||
with_descendants = true,
|
||||
}: DuplicateDocPayload): Promise<DuplicateDocResponse> => {
|
||||
const response = await fetchAPI(`documents/${docId}/duplicate/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ with_accesses }),
|
||||
body: JSON.stringify({ with_accesses, with_descendants }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -84,14 +87,36 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
|
||||
return await duplicateDoc(variables);
|
||||
},
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
// Add the duplicated document to the list of documents in the cache
|
||||
// It avoids the need to refetch the list of documents after duplicating a document
|
||||
queryClient.setQueriesData<InfiniteData<DocsResponse>>(
|
||||
{
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
predicate: (query) => {
|
||||
const params = query.queryKey[1] as DocsParams | undefined;
|
||||
return params?.is_creator_me !== false;
|
||||
},
|
||||
},
|
||||
(oldData) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
const message = t('Document duplicated successfully!');
|
||||
toast(message, VariantType.SUCCESS, {
|
||||
duration: 3000,
|
||||
});
|
||||
const [firstPage, ...restPages] = oldData.pages;
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
pages: [
|
||||
{
|
||||
...firstPage,
|
||||
count: firstPage.count + 1,
|
||||
results: [data, ...firstPage.results],
|
||||
},
|
||||
...restPages,
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
void options?.onSuccess?.(data, variables, onMutateResult, context);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
type UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { Box, ButtonCloseModal, ButtonLink, Text } from '@/components';
|
||||
import { KEY_LIST_DOC_TRASHBIN } from '@/docs/docs-grid/api/useDocsTrashbin';
|
||||
|
||||
import { KEY_LIST_DOC, useDuplicateDoc } from '../api';
|
||||
import { useRemoveDoc } from '../api/useRemoveDoc';
|
||||
import { type Doc } from '../types';
|
||||
|
||||
const ModalStyle = createGlobalStyle`
|
||||
.c__modal__footer {
|
||||
margin-top: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
interface ConfirmationDuplicateModalProps {
|
||||
doc: Doc;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmationDuplicateModal = ({
|
||||
doc,
|
||||
onClose,
|
||||
}: ConfirmationDuplicateModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const [isWithSubdocs, setIsWithSubdocs] = useState(true);
|
||||
const { mutate: duplicateDoc } = useDuplicateDoc({
|
||||
onSuccess: (data) => {
|
||||
onClose();
|
||||
|
||||
const onCloseToast = (
|
||||
e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
|
||||
) => {
|
||||
const toastEl = e.currentTarget.closest<HTMLElement>('[role="alert"]');
|
||||
if (toastEl) {
|
||||
toastEl.style.display = 'none';
|
||||
}
|
||||
};
|
||||
toast(t('Document duplicated to My docs'), VariantType.INFO, {
|
||||
duration: 10000,
|
||||
actions: <ToastActions docId={data.id} onClose={onCloseToast} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
onClose={onClose}
|
||||
aria-label={t('Confirmation to duplicate the document')}
|
||||
aria-labelledby="modal-duplicate-doc-title"
|
||||
aria-describedby="modal-duplicate-doc-desc"
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
aria-label={t('Cancel the duplicate action')}
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
autoFocus
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t('Confirm the duplicate action')}
|
||||
color="error"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
duplicateDoc({
|
||||
docId: doc.id,
|
||||
with_descendants: isWithSubdocs,
|
||||
canSave: doc.abilities.partial_update,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('Duplicate')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
size={ModalSize.SMALL}
|
||||
title={
|
||||
<>
|
||||
<Text
|
||||
$size="h6"
|
||||
as="h2"
|
||||
id="modal-duplicate-doc-title"
|
||||
$margin="0"
|
||||
$align="flex-start"
|
||||
>
|
||||
{t('Duplicate')}
|
||||
</Text>
|
||||
<Box $position="absolute" $css="top: 8px; right: 8px;">
|
||||
<ButtonCloseModal
|
||||
aria-label={t('Close the duplicate modal')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ModalStyle />
|
||||
<Text
|
||||
id="modal-duplicate-doc-desc"
|
||||
className="--docs--modal-duplicate-doc"
|
||||
$size="sm"
|
||||
$variation="secondary"
|
||||
as="p"
|
||||
$margin="0"
|
||||
>
|
||||
{t('The copy will be private and added to My docs.')}
|
||||
</Text>
|
||||
<Box $margin={{ vertical: 'base' }}>
|
||||
<Checkbox
|
||||
label={t('Duplicate subdocs')}
|
||||
checked={isWithSubdocs}
|
||||
onChange={(e) => setIsWithSubdocs(e.target.checked)}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const ToastActions = ({
|
||||
docId,
|
||||
onClose,
|
||||
}: {
|
||||
docId: string;
|
||||
onClose: (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { mutate: removeDoc } = useRemoveDoc({
|
||||
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN],
|
||||
});
|
||||
const openRef = useRef<HTMLButtonElement & HTMLAnchorElement>(null);
|
||||
|
||||
// When the toast is displayed, we want to focus the "Open" button for accessibility reasons.
|
||||
useEffect(() => {
|
||||
openRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box $direction="row" $gap="xs">
|
||||
<ButtonLink
|
||||
ref={openRef}
|
||||
variant="tertiary"
|
||||
href={`/docs/${docId}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('Open')}
|
||||
</ButtonLink>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={(e) => {
|
||||
removeDoc({ docId });
|
||||
onClose(e);
|
||||
}}
|
||||
>
|
||||
{t('Undo')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+4
-17
@@ -12,7 +12,6 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
|
||||
import { useConfig } from '@/core';
|
||||
import { KEY_LIST_DOC_TRASHBIN } from '@/docs/docs-grid';
|
||||
import { useKeyboardAction } from '@/hooks';
|
||||
|
||||
import { KEY_DOC, KEY_LIST_FAVORITE_DOC } from '../api';
|
||||
import { KEY_LIST_DOC } from '../api/useDocs';
|
||||
@@ -73,25 +72,16 @@ export const ModalRemoveDoc = ({
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
const keyboardAction = useKeyboardAction();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
removeDoc({ docId: doc.id });
|
||||
};
|
||||
|
||||
const handleCloseKeyDown = keyboardAction(handleClose);
|
||||
const handleDeleteKeyDown = keyboardAction(handleDelete);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
onClose={handleClose}
|
||||
onClose={onClose}
|
||||
aria-label={t('Delete a doc')}
|
||||
rightActions={
|
||||
<>
|
||||
@@ -101,8 +91,7 @@ export const ModalRemoveDoc = ({
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
autoFocus
|
||||
onClick={handleClose}
|
||||
onKeyDown={handleCloseKeyDown}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
@@ -111,7 +100,6 @@ export const ModalRemoveDoc = ({
|
||||
color="error"
|
||||
fullWidth
|
||||
onClick={handleDelete}
|
||||
onKeyDown={handleDeleteKeyDown}
|
||||
>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
@@ -129,11 +117,10 @@ export const ModalRemoveDoc = ({
|
||||
>
|
||||
{t('Delete a doc')}
|
||||
</Text>
|
||||
<Box $position="absolute" $css="top: 4px; right: 4px;">
|
||||
<Box $position="absolute" $css="top: 8px; right: 8px;">
|
||||
<ButtonCloseModal
|
||||
aria-label={t('Close the delete modal')}
|
||||
onClick={handleClose}
|
||||
onKeyDown={handleCloseKeyDown}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
|
||||
+4
-9
@@ -4,7 +4,7 @@ import { useRouter } from 'next/router';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Text } from '@/components';
|
||||
import { ButtonLink, Text } from '@/components';
|
||||
import { useConfig } from '@/core/config/api/useConfig';
|
||||
import { useLeftPanelStore } from '@/features/left-panel/stores/useLeftPanelStore';
|
||||
import ArrowDownIcon from '@/icons/arrow-drop-down.svg';
|
||||
@@ -22,7 +22,6 @@ interface NewDocButtonProps {
|
||||
}
|
||||
|
||||
export const NewDocButton = ({ onClose }: NewDocButtonProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { currentDoc } = useDocStore();
|
||||
const { data: config } = useConfig();
|
||||
@@ -30,15 +29,11 @@ export const NewDocButton = ({ onClose }: NewDocButtonProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
<ButtonLink
|
||||
href="/docs/new"
|
||||
data-testid="new-doc-button"
|
||||
color="brand"
|
||||
onClick={(e) => {
|
||||
if (!e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void router.push('/docs/new');
|
||||
}
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
}}
|
||||
icon={<PlusIcon aria-hidden="true" width={24} height={24} />}
|
||||
@@ -51,7 +46,7 @@ export const NewDocButton = ({ onClose }: NewDocButtonProps) => {
|
||||
<Text $withThemeInherited $size="md" $weight="500">
|
||||
{t('New')}
|
||||
</Text>
|
||||
</Button>
|
||||
</ButtonLink>
|
||||
{isDropdownEnabled && <DropdownArrow />}
|
||||
</>
|
||||
);
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ const DocSearchModalGlobal = ({
|
||||
'Search documents by name, navigate using arrows, and select a result with Enter.',
|
||||
)}
|
||||
</Text>
|
||||
<Box $position="absolute" $css="top: 4px; right: 4px;">
|
||||
<Box $position="absolute" $css="top: 8px; right: 8px;">
|
||||
<ButtonCloseModal
|
||||
aria-label={t('Close the search modal')}
|
||||
onClick={modalProps.onClose}
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ export const ConfirmationLeaveModal = ({
|
||||
>
|
||||
{t('Leave a doc')}
|
||||
</Text>
|
||||
<Box $position="absolute" $css="top: 4px; right: 4px;">
|
||||
<Box $position="absolute" $css="top: 8px; right: 8px;">
|
||||
<ButtonCloseModal
|
||||
aria-label={t('Close the leave modal')}
|
||||
onClick={onClose}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
useTrans,
|
||||
} from '@/docs/doc-management';
|
||||
import { useLeftPanelStore } from '@/features/left-panel';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
import { useFocusStore, useResponsiveStore } from '@/stores';
|
||||
|
||||
import { isDocNode } from '../utils';
|
||||
|
||||
@@ -108,6 +108,7 @@ const DocSubPageItemContent = (props: TreeViewNodeProps<Doc>) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { closePanel } = useLeftPanelStore();
|
||||
const { addLastFocus } = useFocusStore();
|
||||
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title || '');
|
||||
const displayTitle = titleWithoutEmoji || untitledDocument;
|
||||
@@ -148,6 +149,7 @@ const DocSubPageItemContent = (props: TreeViewNodeProps<Doc>) => {
|
||||
const isCurrentPage = router.query?.id === doc.id;
|
||||
const isDeleted = !!doc.deleted_at;
|
||||
const actionsRef = useRef<HTMLDivElement>(null);
|
||||
const subPageItemRef = useRef<HTMLAnchorElement>(null);
|
||||
const buttonOptionRef = useRef<ButtonElement | null>(null);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -168,15 +170,18 @@ const DocSubPageItemContent = (props: TreeViewNodeProps<Doc>) => {
|
||||
const handleActionsOpenChange = (isOpen: boolean) => {
|
||||
setMenuOpen(isOpen);
|
||||
|
||||
// When the menu closes (via Escape or activating an option),
|
||||
// return focus to the tree item so focus is not lost.
|
||||
if (!isOpen) {
|
||||
node.focus();
|
||||
// When the actions menu opens, we save the last focused element
|
||||
// to restore focus later when the menu closes or actions are completed.
|
||||
const el =
|
||||
subPageItemRef?.current?.closest<HTMLDivElement>('.c__tree-view--row');
|
||||
if (isOpen && el) {
|
||||
addLastFocus(el);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledLink
|
||||
ref={subPageItemRef}
|
||||
className="--docs-sub-page-item"
|
||||
/**
|
||||
* Conflict with the react-arborist DND.
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from '@/docs/doc-management';
|
||||
import { useLeftPanelStore } from '@/features/left-panel/stores/useLeftPanelStore';
|
||||
import { TreeSkeleton } from '@/features/skeletons/components/TreeSkeleton';
|
||||
import { useFocusStore } from '@/stores/useFocusStore';
|
||||
import { useResponsiveStore } from '@/stores/useResponsiveStore';
|
||||
|
||||
import { CLASS_DOC_TITLE } from '../../doc-header';
|
||||
@@ -39,6 +40,8 @@ import { findIndexInTree, isDocNode } from '../utils';
|
||||
import { DocSubPageItem } from './DocSubPageItem';
|
||||
import { DocTreeItemActions } from './DocTreeItemActions';
|
||||
|
||||
const DOC_TREE_ROOT_ITEM_ID = 'doc-tree-root-item';
|
||||
|
||||
type DocTreeProps = {
|
||||
currentDoc: Doc;
|
||||
};
|
||||
@@ -58,6 +61,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
const rootItemRef = useRef<HTMLDivElement>(null);
|
||||
const rootActionsRef = useRef<HTMLDivElement>(null);
|
||||
const rootButtonOptionRef = useRef<ButtonElement | null>(null);
|
||||
const { addLastFocus } = useFocusStore();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -102,8 +106,13 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
const handleRootKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const isInActions = !!target?.closest('.doc-tree-root-item-actions');
|
||||
const isOnEmojiButton = !!target?.closest('.--docs--doc-icon');
|
||||
|
||||
if (!target || target?.id !== DOC_TREE_ROOT_ITEM_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInActions = !!target.closest('.doc-tree-root-item-actions');
|
||||
const isOnEmojiButton = !!target.closest('.--docs--doc-icon');
|
||||
const isOnRootItem = target === e.currentTarget;
|
||||
|
||||
if (e.key === 'F2' && !rootActionsOpen && !isInActions) {
|
||||
@@ -142,18 +151,14 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
);
|
||||
|
||||
// Handle menu open/close for root item - mirrors DocSubPageItem behavior
|
||||
const handleRootActionsOpenChange = useCallback((isOpen: boolean) => {
|
||||
setRootActionsOpen(isOpen);
|
||||
const handleRootActionsOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
setRootActionsOpen(isOpen);
|
||||
|
||||
// When the menu closes, return focus to the root tree item
|
||||
// (same behavior as DocSubPageItem for consistency)
|
||||
// Use requestAnimationFrame for smoother focus transition without flickering
|
||||
if (!isOpen) {
|
||||
requestAnimationFrame(() => {
|
||||
rootItemRef.current?.focus();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
addLastFocus(rootItemRef.current);
|
||||
},
|
||||
[addLastFocus],
|
||||
);
|
||||
|
||||
/**
|
||||
* This effect is used to reset the tree when a new document
|
||||
@@ -325,7 +330,8 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
>
|
||||
<Box
|
||||
ref={rootItemRef}
|
||||
data-testid="doc-tree-root-item"
|
||||
data-testid={DOC_TREE_ROOT_ITEM_ID}
|
||||
id={DOC_TREE_ROOT_ITEM_ID}
|
||||
role="treeitem"
|
||||
aria-label={`${t('Root document {{title}}', { title: treeContext.root?.title || untitledDocument })}`}
|
||||
aria-selected={rootIsSelected}
|
||||
|
||||
+28
-7
@@ -10,7 +10,7 @@ import {
|
||||
useTreeContext,
|
||||
} from '@gouvfr-lasuite/ui-kit';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useRef } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
useDocTitleUpdate,
|
||||
} from '@/docs/doc-management';
|
||||
import { useDuplicateDoc } from '@/docs/doc-management/api';
|
||||
import { ConfirmationDuplicateModal } from '@/docs/doc-management/components/ConfirmationDuplicateModal';
|
||||
import { useFocusStore } from '@/stores/useFocusStore';
|
||||
|
||||
import { useDetachDoc } from '../api/useDetach';
|
||||
import MoveDocIcon from '../assets/doc-extract-bold.svg';
|
||||
@@ -59,6 +61,8 @@ export const DocTreeItemActions = ({
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const deleteModal = useModal();
|
||||
const { restoreFocus } = useFocusStore();
|
||||
const [isModalDuplicateOpen, setIsModalDuplicateOpen] = useState(false);
|
||||
const copyLink = useCopyDocLink(doc.id);
|
||||
const { mutate: detachDoc } = useDetachDoc();
|
||||
const treeContext = useTreeContext<Doc | null>();
|
||||
@@ -141,11 +145,14 @@ export const DocTreeItemActions = ({
|
||||
icon: <Icon iconName="content_copy" />,
|
||||
isDisabled: !doc.abilities.duplicate,
|
||||
callback: () => {
|
||||
duplicateDoc({
|
||||
docId: doc.id,
|
||||
with_accesses: false,
|
||||
canSave: doc.abilities.partial_update,
|
||||
});
|
||||
if (doc.numchild) {
|
||||
setIsModalDuplicateOpen(true);
|
||||
} else {
|
||||
duplicateDoc({
|
||||
docId: doc.id,
|
||||
canSave: doc.abilities.partial_update,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -182,11 +189,16 @@ export const DocTreeItemActions = ({
|
||||
<Box className="doc-tree-root-item-actions actions">
|
||||
<Box
|
||||
ref={targetActionsRef}
|
||||
className="--docs--doc-tree-item-actions"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
className="--docs--doc-tree-item-actions"
|
||||
$gap="4xs"
|
||||
tabIndex={-1}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
restoreFocus();
|
||||
}
|
||||
}}
|
||||
$css={css`
|
||||
& button {
|
||||
height: 24px;
|
||||
@@ -248,6 +260,15 @@ export const DocTreeItemActions = ({
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{isModalDuplicateOpen && (
|
||||
<ConfirmationDuplicateModal
|
||||
onClose={() => {
|
||||
setIsModalDuplicateOpen(false);
|
||||
restoreFocus();
|
||||
}}
|
||||
doc={doc}
|
||||
/>
|
||||
)}
|
||||
{deleteModal.isOpen && (
|
||||
<ModalRemoveDoc
|
||||
onClose={deleteModal.onClose}
|
||||
|
||||
@@ -185,7 +185,7 @@ export const DocMoveModal = ({
|
||||
<Text as="h2" $margin="0" $size="s" $align="flex-start">
|
||||
{t('Choose a new parent doc')}
|
||||
</Text>
|
||||
<Box $position="absolute" $css="top: 4px; right: 4px;">
|
||||
<Box $position="absolute" $css="top: 8px; right: 8px;">
|
||||
<ButtonCloseModal
|
||||
aria-label={t('Close the move modal')}
|
||||
onClick={onClose}
|
||||
|
||||
+28
-5
@@ -52,6 +52,16 @@ const ConfirmationLeaveModal = dynamic(
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const ConfirmationDuplicateModal = dynamic(
|
||||
() =>
|
||||
import('@/docs/doc-management/components/ConfirmationDuplicateModal').then(
|
||||
(mod) => ({
|
||||
default: mod.ConfirmationDuplicateModal,
|
||||
}),
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface DocsGridActionsProps {
|
||||
doc: Doc;
|
||||
}
|
||||
@@ -61,6 +71,7 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
|
||||
const { restoreFocus, addLastFocus } = useFocusStore();
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
|
||||
const [isModalDuplicateOpen, setIsModalDuplicateOpen] = useState(false);
|
||||
const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false);
|
||||
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
|
||||
const [isModalMoveOpen, setIsModalMoveOpen] = useState(false);
|
||||
@@ -122,11 +133,14 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
|
||||
icon: <ContentCopySVG width={24} height={24} aria-hidden="true" />,
|
||||
isDisabled: !doc.abilities.duplicate,
|
||||
callback: () => {
|
||||
duplicateDoc({
|
||||
docId: doc.id,
|
||||
with_accesses: false,
|
||||
canSave: false,
|
||||
});
|
||||
if (doc.numchild) {
|
||||
setIsModalDuplicateOpen(true);
|
||||
} else {
|
||||
duplicateDoc({
|
||||
docId: doc.id,
|
||||
canSave: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
showSeparator: true,
|
||||
},
|
||||
@@ -204,6 +218,15 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
|
||||
doc={doc}
|
||||
/>
|
||||
)}
|
||||
{isModalDuplicateOpen && (
|
||||
<ConfirmationDuplicateModal
|
||||
onClose={() => {
|
||||
setIsModalDuplicateOpen(false);
|
||||
restoreFocus();
|
||||
}}
|
||||
doc={doc}
|
||||
/>
|
||||
)}
|
||||
{isModalMoveOpen && (
|
||||
<DocMoveModal
|
||||
doc={doc}
|
||||
|
||||
Reference in New Issue
Block a user