✨(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:
committed by
Anthony LC
parent
cab7771b82
commit
510d6c3ff1
@@ -99,6 +99,9 @@ export const DropdownMenu = ({
|
||||
$size="xs"
|
||||
$weight="bold"
|
||||
$padding={{ vertical: 'xs', horizontal: 'base' }}
|
||||
$css={css`
|
||||
white-space: pre-line;
|
||||
`}
|
||||
>
|
||||
{topMessage}
|
||||
</Text>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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="ô€™ "
|
||||
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 |
@@ -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 |
@@ -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,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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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']};
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user