(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-07-01 15:46:48 +02:00
parent dd742d9e6d
commit 2e26ad2b09
14 changed files with 632 additions and 122 deletions
@@ -99,6 +99,9 @@ export const DropdownMenu = ({
$size="xs"
$weight="bold"
$padding={{ vertical: 'xs', horizontal: 'base' }}
$css={css`
white-space: pre-line;
`}
>
{topMessage}
</Text>
@@ -65,9 +65,7 @@ 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;
@@ -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 (
<>
@@ -1,7 +1,8 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Button, useModal } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -23,7 +24,20 @@ const DocToolBoxLicence = dynamic(() =>
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 { spacingsTokens } = useCunninghamTheme();
@@ -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>
)}
</>
);
};
@@ -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}
@@ -33,7 +33,7 @@ type DocShareInvitationItemProps = {
invitation: Invitation;
};
const DocShareInvitationItem = ({
export const DocShareInvitationItem = ({
doc,
invitation,
}: DocShareInvitationItemProps) => {
@@ -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,10 @@ const DocShareMemberItem = ({ doc, access }: Props) => {
},
];
const canUpdate = isInherited
? false
: (doc?.abilities.accesses_manage ?? false);
return (
<Box
$width="100%"
@@ -104,14 +119,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 +150,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 +167,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]);
@@ -11,22 +11,26 @@ import {
QuickSearchGroup,
} from '@/components/quick-search/';
import { User } from '@/features/auth';
import { Doc } from '@/features/docs';
import { Access, Doc } from '@/features/docs';
import { useResponsiveStore } from '@/stores';
import { isValidEmail } from '@/utils';
import { KEY_LIST_USER, useUsers } from '../api';
import {
ButtonAccessRequest,
QuickSearchGroupAccessRequest,
} from './DocShareAccessRequest';
KEY_LIST_USER,
useDocAccesses,
useDocInvitationsInfinite,
useUsers,
} from '../api';
import { Invitation } from '../types';
import { DocInheritedShareContent } from './DocInheritedShareContent';
import { ButtonAccessRequest } from './DocShareAccessRequest';
import { DocShareAddMemberList } from './DocShareAddMemberList';
import {
DocShareInvitationItem,
DocShareModalInviteUserRow,
QuickSearchGroupInvitation,
} from './DocShareInvitation';
import { QuickSearchGroupMember } from './DocShareMember';
import { DocShareMemberItem } from './DocShareMember';
import { DocShareModalFooter } from './DocShareModalFooter';
const ShareModalStyle = createGlobalStyle`
@@ -69,6 +73,10 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
setInputValue('');
};
const { data: membersQuery } = useDocAccesses({
docId: doc.id,
});
const searchUsersQuery = useUsers(
{ query: userQuery, docId: doc.id },
{
@@ -77,6 +85,23 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
},
);
const membersData: QuickSearchData<Access> = useMemo(() => {
const members: Access[] =
membersQuery?.filter((access) => access.document.id === doc.id) ?? [];
const count = doc.nb_accesses_direct > 1 ? doc.nb_accesses_direct : 1;
return {
groupName:
count === 1
? t('Document owner')
: t('Share with {{count}} users', {
count: count,
}),
elements: members,
};
}, [membersQuery, doc.id, doc.nb_accesses_direct, t]);
const onFilter = useDebouncedCallback((str: string) => {
setUserQuery(str);
}, 300);
@@ -103,6 +128,18 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
setListHeight(height);
};
const inheritedAccesses = useMemo(() => {
return (
membersQuery?.filter((access) => access.document.id !== doc.id) ?? []
);
}, [membersQuery, doc.id]);
// const rootDoc = treeContext?.root;
const isRootDoc = false;
const showInheritedShareContent =
inheritedAccesses.length > 0 && showMemberSection && !isRootDoc;
return (
<>
<Modal
@@ -188,12 +225,22 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
loading={searchUsersQuery.isLoading}
placeholder={t('Type a name or email')}
>
{inheritedAccesses.length > 0 &&
showInheritedShareContent && (
<DocInheritedShareContent
rawAccesses={
membersQuery?.filter(
(access) => access.document.id !== doc.id,
) ?? []
}
/>
)}
{showMemberSection ? (
<>
<QuickSearchGroupAccessRequest doc={doc} />
<QuickSearchGroupInvitation doc={doc} />
<QuickSearchGroupMember doc={doc} />
</>
<QuickSearchMemberSection
doc={doc}
hasInheritedShareContent={inheritedAccesses.length > 0}
membersData={membersData}
/>
) : (
<QuickSearchInviteInputSection
searchUsersRawData={searchUsersQuery.data}
@@ -257,10 +304,93 @@ 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>
);
};
interface QuickSearchMemberSectionProps {
doc: Doc;
membersData: QuickSearchData<Access>;
hasInheritedShareContent?: boolean;
}
const QuickSearchMemberSection = ({
doc,
membersData,
hasInheritedShareContent = false,
}: QuickSearchMemberSectionProps) => {
const { t } = useTranslation();
const { data, hasNextPage, fetchNextPage } = useDocInvitationsInfinite({
docId: doc.id,
});
const invitationsData: QuickSearchData<Invitation> = useMemo(() => {
const invitations = data?.pages.flatMap((page) => page.results) || [];
return {
groupName: t('Pending invitations'),
elements: invitations,
endActions: hasNextPage
? [
{
content: <Text data-testid="load-more-invitations" />,
onSelect: () => void fetchNextPage(),
},
]
: undefined,
};
}, [data?.pages, fetchNextPage, hasNextPage, t]);
const showSeparator =
invitationsData.elements.length > 0 && membersData.elements.length > 0;
if (
invitationsData.elements.length === 0 &&
membersData.elements.length === 0
) {
return null;
}
return (
<>
{hasInheritedShareContent && <HorizontalSeparator $withPadding={false} />}
{invitationsData.elements.length > 0 && (
<Box
aria-label={t('List invitation card')}
$padding={{ horizontal: 'base', bottom: '3xs' }}
$margin={{ bottom: showSeparator ? 'base' : undefined }}
>
<QuickSearchGroup
group={invitationsData}
renderElement={(invitation) => (
<DocShareInvitationItem doc={doc} invitation={invitation} />
)}
/>
</Box>
)}
{showSeparator && <HorizontalSeparator $withPadding={false} />}
<Box
aria-label={t('List members card')}
$padding={{ horizontal: 'base', bottom: '3xs' }}
>
<QuickSearchGroup
group={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
)}
/>
</Box>
</>
);
};
@@ -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,
docLinkIsDesync,
getDocLinkReach,
useUpdateDocLink,
} from '@/features/docs';
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,19 @@ 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 { linkModeTranslations, linkReachChoices, linkReachTranslations } =
useTranslatedShareSettings();
const description =
docLinkRole === LinkRole.READER
? linkReachChoices[linkReach].descriptionReadOnly
: linkReachChoices[linkReach].descriptionEdit;
const api = useUpdateDocLink({
onSuccess: () => {
toast(
@@ -51,38 +68,94 @@ 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);
}
};
const showDesync = useMemo(() => {
return docLinkIsDesync(doc);
}, [doc]);
return (
<Box
@@ -94,6 +167,38 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
<Text $weight="700" $size="sm" $variation="700">
{t('Link parameters')}
</Text>
{showDesync && (
<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 +220,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 +257,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">
@@ -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"
@@ -25,7 +25,7 @@ type DocTreeProps = {
};
export const DocTree = ({ initialTargetId }: DocTreeProps) => {
const { spacingsTokens } = useCunninghamTheme();
const spacing = spacingsTokens();
const treeContext = useTreeContext<Doc>();
const { currentDoc } = useDocStore();
const router = useRouter();
@@ -134,11 +134,25 @@ 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: ${spacing['2xs']};
padding: ${spacingsTokens['2xs']};
border-radius: 4px;
width: 100%;
background-color: ${rootIsSelected
@@ -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';
@@ -34,7 +33,7 @@ export const DocTreeItemActions = ({
const router = useRouter();
const { t } = useTranslation();
const deleteModal = useModal();
const { togglePanel } = useLeftPanelStore();
const copyLink = useCopyDocLink(doc.id);
const canUpdate = isOwnerOrAdmin(doc);
const { isCurrentParent } = useTreeUtils(doc);
@@ -53,7 +52,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}`);
}
},
},
@@ -93,23 +92,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}`);
}
};
@@ -152,10 +152,11 @@ export const DraggableDocGridItem = ({
canDrag,
updateCanDrop,
}: DocGridItemProps) => {
const canDropItem = doc.user_roles.some(
(role) =>
role === Role.ADMIN || role === Role.OWNER || role === Role.EDITOR,
);
const userRole = doc.user_role;
const canDropItem =
userRole === Role.ADMIN ||
userRole === Role.OWNER ||
userRole === Role.EDITOR;
return (
<Droppable