From 510d6c3ff14c1177397f1364e2774affdf24b1d3 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Mon, 19 May 2025 09:02:47 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20enhance=20document=20shar?= =?UTF-8?q?ing=20and=20visibility=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added a new component `DocInheritedShareContent` to display inherited access information for documents. - Updated `DocShareModal` to include inherited share content when applicable. - Refactored `DocRoleDropdown` to improve role selection messaging based on inherited roles. - Enhanced `DocVisibility` to manage link reach and role updates more effectively, including handling desynchronization scenarios. - Improved `DocShareMemberItem` to accommodate inherited access logic and ensure proper role management. --- .../impress/src/components/DropdownMenu.tsx | 3 + .../quick-search/QuickSearchStyle.tsx | 6 - .../docs/doc-header/components/DocHeader.tsx | 5 +- .../docs/doc-header/components/DocToolBox.tsx | 18 +- .../src/features/docs/doc-management/utils.ts | 12 - .../docs/doc-share/assets/desynchro.svg | 15 ++ .../features/docs/doc-share/assets/undo.svg | 15 ++ .../components/DocInheritedShareContent.tsx | 206 ++++++++++++++++++ .../doc-share/components/DocRoleDropdown.tsx | 46 ++-- .../components/DocShareInvitation.tsx | 2 +- .../doc-share/components/DocShareMember.tsx | 55 ++--- .../doc-share/components/DocShareModal.tsx | 42 +++- .../doc-share/components/DocVisibility.tsx | 184 +++++++++++++--- .../doc-tree/components/DocSubPageItem.tsx | 9 +- .../docs/doc-tree/components/DocTree.tsx | 18 +- .../components/DocTreeItemActions.tsx | 20 +- .../docs/doc-tree/hooks/useTreeUtils.tsx | 6 + 17 files changed, 543 insertions(+), 119 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/assets/desynchro.svg create mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/assets/undo.svg create mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx diff --git a/src/frontend/apps/impress/src/components/DropdownMenu.tsx b/src/frontend/apps/impress/src/components/DropdownMenu.tsx index 5fc3d64b..5513ccb7 100644 --- a/src/frontend/apps/impress/src/components/DropdownMenu.tsx +++ b/src/frontend/apps/impress/src/components/DropdownMenu.tsx @@ -99,6 +99,9 @@ export const DropdownMenu = ({ $size="xs" $weight="bold" $padding={{ vertical: 'xs', horizontal: 'base' }} + $css={css` + white-space: pre-line; + `} > {topMessage} diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx index b6fa0ad6..58aca88d 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx @@ -26,8 +26,6 @@ export const QuickSearchStyle = createGlobalStyle` } } - - [cmdk-item] { content-visibility: auto; cursor: pointer; @@ -64,10 +62,6 @@ export const QuickSearchStyle = createGlobalStyle` } [cmdk-list] { - - padding: 0 var(--c--theme--spacings--base) var(--c--theme--spacings--base) - var(--c--theme--spacings--base); - flex:1; overflow-y: auto; overscroll-behavior: contain; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx index cae72309..bd152eb7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx @@ -8,6 +8,7 @@ import { LinkReach, Role, currentDocRole, + getDocLinkReach, useIsCollaborativeEditable, useTrans, } from '@/docs/doc-management'; @@ -28,8 +29,8 @@ export const DocHeader = ({ doc }: DocHeaderProps) => { const { t } = useTranslation(); const { transRole } = useTrans(); const { isEditable } = useIsCollaborativeEditable(doc); - const docIsPublic = doc.link_reach === LinkReach.PUBLIC; - const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED; + const docIsPublic = getDocLinkReach(doc) === LinkReach.PUBLIC; + const docIsAuth = getDocLinkReach(doc) === LinkReach.AUTHENTICATED; return ( <> 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 aaaeab5e..5cbe3d02 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 @@ -1,3 +1,4 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; import { Button, VariantType, @@ -5,7 +6,7 @@ import { useToastProvider, } from '@openfun/cunningham-react'; import { useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -46,7 +47,20 @@ interface DocToolBoxProps { export const DocToolBox = ({ doc }: DocToolBoxProps) => { const { t } = useTranslation(); - const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view; + const treeContext = useTreeContext(); + + /** + * Following the change where there is no default owner when adding a sub-page, + * we need to handle both the case where the doc is the root and the case of sub-pages. + */ + const hasAccesses = useMemo(() => { + if (treeContext?.root?.id === doc.id) { + return doc.nb_accesses_direct > 1 && doc.abilities.accesses_view; + } + + return doc.nb_accesses_direct >= 1 && doc.abilities.accesses_view; + }, [doc, treeContext?.root]); + const queryClient = useQueryClient(); const { toast } = useToastProvider(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts index 62f0bad6..a346f49e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts @@ -30,15 +30,3 @@ export const getDocLinkReach = (doc: Doc): LinkReach => { export const getDocLinkRole = (doc: Doc): LinkRole => { return doc.computed_link_role ?? doc.link_role; }; - -export const docLinkIsDesync = (doc: Doc) => { - // If the document has no ancestors - if (!doc.ancestors_link_reach) { - return false; - } - - return ( - doc.computed_link_reach !== doc.ancestors_link_reach || - doc.computed_link_role !== doc.ancestors_link_role - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/assets/desynchro.svg b/src/frontend/apps/impress/src/features/docs/doc-share/assets/desynchro.svg new file mode 100644 index 00000000..48efb1b2 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/assets/desynchro.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/assets/undo.svg b/src/frontend/apps/impress/src/features/docs/doc-share/assets/undo.svg new file mode 100644 index 00000000..6d715a0c --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/assets/undo.svg @@ -0,0 +1,15 @@ + + + + + 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 new file mode 100644 index 00000000..5d8aef9a --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx @@ -0,0 +1,206 @@ +import { Button, Modal, ModalSize, useModal } from '@openfun/cunningham-react'; +import { Fragment, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { createGlobalStyle } from 'styled-components'; + +import { Box, 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 { DocShareMemberItem } from './DocShareMemberItem'; +const ShareModalStyle = createGlobalStyle` + .c__modal__title { + padding-bottom: 0 !important; + } + .c__modal__scroller { + padding: 15px 15px !important; + } +`; + +type Props = { + 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) => { + 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; + + if (!hasAccesses) { + return null; + } + + return ( + + + + {t('Inherited share')} + + + {inheritedData && ( + + )} + + + ); +}; + +type DocInheritedShareContentItemProps = { + accesses: Access[]; + document_id: string; +}; +export const DocInheritedShareContentItem = ({ + accesses, + document_id, +}: DocInheritedShareContentItemProps) => { + const { t } = useTranslation(); + const { spacingsTokens } = useCunninghamTheme(); + const { data: doc, error, isLoading } = useDoc({ id: document_id }); + const errorCode = error?.status; + + const accessModal = useModal(); + if ((!doc && !isLoading && !error) || (error && errorCode !== 403)) { + return null; + } + + return ( + <> + + + + + {isLoading ? ( + + + + + ) : ( + <> + + + {error && errorCode === 403 + ? t('You do not have permission to view this document') + : (doc?.title ?? t('Untitled document'))} + + + + {t('Members of this page have access')} + + + )} + + + {!isLoading && ( + + )} + + {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 261eb5f6..b62db014 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,3 +1,5 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; import { DropdownMenu, DropdownMenuOption, Text } from '@/components'; @@ -18,8 +20,38 @@ export const DocRoleDropdown = ({ onSelectRole, rolesAllowed, }: DocRoleDropdownProps) => { + const { t } = useTranslation(); const { transRole, translatedRoles } = useTrans(); + /** + * When there is a higher role, the rolesAllowed are truncated + * We display a message to indicate that there is a higher role + */ + const topMessage = useMemo(() => { + if (!canUpdate || !rolesAllowed || rolesAllowed.length === 0) { + return message; + } + + const allRoles = Object.keys(translatedRoles); + + if (rolesAllowed.length < allRoles.length) { + let result = message ? `${message}\n\n` : ''; + result += t('This user has access inherited from a parent page.'); + return result; + } + + return message; + }, [canUpdate, rolesAllowed, translatedRoles, message, t]); + + const roles: DropdownMenuOption[] = Object.keys(translatedRoles).map( + (key) => { + return { + label: transRole(key as Role), + callback: () => onSelectRole?.(key as Role), + isSelected: currentRole === (key as Role), + }; + }, + ); if (!canUpdate) { return ( @@ -27,21 +59,9 @@ export const DocRoleDropdown = ({ ); } - - const roles: DropdownMenuOption[] = Object.keys(translatedRoles).map( - (key) => { - return { - label: transRole(key as Role), - callback: () => onSelectRole?.(key as Role), - disabled: rolesAllowed && !rolesAllowed.includes(key as Role), - isSelected: currentRole === (key as Role), - }; - }, - ); - return ( { 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 6fa843d9..da2196eb 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 @@ -8,32 +8,31 @@ import { DropdownMenu, DropdownMenuOption, IconOptions, - LoadMoreText, } from '@/components'; import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search'; import { useCunninghamTheme } from '@/cunningham'; import { Access, Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management/'; import { useResponsiveStore } from '@/stores'; -import { - useDeleteDocAccess, - useDocAccessesInfinite, - useUpdateDocAccess, -} from '../api'; +import { useDeleteDocAccess, useDocAccesses, useUpdateDocAccess } from '../api'; import { useWhoAmI } from '../hooks'; import { DocRoleDropdown } from './DocRoleDropdown'; import { SearchUserRow } from './SearchUserRow'; type Props = { - doc: Doc; + doc?: Doc; access: Access; + isInherited?: boolean; }; - -const DocShareMemberItem = ({ doc, access }: Props) => { +export const DocShareMemberItem = ({ + doc, + access, + isInherited = false, +}: Props) => { const { t } = useTranslation(); const queryClient = useQueryClient(); - const { isLastOwner, isOtherOwner } = useWhoAmI(access); + const { isLastOwner } = useWhoAmI(access); const { toast } = useToastProvider(); const { isDesktop } = useResponsiveStore(); @@ -47,6 +46,9 @@ const DocShareMemberItem = ({ doc, access }: Props) => { const { mutate: updateDocAccess } = useUpdateDocAccess({ onSuccess: () => { + if (!doc) { + return; + } void queryClient.invalidateQueries({ queryKey: [KEY_SUB_PAGE, { id: doc.id }], }); @@ -60,6 +62,9 @@ const DocShareMemberItem = ({ doc, access }: Props) => { const { mutate: removeDocAccess } = useDeleteDocAccess({ onSuccess: () => { + if (!doc) { + return; + } void queryClient.invalidateQueries({ queryKey: [KEY_SUB_PAGE, { id: doc.id }], }); @@ -72,6 +77,9 @@ const DocShareMemberItem = ({ doc, access }: Props) => { }); const onUpdate = (newRole: Role) => { + if (!doc) { + return; + } updateDocAccess({ docId: doc.id, role: newRole, @@ -80,6 +88,9 @@ const DocShareMemberItem = ({ doc, access }: Props) => { }; const onRemove = () => { + if (!doc) { + return; + } removeDocAccess({ accessId: access.id, docId: doc.id }); }; @@ -92,6 +103,8 @@ const DocShareMemberItem = ({ doc, access }: Props) => { }, ]; + const canUpdate = isInherited ? false : !!doc?.abilities.accesses_manage; + return ( { right={ - {isDesktop && doc.abilities.accesses_manage && ( + {isDesktop && canUpdate && ( { const { t } = useTranslation(); - const membersQuery = useDocAccessesInfinite({ + const membersQuery = useDocAccesses({ docId: doc.id, }); const membersData: QuickSearchData = useMemo(() => { - const members = - membersQuery.data?.pages.flatMap((page) => page.results) || []; + const members = membersQuery.data || []; - const count = membersQuery.data?.pages[0]?.count ?? 1; + const count = members.length; return { groupName: @@ -153,14 +165,7 @@ export const QuickSearchGroupMember = ({ count: count, }), elements: members, - endActions: membersQuery.hasNextPage - ? [ - { - content: , - onSelect: () => void membersQuery.fetchNextPage(), - }, - ] - : undefined, + endActions: undefined, }; }, [membersQuery, t]); 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 591d7e54..5c92705c 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 @@ -15,8 +15,9 @@ import { User } from '@/features/auth'; import { useResponsiveStore } from '@/stores'; import { isValidEmail } from '@/utils'; -import { KEY_LIST_USER, useUsers } from '../api'; +import { KEY_LIST_USER, useDocAccesses, useUsers } from '../api'; +import { DocInheritedShareContent } from './DocInheritedShareContent'; import { ButtonAccessRequest, QuickSearchGroupAccessRequest, @@ -69,6 +70,10 @@ export const DocShareModal = ({ doc, onClose }: Props) => { setInputValue(''); }; + const { data: membersQuery } = useDocAccesses({ + docId: doc.id, + }); + const searchUsersQuery = useUsers( { query: userQuery, docId: doc.id }, { @@ -103,6 +108,17 @@ export const DocShareModal = ({ doc, onClose }: Props) => { setListHeight(height); }; + const inheritedAccesses = useMemo(() => { + return ( + membersQuery?.filter((access) => access.document.id !== doc.id) ?? [] + ); + }, [membersQuery, doc.id]); + + const isRootDoc = false; + + const showInheritedShareContent = + inheritedAccesses.length > 0 && showMemberSection && !isRootDoc; + return ( <> { loading={searchUsersQuery.isLoading} placeholder={t('Type a name or email')} > + {showInheritedShareContent && ( + access.document.id !== doc.id, + ) ?? [] + } + /> + )} {showMemberSection ? ( <> @@ -257,10 +282,15 @@ const QuickSearchInviteInputSection = ({ }, [onSelect, searchUsersRawData, t, userQuery]); return ( - } - /> + + } + /> + ); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx index ca081ce2..15470766 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx @@ -1,5 +1,9 @@ -import { VariantType, useToastProvider } from '@openfun/cunningham-react'; -import { useState } from 'react'; +import { + Button, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -17,12 +21,17 @@ import { KEY_LIST_DOC, LinkReach, LinkRole, + getDocLinkReach, useUpdateDocLink, } from '@/docs/doc-management'; +import { useTreeUtils } from '@/docs/doc-tree'; import { useResponsiveStore } from '@/stores'; import { useTranslatedShareSettings } from '../hooks/'; +import Desync from './../assets/desynchro.svg'; +import Undo from './../assets/undo.svg'; + interface DocVisibilityProps { doc: Doc; } @@ -33,11 +42,20 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => { const { isDesktop } = useResponsiveStore(); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const canManage = doc.abilities.accesses_manage; - const [linkReach, setLinkReach] = useState(doc.link_reach); - const [docLinkRole, setDocLinkRole] = useState(doc.link_role); + const [linkReach, setLinkReach] = useState(getDocLinkReach(doc)); + const [docLinkRole, setDocLinkRole] = useState( + doc.computed_link_role ?? LinkRole.READER, + ); + const { isDesyncronized } = useTreeUtils(doc); + const { linkModeTranslations, linkReachChoices, linkReachTranslations } = useTranslatedShareSettings(); + const description = + docLinkRole === LinkRole.READER + ? linkReachChoices[linkReach].descriptionReadOnly + : linkReachChoices[linkReach].descriptionEdit; + const api = useUpdateDocLink({ onSuccess: () => { toast( @@ -51,38 +69,90 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => { listInvalideQueries: [KEY_LIST_DOC, KEY_DOC], }); - const updateReach = (link_reach: LinkReach) => { - api.mutate({ id: doc.id, link_reach }); - setLinkReach(link_reach); - }; + const updateReach = useCallback( + (link_reach: LinkReach, link_role?: LinkRole) => { + const params: { + id: string; + link_reach: LinkReach; + link_role?: LinkRole; + } = { + id: doc.id, + link_reach, + }; - const updateLinkRole = (link_role: LinkRole) => { - api.mutate({ id: doc.id, link_role }); - setDocLinkRole(link_role); - }; - - const linkReachOptions: DropdownMenuOption[] = Object.keys( - linkReachTranslations, - ).map((key) => ({ - label: linkReachTranslations[key as LinkReach], - icon: linkReachChoices[key as LinkReach].icon, - callback: () => updateReach(key as LinkReach), - isSelected: linkReach === (key as LinkReach), - })); - - const linkMode: DropdownMenuOption[] = Object.keys(linkModeTranslations).map( - (key) => ({ - label: linkModeTranslations[key as LinkRole], - callback: () => updateLinkRole(key as LinkRole), - isSelected: docLinkRole === (key as LinkRole), - }), + api.mutate(params); + setLinkReach(link_reach); + if (link_role) { + params.link_role = link_role; + setDocLinkRole(link_role); + } + }, + [api, doc.id], ); - const showLinkRoleOptions = doc.link_reach !== LinkReach.RESTRICTED; - const description = - docLinkRole === LinkRole.READER - ? linkReachChoices[linkReach].descriptionReadOnly - : linkReachChoices[linkReach].descriptionEdit; + const updateLinkRole = useCallback( + (link_role: LinkRole) => { + api.mutate({ id: doc.id, link_role }); + setDocLinkRole(link_role); + }, + [api, doc.id], + ); + + const linkReachOptions: DropdownMenuOption[] = useMemo(() => { + return Object.values(LinkReach).map((key) => { + const isDisabled = + doc.abilities.link_select_options[key as LinkReach] === undefined; + + return { + label: linkReachTranslations[key as LinkReach], + callback: () => updateReach(key as LinkReach), + isSelected: linkReach === (key as LinkReach), + disabled: isDisabled, + }; + }); + }, [doc, linkReach, linkReachTranslations, updateReach]); + + const haveDisabledOptions = linkReachOptions.some( + (option) => option.disabled, + ); + + const showLinkRoleOptions = doc.computed_link_reach !== LinkReach.RESTRICTED; + + const linkRoleOptions: DropdownMenuOption[] = useMemo(() => { + const options = doc.abilities.link_select_options[linkReach] ?? []; + return Object.values(LinkRole).map((key) => { + const isDisabled = !options.includes(key); + return { + label: linkModeTranslations[key], + callback: () => updateLinkRole(key), + isSelected: docLinkRole === key, + disabled: isDisabled, + }; + }); + }, [doc, docLinkRole, linkModeTranslations, updateLinkRole, linkReach]); + + const haveDisabledLinkRoleOptions = linkRoleOptions.some( + (option) => option.disabled, + ); + + const undoDesync = () => { + const params: { + id: string; + link_reach: LinkReach; + link_role?: LinkRole; + } = { + id: doc.id, + link_reach: doc.ancestors_link_reach, + }; + if (doc.ancestors_link_role) { + params.link_role = doc.ancestors_link_role; + } + api.mutate(params); + setLinkReach(doc.ancestors_link_reach); + if (doc.ancestors_link_role) { + setDocLinkRole(doc.ancestors_link_role); + } + }; return ( { {t('Link parameters')} + {isDesyncronized && ( + + + + + {t('Sharing rules differ from the parent page')} + + + {doc.abilities.accesses_manage && ( + + )} + + )} { `} disabled={!canManage} showArrow={true} + topMessage={ + haveDisabledOptions + ? t( + 'You cannot restrict access to a subpage relative to its parent page.', + ) + : undefined + } options={linkReachOptions} > @@ -145,7 +254,14 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => { 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 789686ba..8fae3131 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 @@ -39,7 +39,6 @@ export const DocSubPageItem = (props: Props) => { const { spacingsTokens } = useCunninghamTheme(); const [isHover, setIsHover] = useState(false); - const spacing = spacingsTokens(); const router = useRouter(); const { togglePanel } = useLeftPanelStore(); @@ -74,8 +73,9 @@ export const DocSubPageItem = (props: Props) => { .then((allChildren) => { node.open(); - router.push(`/docs/${doc.id}`); + router.push(`/docs/${createdDoc.id}`); treeContext?.treeData.setChildren(node.data.value.id, allChildren); + treeContext?.treeData.setSelectedNode(createdDoc); togglePanel(); }) .catch(console.error); @@ -89,6 +89,7 @@ export const DocSubPageItem = (props: Props) => { treeContext?.treeData.addChild(node.data.value.id, newDoc); node.open(); router.push(`/docs/${createdDoc.id}`); + treeContext?.treeData.setSelectedNode(newDoc); togglePanel(); } }; @@ -115,7 +116,7 @@ export const DocSubPageItem = (props: Props) => { data-testid={`doc-sub-page-item-${props.node.data.value.id}`} $width="100%" $direction="row" - $gap={spacing['xs']} + $gap={spacingsTokens['xs']} role="button" tabIndex={0} $align="center" @@ -139,7 +140,7 @@ export const DocSubPageItem = (props: Props) => { {doc.title || untitledDocument} - {doc.nb_accesses_direct > 1 && ( + {doc.nb_accesses_direct >= 1 && ( { } return ( - - + + { - onCreateSuccess?.(doc); - togglePanel(); - router.push(`/docs/${doc.id}`); - treeContext?.treeData.setSelectedNode(doc); + onSuccess: (newDoc) => { + onCreateSuccess?.(newDoc); }, }); const afterDelete = () => { if (parentId) { treeContext?.treeData.deleteNode(doc.id); - router.push(`/docs/${parentId}`); + void router.push(`/docs/${parentId}`); } else if (doc.id === treeContext?.root?.id && !parentId) { - router.push(`/docs/`); + void router.push(`/docs/`); } else if (treeContext && treeContext.root) { treeContext?.treeData.deleteNode(doc.id); - router.push(`/docs/${treeContext.root.id}`); + void router.push(`/docs/${treeContext.root.id}`); } }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx index 55ebff95..84578bbe 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx @@ -9,5 +9,11 @@ export const useTreeUtils = (doc: Doc) => { isParent: doc.nb_accesses_ancestors <= 1, // it is a parent isChild: doc.nb_accesses_ancestors > 1, // it is a child isCurrentParent: treeContext?.root?.id === doc.id || doc.depth === 1, // it can be a child but not for the current user + isDesyncronized: !!( + doc.ancestors_link_reach && + doc.ancestors_link_role && + (doc.computed_link_reach !== doc.ancestors_link_reach || + doc.computed_link_role !== doc.ancestors_link_role) + ), } as const; };