(frontend) enhance document sharing and visibility features

- 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.
This commit is contained in:
Nathan Panchout
2025-05-19 09:02:47 +02:00
committed by Anthony LC
parent cab7771b82
commit 510d6c3ff1
17 changed files with 543 additions and 119 deletions

View File

@@ -99,6 +99,9 @@ export const DropdownMenu = ({
$size="xs"
$weight="bold"
$padding={{ vertical: 'xs', horizontal: 'base' }}
$css={css`
white-space: pre-line;
`}
>
{topMessage}
</Text>

View File

@@ -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;

View File

@@ -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 (
<>

View File

@@ -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<Doc>();
/**
* 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();

View File

@@ -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
);
};

View File

@@ -0,0 +1,15 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="desynchro">
<path
id="&#244;&#128;&#153;&#160;"
d="M5.99986 12.2256C5.99986 11.7946 6.25238 11.5791 6.75741 11.5791H10.4145C10.802 11.5791 11.2156 11.4811 11.6553 11.2852C12.0994 11.0893 12.5413 10.8302 12.981 10.508C13.4208 10.1902 13.8235 9.84192 14.1892 9.46314C14.5593 9.08437 14.864 8.70778 15.1035 8.33336L15.4496 7.80438C15.5802 7.59105 15.7718 7.48438 16.0243 7.48438C16.1897 7.48438 16.3334 7.5388 16.4553 7.64765C16.5816 7.76084 16.6447 7.91758 16.6447 8.11785C16.6447 8.30506 16.5837 8.48791 16.4618 8.66641L16.2398 9.01254C15.996 9.37389 15.7086 9.7309 15.3778 10.0835C15.0469 10.4362 14.6964 10.7606 14.3263 11.0566C13.9606 11.357 13.6058 11.6073 13.2619 11.8076C12.9179 12.0122 12.6153 12.1472 12.3541 12.2125V12.2386C12.6153 12.2996 12.9179 12.4324 13.2619 12.637C13.6058 12.8416 13.9606 13.0941 14.3263 13.3945C14.6921 13.695 15.0404 14.0193 15.3712 14.3676C15.7065 14.7203 15.996 15.0794 16.2398 15.4451L16.4618 15.7847C16.5837 15.9632 16.6447 16.1461 16.6447 16.3333C16.6447 16.5292 16.5837 16.6838 16.4618 16.797C16.3443 16.9102 16.1963 16.9668 16.0178 16.9668C15.7696 16.9668 15.5802 16.8601 15.4496 16.6468L15.1035 16.1243C14.864 15.7499 14.5593 15.3711 14.1892 14.988C13.8235 14.6092 13.4208 14.2588 12.981 13.9366C12.5413 13.6188 12.0994 13.3619 11.6553 13.166C11.2156 12.9701 10.802 12.8721 10.4145 12.8721H6.75741C6.25238 12.8721 5.99986 12.6566 5.99986 12.2256ZM14.3068 7.64112C14.1065 7.48438 14.0303 7.32329 14.0782 7.15785C14.1261 6.99677 14.2697 6.88139 14.5092 6.81173L17.0822 6.03459C17.2782 5.97364 17.4392 5.99323 17.5655 6.09337C17.6918 6.1935 17.7505 6.34371 17.7418 6.54398L17.6308 9.23457C17.6221 9.48274 17.5437 9.64818 17.3957 9.7309C17.252 9.81362 17.0822 9.78097 16.8863 9.63294L14.3068 7.64112ZM14.3198 16.7251L16.9516 14.8117C17.1519 14.6637 17.3239 14.6354 17.4675 14.7268C17.6112 14.8182 17.6831 14.9858 17.6831 15.2296L17.7157 17.9268C17.7157 18.127 17.6504 18.2729 17.5198 18.3643C17.3935 18.4601 17.2324 18.4753 17.0365 18.41L14.4896 17.5545C14.2545 17.4805 14.1152 17.3608 14.0717 17.1953C14.0281 17.0299 14.1108 16.8732 14.3198 16.7251Z"
fill="#000091"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,15 @@
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="undo">
<path
id="v"
d="M4.6665 12.667V11.3337H9.39984C10.0998 11.3337 10.7082 11.1114 11.2248 10.667C11.7415 10.2225 11.9998 9.66699 11.9998 9.00033C11.9998 8.33366 11.7415 7.7781 11.2248 7.33366C10.7082 6.88921 10.0998 6.66699 9.39984 6.66699H5.19984L6.93317 8.40033L5.99984 9.33366L2.6665 6.00033L5.99984 2.66699L6.93317 3.60033L5.19984 5.33366H9.39984C10.4776 5.33366 11.4026 5.68366 12.1748 6.38366C12.9471 7.08366 13.3332 7.95588 13.3332 9.00033C13.3332 10.0448 12.9471 10.917 12.1748 11.617C11.4026 12.317 10.4776 12.667 9.39984 12.667H4.6665Z"
fill="#000091"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 725 B

View File

@@ -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 (
<Box $gap={spacingsTokens.sm}>
<Box
$gap={spacingsTokens.sm}
$padding={{
horizontal: spacingsTokens.base,
vertical: spacingsTokens.sm,
bottom: '0px',
}}
>
<Text $variation="1000" $weight="bold" $size="sm">
{t('Inherited share')}
</Text>
{inheritedData && (
<DocInheritedShareContentItem
key={inheritedData?.parentId}
accesses={inheritedData?.members ?? []}
document_id={inheritedData?.parentId ?? ''}
/>
)}
</Box>
</Box>
);
};
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 (
<>
<Box
$gap={spacingsTokens.sm}
$width="100%"
$direction="row"
$align="center"
$margin={{ bottom: spacingsTokens.sm }}
$justify="space-between"
>
<Box $direction="row" $align="center" $gap={spacingsTokens.sm}>
<SimpleFileIcon />
<Box>
{isLoading ? (
<Box $direction="column" $gap="2px">
<Box className="skeleton" $width="150px" $height="20px" />
<Box className="skeleton" $width="200px" $height="17px" />
</Box>
) : (
<>
<StyledLink href={`/docs/${doc?.id}`}>
<Text $variation="1000" $weight="bold" $size="sm">
{error && errorCode === 403
? t('You do not have permission to view this document')
: (doc?.title ?? t('Untitled document'))}
</Text>
</StyledLink>
<Text $variation="600" $weight="400" $size="xs">
{t('Members of this page have access')}
</Text>
</>
)}
</Box>
</Box>
{!isLoading && (
<Button color="primary-text" size="small" onClick={accessModal.open}>
{t('See access')}
</Button>
)}
</Box>
{accessModal.isOpen && (
<Modal
isOpen
closeOnClickOutside
onClose={accessModal.close}
title={
<Box $align="flex-start">
<Text $variation="1000" $weight="bold" $size="sm">
{t('Access inherited from the parent page')}
</Text>
</Box>
}
size={ModalSize.MEDIUM}
>
<ShareModalStyle />
<Box $padding={{ top: spacingsTokens.sm }}>
{accesses.map((access) => (
<Fragment key={access.id}>
<DocShareMemberItem doc={doc} access={access} isInherited />
</Fragment>
))}
</Box>
</Modal>
)}
</>
);
};

View File

@@ -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 (
<Text aria-label="doc-role-text" $variation="600">
@@ -27,21 +59,9 @@ export const DocRoleDropdown = ({
</Text>
);
}
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 (
<DropdownMenu
topMessage={message}
topMessage={topMessage}
label="doc-role-dropdown"
showArrow={true}
options={roles}

View File

@@ -33,7 +33,7 @@ type DocShareInvitationItemProps = {
invitation: Invitation;
};
const DocShareInvitationItem = ({
export const DocShareInvitationItem = ({
doc,
invitation,
}: DocShareInvitationItemProps) => {

View File

@@ -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 (
<Box
$width="100%"
@@ -104,14 +117,14 @@ const DocShareMemberItem = ({ doc, access }: Props) => {
right={
<Box $direction="row" $align="center" $gap={spacingsTokens['2xs']}>
<DocRoleDropdown
currentRole={access.role}
currentRole={isInherited ? access.max_role : access.role}
onSelectRole={onUpdate}
canUpdate={doc.abilities.accesses_manage}
canUpdate={canUpdate}
message={message}
rolesAllowed={access.abilities.set_role_to}
/>
{isDesktop && doc.abilities.accesses_manage && (
{isDesktop && canUpdate && (
<DropdownMenu options={moreActions}>
<IconOptions
isHorizontal
@@ -135,15 +148,14 @@ export const QuickSearchGroupMember = ({
doc,
}: QuickSearchGroupMemberProps) => {
const { t } = useTranslation();
const membersQuery = useDocAccessesInfinite({
const membersQuery = useDocAccesses({
docId: doc.id,
});
const membersData: QuickSearchData<Access> = 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: <LoadMoreText data-testid="load-more-members" />,
onSelect: () => void membersQuery.fetchNextPage(),
},
]
: undefined,
endActions: undefined,
};
}, [membersQuery, t]);

View File

@@ -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 (
<>
<Modal
@@ -188,6 +204,15 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
loading={searchUsersQuery.isLoading}
placeholder={t('Type a name or email')}
>
{showInheritedShareContent && (
<DocInheritedShareContent
rawAccesses={
membersQuery?.filter(
(access) => access.document.id !== doc.id,
) ?? []
}
/>
)}
{showMemberSection ? (
<>
<QuickSearchGroupAccessRequest doc={doc} />
@@ -257,10 +282,15 @@ const QuickSearchInviteInputSection = ({
}, [onSelect, searchUsersRawData, t, userQuery]);
return (
<QuickSearchGroup
group={searchUserData}
onSelect={onSelect}
renderElement={(user) => <DocShareModalInviteUserRow user={user} />}
/>
<Box
aria-label={t('List search user result card')}
$padding={{ horizontal: 'base', bottom: '3xs' }}
>
<QuickSearchGroup
group={searchUserData}
onSelect={onSelect}
renderElement={(user) => <DocShareModalInviteUserRow user={user} />}
/>
</Box>
);
};

View File

@@ -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<LinkReach>(doc.link_reach);
const [docLinkRole, setDocLinkRole] = useState<LinkRole>(doc.link_role);
const [linkReach, setLinkReach] = useState<LinkReach>(getDocLinkReach(doc));
const [docLinkRole, setDocLinkRole] = useState<LinkRole>(
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 (
<Box
@@ -94,6 +164,38 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
<Text $weight="700" $size="sm" $variation="700">
{t('Link parameters')}
</Text>
{isDesyncronized && (
<Box
$background={colorsTokens['primary-100']}
$padding="3xs"
$direction="row"
$align="center"
$justify="space-between"
$gap={spacingsTokens['4xs']}
$color={colorsTokens['primary-800']}
$css={css`
border: 1px solid ${colorsTokens['primary-300']};
border-radius: ${spacingsTokens['2xs']};
`}
>
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
<Desync />
<Text $size="xs" $theme="primary" $variation="800" $weight="400">
{t('Sharing rules differ from the parent page')}
</Text>
</Box>
{doc.abilities.accesses_manage && (
<Button
onClick={undoDesync}
size="small"
color="primary-text"
icon={<Undo />}
>
{t('Restore')}
</Button>
)}
</Box>
)}
<Box
$direction="row"
$align="center"
@@ -115,6 +217,13 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
`}
disabled={!canManage}
showArrow={true}
topMessage={
haveDisabledOptions
? t(
'You cannot restrict access to a subpage relative to its parent page.',
)
: undefined
}
options={linkReachOptions}
>
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
@@ -145,7 +254,14 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
<DropdownMenu
disabled={!canManage}
showArrow={true}
options={linkMode}
options={linkRoleOptions}
topMessage={
haveDisabledLinkRoleOptions
? t(
'You cannot restrict access to a subpage relative to its parent page.',
)
: undefined
}
label={t('Visibility mode')}
>
<Text $weight="initial" $variation="600">

View File

@@ -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) => {
<Text $css={ItemTextCss} $size="sm" $variation="1000">
{doc.title || untitledDocument}
</Text>
{doc.nb_accesses_direct > 1 && (
{doc.nb_accesses_direct >= 1 && (
<Icon
variant="filled"
iconName="group"

View File

@@ -128,8 +128,22 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => {
}
return (
<Box data-testid="doc-tree" $height="100%">
<Box $padding={{ horizontal: 'sm', top: 'sm', bottom: '-1px' }}>
<Box
data-testid="doc-tree"
$height="100%"
$css={css`
.c__tree-view--container {
z-index: 1;
margin-top: -10px;
}
`}
>
<Box
$padding={{ horizontal: 'sm', top: 'sm', bottom: '4px' }}
$css={css`
z-index: 2;
`}
>
<Box
$css={css`
padding: ${spacingsTokens['2xs']};

View File

@@ -4,13 +4,12 @@ import {
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
import { useModal } from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import { useRouter } from 'next/router';
import { Fragment, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton, Icon } from '@/components';
import { useLeftPanelStore } from '@/features/left-panel';
import { Doc, ModalRemoveDoc, useCopyDocLink } from '../../doc-management';
import { useCreateChildrenDoc } from '../api/useCreateChildren';
@@ -33,7 +32,7 @@ export const DocTreeItemActions = ({
const router = useRouter();
const { t } = useTranslation();
const deleteModal = useModal();
const { togglePanel } = useLeftPanelStore();
const copyLink = useCopyDocLink(doc.id);
const { isCurrentParent } = useTreeUtils(doc);
const { mutate: detachDoc } = useDetachDoc();
@@ -51,7 +50,7 @@ export const DocTreeItemActions = ({
treeContext.treeData.deleteNode(doc.id);
if (treeContext.root) {
treeContext.treeData.setSelectedNode(treeContext.root);
router.push(`/docs/${treeContext.root.id}`);
void router.push(`/docs/${treeContext.root.id}`);
}
},
},
@@ -91,23 +90,20 @@ export const DocTreeItemActions = ({
];
const { mutate: createChildrenDoc } = useCreateChildrenDoc({
onSuccess: (doc) => {
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}`);
}
};

View File

@@ -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;
};