✨(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:
@@ -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();
|
||||
|
||||
+206
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+33
-13
@@ -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}
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ type DocShareInvitationItemProps = {
|
||||
invitation: Invitation;
|
||||
};
|
||||
|
||||
const DocShareInvitationItem = ({
|
||||
export const DocShareInvitationItem = ({
|
||||
doc,
|
||||
invitation,
|
||||
}: DocShareInvitationItemProps) => {
|
||||
|
||||
+32
-25
@@ -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]);
|
||||
|
||||
|
||||
+148
-18
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+153
-34
@@ -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
|
||||
|
||||
+8
-12
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+5
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user