diff --git a/src/frontend/apps/impress/src/components/AlertModal.tsx b/src/frontend/apps/impress/src/components/AlertModal.tsx new file mode 100644 index 00000000..9b591341 --- /dev/null +++ b/src/frontend/apps/impress/src/components/AlertModal.tsx @@ -0,0 +1,68 @@ +import { Button, Modal, ModalSize } from '@openfun/cunningham-react'; +import { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box } from './Box'; +import { Text } from './Text'; + +export type AlertModalProps = { + description: ReactNode; + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + cancelLabel?: string; + confirmLabel?: string; +}; + +export const AlertModal = ({ + cancelLabel, + confirmLabel, + description, + isOpen, + onClose, + onConfirm, + title, +}: AlertModalProps) => { + const { t } = useTranslation(); + return ( + + {title} + + } + rightActions={ + <> + + + + } + > + + + {description} + + + + ); +}; diff --git a/src/frontend/apps/impress/src/components/index.ts b/src/frontend/apps/impress/src/components/index.ts index c1a1314f..a0f256f5 100644 --- a/src/frontend/apps/impress/src/components/index.ts +++ b/src/frontend/apps/impress/src/components/index.ts @@ -1,3 +1,4 @@ +export * from './AlertModal'; export * from './Box'; export * from './BoxButton'; export * from './Card'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 5cbe3d02..762686ff 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -277,7 +277,11 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { {modalShare.isOpen && ( - modalShare.close()} doc={doc} /> + modalShare.close()} + doc={doc} + isRootDoc={treeContext?.root?.id === doc.id} + /> )} {isModalExportOpen && ModalExport && ( setIsModalExportOpen(false)} doc={doc} /> diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx index f83b1aff..fd7b038a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx @@ -5,14 +5,13 @@ import { VariantType, useToastProvider, } from '@openfun/cunningham-react'; -import { t } from 'i18next'; import { usePathname } from 'next/navigation'; import { useRouter } from 'next/router'; +import { Trans, useTranslation } from 'react-i18next'; import { Box, Text, TextErrors } from '@/components'; import { useRemoveDoc } from '../api/useRemoveDoc'; -import { useTrans } from '../hooks'; import { Doc } from '../types'; interface ModalRemoveDocProps { @@ -27,10 +26,9 @@ export const ModalRemoveDoc = ({ afterDelete, }: ModalRemoveDocProps) => { const { toast } = useToastProvider(); + const { t } = useTranslation(); const { push } = useRouter(); const pathname = usePathname(); - const { untitledDocument } = useTrans(); - const { mutate: removeDoc, isError, @@ -82,7 +80,7 @@ export const ModalRemoveDoc = ({ } - size={ModalSize.SMALL} + size={ModalSize.MEDIUM} title={ {!isError && ( - - {t('Are you sure you want to delete the document "{{title}}"?', { - title: doc.title ?? untitledDocument, - })} - + <> + + + This document and any sub-documents will be + permanently deleted. This action is irreversible. + + + )} {isError && } diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx index 28a01767..c8f1dbd1 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx @@ -74,7 +74,10 @@ export const DocSearchModal = ({ loading={loading} onFilter={handleInputSearch} > - + {showFilters && ( => { +}: DocAccessesParams): Promise => { let url = `documents/${docId}/accesses/`; if (ordering) { @@ -36,7 +33,7 @@ export const getDocAccesses = async ({ export const KEY_LIST_DOC_ACCESSES = 'docs-accesses'; export function useDocAccesses( - params: DocAccessesAPIParams, + params: DocAccessesParams, queryConfig?: UseQueryOptions, ) { return useQuery({ diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx index 5d8aef9a..86175eda 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx @@ -1,91 +1,25 @@ -import { Button, Modal, ModalSize, useModal } from '@openfun/cunningham-react'; -import { Fragment, useMemo } from 'react'; +import { Button } from '@openfun/cunningham-react'; +import { Fragment } from 'react'; import { useTranslation } from 'react-i18next'; -import { createGlobalStyle } from 'styled-components'; -import { Box, StyledLink, Text } from '@/components'; +import { Box, HorizontalSeparator, Icon, StyledLink, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { - Access, - RoleImportance, - useDoc, - useDocStore, -} from '../../doc-management'; -import SimpleFileIcon from '../../docs-grid/assets/simple-document.svg'; +import { Access, useDocStore } from '../../doc-management'; -import { DocShareMemberItem } from './DocShareMemberItem'; -const ShareModalStyle = createGlobalStyle` - .c__modal__title { - padding-bottom: 0 !important; - } - .c__modal__scroller { - padding: 15px 15px !important; - } -`; +import { DocShareMemberItem } from './DocShareMember'; -type Props = { +type DocInheritedShareContentProps = { rawAccesses: Access[]; }; -const getMaxRoleBetweenAccesses = (access1: Access, access2: Access) => { - const role1 = access1.max_role; - const role2 = access2.max_role; - - const roleImportance1 = RoleImportance[role1]; - const roleImportance2 = RoleImportance[role2]; - - return roleImportance1 > roleImportance2 ? role1 : role2; -}; - -export const DocInheritedShareContent = ({ rawAccesses }: Props) => { +export const DocInheritedShareContent = ({ + rawAccesses, +}: DocInheritedShareContentProps) => { const { t } = useTranslation(); const { spacingsTokens } = useCunninghamTheme(); const { currentDoc } = useDocStore(); - const inheritedData = useMemo(() => { - if (!currentDoc || rawAccesses.length === 0) { - return null; - } - - let parentId = null; - let parentPathLength = 0; - const members: Access[] = []; - - // Find the parent document with the longest path that is different from currentDoc - for (const access of rawAccesses) { - const docPath = access.document.path; - - // Skip if it's the current document - if (access.document.id === currentDoc.id) { - continue; - } - - const findIndex = members.findIndex( - (member) => member.user.id === access.user.id, - ); - if (findIndex === -1) { - members.push(access); - } else { - const accessToUpdate = members[findIndex]; - const currentRole = accessToUpdate.max_role; - const maxRole = getMaxRoleBetweenAccesses(accessToUpdate, access); - - if (maxRole !== currentRole) { - members[findIndex] = access; - } - } - - // Check if this document has a longer path than our current candidate - if (docPath && (!parentId || docPath.length > parentPathLength)) { - parentId = access.document.id; - parentPathLength = docPath.length; - } - } - - return { parentId, members }; - }, [currentDoc, rawAccesses]); - // Check if accesses map is empty const hasAccesses = rawAccesses.length > 0; @@ -94,113 +28,44 @@ export const DocInheritedShareContent = ({ rawAccesses }: Props) => { } return ( - + + - - {t('Inherited share')} - - - {inheritedData && ( - - )} + + + {t('People with access via the parent document')} + + + + - )} - - {accessModal.isOpen && ( - - - {t('Access inherited from the parent page')} - - - } - size={ModalSize.MEDIUM} - > - - - {accesses.map((access) => ( - - - - ))} - - - )} - - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocRoleDropdown.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocRoleDropdown.tsx index b62db014..4f03f9c9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocRoleDropdown.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocRoleDropdown.tsx @@ -1,16 +1,30 @@ +import { VariantType, useToastProvider } from '@openfun/cunningham-react'; +import { useQueryClient } from '@tanstack/react-query'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; import { DropdownMenu, DropdownMenuOption, Text } from '@/components'; -import { Role, useTrans } from '@/docs/doc-management/'; +import { + Access, + Doc, + KEY_SUB_PAGE, + Role, + useTrans, +} from '@/docs/doc-management/'; + +import { useDeleteDocAccess, useDeleteDocInvitation } from '../api'; +import { Invitation, isInvitation } from '../types'; type DocRoleDropdownProps = { + doc?: Doc; + access?: Access | Invitation; canUpdate?: boolean; currentRole: Role; message?: string; onSelectRole: (role: Role) => void; rolesAllowed?: Role[]; + isLastOwner?: boolean; }; export const DocRoleDropdown = ({ @@ -18,10 +32,65 @@ export const DocRoleDropdown = ({ currentRole, message, onSelectRole, + doc, rolesAllowed, + access, + isLastOwner = false, }: DocRoleDropdownProps) => { const { t } = useTranslation(); const { transRole, translatedRoles } = useTrans(); + const queryClient = useQueryClient(); + const { toast } = useToastProvider(); + + const { mutate: removeDocInvitation } = useDeleteDocInvitation({ + onSuccess: () => { + if (!doc) { + return; + } + + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, + onError: (error) => { + toast( + error?.data?.role?.[0] ?? t('Error during delete invitation'), + VariantType.ERROR, + { + duration: 4000, + }, + ); + }, + }); + + const { mutate: removeDocAccess } = useDeleteDocAccess({ + onSuccess: () => { + if (!doc) { + return; + } + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, + onError: () => { + toast(t('Error while deleting invitation'), VariantType.ERROR, { + duration: 4000, + }); + }, + }); + + const onRemove = () => { + const invitation = isInvitation(access); + if (!doc || !access) { + return; + } + + if (invitation) { + removeDocInvitation({ invitationId: access.id, docId: doc.id }); + } else { + removeDocAccess({ accessId: access.id, docId: doc.id }); + } + }; /** * When there is a higher role, the rolesAllowed are truncated @@ -44,14 +113,18 @@ export const DocRoleDropdown = ({ }, [canUpdate, rolesAllowed, translatedRoles, message, t]); const roles: DropdownMenuOption[] = Object.keys(translatedRoles).map( - (key) => { + (key, index) => { + const isLast = index === Object.keys(translatedRoles).length - 1; return { label: transRole(key as Role), callback: () => onSelectRole?.(key as Role), isSelected: currentRole === (key as Role), + showSeparator: isLast, + disabled: isLastOwner && key !== 'owner', }; }, ); + if (!canUpdate) { return ( @@ -59,15 +132,27 @@ export const DocRoleDropdown = ({ ); } + return ( {canUpdate && ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx index da2196eb..c258a3b8 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx @@ -3,19 +3,14 @@ import { useQueryClient } from '@tanstack/react-query'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { - Box, - DropdownMenu, - DropdownMenuOption, - IconOptions, -} from '@/components'; -import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search'; +import { Box } from '@/components'; +import { QuickSearchData } from '@/components/quick-search'; +import { QuickSearchGroup } from '@/components/quick-search/QuickSearchGroup'; import { useCunninghamTheme } from '@/cunningham'; import { Access, Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management/'; -import { useResponsiveStore } from '@/stores'; -import { useDeleteDocAccess, useDocAccesses, useUpdateDocAccess } from '../api'; -import { useWhoAmI } from '../hooks'; +import { useDocAccesses, useUpdateDocAccess } from '../api'; +import { useWhoAmI } from '../hooks/'; import { DocRoleDropdown } from './DocRoleDropdown'; import { SearchUserRow } from './SearchUserRow'; @@ -35,7 +30,6 @@ export const DocShareMemberItem = ({ const { isLastOwner } = useWhoAmI(access); const { toast } = useToastProvider(); - const { isDesktop } = useResponsiveStore(); const { spacingsTokens } = useCunninghamTheme(); const message = isLastOwner @@ -60,22 +54,6 @@ export const DocShareMemberItem = ({ }, }); - const { mutate: removeDocAccess } = useDeleteDocAccess({ - onSuccess: () => { - if (!doc) { - return; - } - void queryClient.invalidateQueries({ - queryKey: [KEY_SUB_PAGE, { id: doc.id }], - }); - }, - onError: () => { - toast(t('Error while deleting the member.'), VariantType.ERROR, { - duration: 4000, - }); - }, - }); - const onUpdate = (newRole: Role) => { if (!doc) { return; @@ -87,22 +65,6 @@ export const DocShareMemberItem = ({ }); }; - const onRemove = () => { - if (!doc) { - return; - } - removeDocAccess({ accessId: access.id, docId: doc.id }); - }; - - const moreActions: DropdownMenuOption[] = [ - { - label: t('Delete'), - icon: 'delete', - callback: onRemove, - disabled: !access.abilities.destroy, - }, - ]; - const canUpdate = isInherited ? false : !!doc?.abilities.accesses_manage; return ( @@ -119,20 +81,13 @@ export const DocShareMemberItem = ({ - - {isDesktop && canUpdate && ( - - - - )} } /> @@ -170,7 +125,7 @@ export const QuickSearchGroupMember = ({ }, [membersQuery, t]); return ( - + ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx index 5c92705c..eda2b4a5 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx @@ -41,10 +41,11 @@ const ShareModalStyle = createGlobalStyle` type Props = { doc: Doc; + isRootDoc?: boolean; onClose: () => void; }; -export const DocShareModal = ({ doc, onClose }: Props) => { +export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => { const { t } = useTranslation(); const selectedUsersRef = useRef(null); @@ -58,7 +59,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => { const [inputValue, setInputValue] = useState(''); const [listHeight, setListHeight] = useState('400px'); - const canShare = doc.abilities.accesses_manage; + const canShare = doc.abilities.accesses_manage && isRootDoc; const canViewAccesses = doc.abilities.accesses_view; const showMemberSection = inputValue === '' && selectedUsers.length === 0; const showFooter = selectedUsers.length === 0 && !inputValue; @@ -114,8 +115,6 @@ export const DocShareModal = ({ doc, onClose }: Props) => { ); }, [membersQuery, doc.id]); - const isRootDoc = false; - const showInheritedShareContent = inheritedAccesses.length > 0 && showMemberSection && !isRootDoc; @@ -149,10 +148,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => { > {canShare && selectedUsers.length > 0 && ( - + { /> )} - {!canViewAccesses && } + {!canViewAccesses && } @@ -213,13 +209,15 @@ export const DocShareModal = ({ doc, onClose }: Props) => { } /> )} - {showMemberSection ? ( - <> + {showMemberSection && isRootDoc && ( + - - ) : ( + + )} + + {!showMemberSection && canShare && ( { - {showFooter && } + {showFooter && ( + + )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx index 6930a68f..587e6f45 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx @@ -10,9 +10,14 @@ import { DocVisibility } from './DocVisibility'; type Props = { doc: Doc; onClose: () => void; + canEditVisibility?: boolean; }; -export const DocShareModalFooter = ({ doc, onClose }: Props) => { +export const DocShareModalFooter = ({ + doc, + onClose, + canEditVisibility = true, +}: Props) => { const copyDocLink = useCopyDocLink(doc.id); const { t } = useTranslation(); return ( @@ -22,10 +27,10 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => { `} className="--docs--doc-share-modal-footer" > - + - - + + { +export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => { const { t } = useTranslation(); const { toast } = useToastProvider(); const { isDesktop } = useResponsiveStore(); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); - const canManage = doc.abilities.accesses_manage; + const canManage = doc.abilities.accesses_manage && canEdit; const [linkReach, setLinkReach] = useState(getDocLinkReach(doc)); const [docLinkRole, setDocLinkRole] = useState( doc.computed_link_role ?? LinkRole.READER, diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/types.tsx index f79fdb28..aab99806 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/types.tsx @@ -17,6 +17,14 @@ export interface Invitation { }; } +/** + * Type guard to check if an object is an Invitation + * Invitation has unique properties: email, issuer, is_expired, and document as a string + */ +export const isInvitation = (obj: unknown): obj is Invitation => { + return obj !== null && typeof obj === 'object' && 'issuer' in obj; +}; + export enum OptionType { INVITATION = 'invitation', NEW_MEMBER = 'new_member', diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx index 8fae3131..9fae9746 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx @@ -16,6 +16,7 @@ import { useTrans, } from '@/features/docs/doc-management'; import { useLeftPanelStore } from '@/features/left-panel'; +import { useResponsiveStore } from '@/stores'; import Logo from './../assets/sub-page-logo.svg'; import { DocTreeItemActions } from './DocTreeItemActions'; @@ -37,7 +38,8 @@ export const DocSubPageItem = (props: Props) => { const { untitledDocument } = useTrans(); const { node } = props; const { spacingsTokens } = useCunninghamTheme(); - const [isHover, setIsHover] = useState(false); + const { isDesktop } = useResponsiveStore(); + const [actionsOpen, setActionsOpen] = useState(false); const router = useRouter(); const { togglePanel } = useLeftPanelStore(); @@ -97,11 +99,22 @@ export const DocSubPageItem = (props: Props) => { return ( setIsHover(true)} - onMouseLeave={() => setIsHover(false)} $css={css` - &:not(:has(.isSelected)):has(.light-doc-item-actions) { + background-color: ${actionsOpen + ? 'var(--c--theme--colors--greyscale-100)' + : 'var(--c--theme--colors--greyscale-000)'}; + + .light-doc-item-actions { + display: ${actionsOpen || !isDesktop ? 'flex' : 'none'}; + } + + &:hover { background-color: var(--c--theme--colors--greyscale-100); + border-radius: 4px; + + .light-doc-item-actions { + display: flex; + } } `} > @@ -150,19 +163,19 @@ export const DocSubPageItem = (props: Props) => { )} - {isHover && ( - - - - )} + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx index 1d35afb7..fdd520c2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -24,6 +24,7 @@ type DocTreeProps = { }; export const DocTree = ({ initialTargetId }: DocTreeProps) => { const { spacingsTokens } = useCunninghamTheme(); + const [rootActionsOpen, setRootActionsOpen] = useState(false); const treeContext = useTreeContext(); const { currentDoc } = useDocStore(); const router = useRouter(); @@ -145,11 +146,12 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => { `} > { .doc-tree-root-item-actions { display: 'flex'; - opacity: 0; + opacity: ${rootActionsOpen ? '1' : '0'}; &:has(.isOpen) { opacity: 1; @@ -201,6 +203,8 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => { }; treeContext?.treeData.addChild(null, newDoc); }} + isOpen={rootActionsOpen} + onOpenChange={setRootActionsOpen} /> diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx index 092d3a2a..add3753c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx @@ -5,7 +5,7 @@ import { } from '@gouvfr-lasuite/ui-kit'; import { useModal } from '@openfun/cunningham-react'; import { useRouter } from 'next/router'; -import { Fragment, useState } from 'react'; +import { Fragment } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -21,14 +21,17 @@ type DocTreeItemActionsProps = { doc: Doc; parentId?: string | null; onCreateSuccess?: (newDoc: Doc) => void; + isOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; }; export const DocTreeItemActions = ({ doc, parentId, onCreateSuccess, + isOpen, + onOpenChange, }: DocTreeItemActionsProps) => { - const [isOpen, setIsOpen] = useState(false); const router = useRouter(); const { t } = useTranslation(); const deleteModal = useModal(); @@ -66,7 +69,7 @@ export const DocTreeItemActions = ({ ...(!isCurrentParent ? [ { - label: t('Convert to doc'), + label: t('Move to my docs'), isDisabled: !doc.abilities.move, icon: ( { onCreateSuccess?.(newDoc); + void router.push(`/docs/${newDoc.id}`); }, }); @@ -118,13 +122,13 @@ export const DocTreeItemActions = ({ { e.stopPropagation(); e.preventDefault(); - setIsOpen(!isOpen); + onOpenChange?.(!isOpen); }} iconName="more_horiz" variant="filled" diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx index 561d16e0..0c47564e 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx @@ -1,15 +1,22 @@ import { DndContext, DragOverlay, Modifier } from '@dnd-kit/core'; import { getEventCoordinates } from '@dnd-kit/utilities'; import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit'; +import { useModal } from '@openfun/cunningham-react'; import { useQueryClient } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Box, Text } from '@/components'; +import { AlertModal, Box, Text } from '@/components'; import { Doc, KEY_LIST_DOC } from '@/docs/doc-management'; +import { + getDocAccesses, + getDocInvitations, + useDeleteDocAccess, + useDeleteDocInvitation, +} from '@/docs/doc-share'; import { useMoveDoc } from '@/docs/doc-tree'; -import { useDragAndDrop } from '../hooks/useDragAndDrop'; +import { DocDragEndData, useDragAndDrop } from '../hooks/useDragAndDrop'; import { DocsGridItem } from './DocsGridItem'; import { Draggable } from './Draggable'; @@ -45,23 +52,75 @@ type DocGridContentListProps = { }; export const DocGridContentList = ({ docs }: DocGridContentListProps) => { - const { mutate: handleMove, isError } = useMoveDoc(); + const { mutateAsync: handleMove, isError } = useMoveDoc(); const queryClient = useQueryClient(); - const onDrag = (sourceDocumentId: string, targetDocumentId: string) => - handleMove( - { + const modalConfirmation = useModal(); + const { mutate: handleDeleteInvitation } = useDeleteDocInvitation(); + const { mutate: handleDeleteAccess } = useDeleteDocAccess(); + const onDragData = useRef(null); + + const handleMoveDoc = async () => { + if (!onDragData.current) { + return; + } + + const { sourceDocumentId, targetDocumentId } = onDragData.current; + modalConfirmation.onClose(); + if (!sourceDocumentId || !targetDocumentId) { + onDragData.current = null; + + return; + } + + try { + await handleMove({ sourceDocumentId, targetDocumentId, position: TreeViewMoveModeEnum.FIRST_CHILD, - }, - { - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: [KEY_LIST_DOC], - }); - }, - }, - ); + }); + + 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; + } + }; + + const onDrag = (data: DocDragEndData) => { + onDragData.current = data; + if (data.source.nb_accesses_direct <= 1) { + void handleMoveDoc(); + return; + } + + modalConfirmation.open(); + }; const { selectedDoc, @@ -105,37 +164,62 @@ export const DocGridContentList = ({ docs }: DocGridContentListProps) => { } return ( - - {docs.map((doc) => ( - + + {docs.map((doc) => ( + + ))} + + + + {overlayText} + + + + + {modalConfirmation.isOpen && ( + {{targetDocumentTitle}}, it will lose its current access rights and inherit the permissions of that document. This access change cannot be undone.', + { + targetDocumentTitle: + onDragData.current?.target.title ?? t('Unnamed document'), + }, + ), + }} + /> + } + confirmLabel={t('Move')} + onConfirm={() => { + void handleMoveDoc(); + }} /> - ))} - - - - {overlayText} - - - - + )} + ); }; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx index f89c3ece..68b84425 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx @@ -11,13 +11,18 @@ import { useState } from 'react'; import { Doc } from '@/docs/doc-management'; +export type DocDragEndData = { + sourceDocumentId: string; + targetDocumentId: string; + source: Doc; + target: Doc; +}; + const activationConstraint = { distance: 20, }; -export function useDragAndDrop( - onDrag: (sourceDocumentId: string, targetDocumentId: string) => void, -) { +export function useDragAndDrop(onDrag: (data: DocDragEndData) => void) { const [selectedDoc, setSelectedDoc] = useState(); const [canDrop, setCanDrop] = useState(); @@ -49,7 +54,12 @@ export function useDragAndDrop( return; } - onDrag(active.id as string, over.id as string); + onDrag({ + sourceDocumentId: active.id as string, + targetDocumentId: over.id as string, + source: active.data.current as Doc, + target: over.data.current as Doc, + }); }; const updateCanDrop = (docCanDrop: boolean, isOver: boolean) => { diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx index ac5bf169..fbf0a937 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx @@ -1,25 +1,13 @@ -import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; import { Button } from '@openfun/cunningham-react'; import { useRouter } from 'next/router'; import { useTranslation } from 'react-i18next'; -import { Doc, useCreateDoc, useDocStore } from '@/docs/doc-management'; -import { isOwnerOrAdmin, useCreateChildrenDoc } from '@/features/docs/doc-tree'; +import { Icon } from '@/components'; +import { useCreateDoc } from '@/features/docs/doc-management'; import { useLeftPanelStore } from '../stores'; export const LeftPanelHeaderButton = () => { - const router = useRouter(); - const isDoc = router.pathname === '/docs/[id]'; - - if (isDoc) { - return ; - } - - return ; -}; - -export const LeftPanelHeaderHomeButton = () => { const router = useRouter(); const { t } = useTranslation(); const { togglePanel } = useLeftPanelStore(); @@ -33,45 +21,10 @@ export const LeftPanelHeaderHomeButton = () => { ); }; - -export const LeftPanelHeaderDocButton = () => { - const router = useRouter(); - const { currentDoc } = useDocStore(); - const { t } = useTranslation(); - const { togglePanel } = useLeftPanelStore(); - const treeContext = useTreeContext(); - const tree = treeContext?.treeData; - const { mutate: createChildrenDoc, isPending: isDocCreating } = - useCreateChildrenDoc({ - onSuccess: (doc) => { - tree?.addRootNode(doc); - tree?.selectNodeById(doc.id); - void router.push(`/docs/${doc.id}`); - togglePanel(); - }, - }); - - const onCreateDoc = () => { - if (treeContext && treeContext.root) { - createChildrenDoc({ - parentId: treeContext.root.id, - }); - } - }; - - return ( - - ); -};