(frontend) add AlertModal and enhance document sharing features

- Introduced a new `AlertModal` component for confirmation dialogs.
- Updated `DocToolBoxLicenceAGPL` and `DocToolBoxLicenceMIT` to include
`isRootDoc` prop for better document management.
- Enhanced `DocShareModal` to conditionally render content based on the
root document status.
- Improved `DocInheritedShareContent` to display inherited access
information more effectively.
- Refactored `DocRoleDropdown` to handle access removal actions and
improve role management.
- Updated `DocShareMemberItem` to accommodate new access management
features.
This commit is contained in:
Nathan Panchout
2025-07-02 14:33:12 +02:00
committed by Anthony LC
parent 1c5270e301
commit 44909faa67
20 changed files with 461 additions and 394 deletions

View File

@@ -0,0 +1,68 @@
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from './Box';
import { Text } from './Text';
export type AlertModalProps = {
description: ReactNode;
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
cancelLabel?: string;
confirmLabel?: string;
};
export const AlertModal = ({
cancelLabel,
confirmLabel,
description,
isOpen,
onClose,
onConfirm,
title,
}: AlertModalProps) => {
const { t } = useTranslation();
return (
<Modal
isOpen={isOpen}
size={ModalSize.MEDIUM}
onClose={onClose}
title={
<Text $size="h6" $align="flex-start" $variation="1000">
{title}
</Text>
}
rightActions={
<>
<Button
aria-label={t('Close the modal')}
color="secondary"
fullWidth
onClick={() => onClose()}
>
{cancelLabel ?? t('Cancel')}
</Button>
<Button
aria-label={confirmLabel ?? t('Confirm')}
color="danger"
onClick={onConfirm}
>
{confirmLabel ?? t('Confirm')}
</Button>
</>
}
>
<Box
aria-label={t('Confirmation button')}
className="--docs--alert-modal"
>
<Box>
<Text $variation="600">{description}</Text>
</Box>
</Box>
</Modal>
);
};

View File

@@ -1,3 +1,4 @@
export * from './AlertModal';
export * from './Box'; export * from './Box';
export * from './BoxButton'; export * from './BoxButton';
export * from './Card'; export * from './Card';

View File

@@ -277,7 +277,11 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
</Box> </Box>
{modalShare.isOpen && ( {modalShare.isOpen && (
<DocShareModal onClose={() => modalShare.close()} doc={doc} /> <DocShareModal
onClose={() => modalShare.close()}
doc={doc}
isRootDoc={treeContext?.root?.id === doc.id}
/>
)} )}
{isModalExportOpen && ModalExport && ( {isModalExportOpen && ModalExport && (
<ModalExport onClose={() => setIsModalExportOpen(false)} doc={doc} /> <ModalExport onClose={() => setIsModalExportOpen(false)} doc={doc} />

View File

@@ -5,14 +5,13 @@ import {
VariantType, VariantType,
useToastProvider, useToastProvider,
} from '@openfun/cunningham-react'; } from '@openfun/cunningham-react';
import { t } from 'i18next';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { Trans, useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components'; import { Box, Text, TextErrors } from '@/components';
import { useRemoveDoc } from '../api/useRemoveDoc'; import { useRemoveDoc } from '../api/useRemoveDoc';
import { useTrans } from '../hooks';
import { Doc } from '../types'; import { Doc } from '../types';
interface ModalRemoveDocProps { interface ModalRemoveDocProps {
@@ -27,10 +26,9 @@ export const ModalRemoveDoc = ({
afterDelete, afterDelete,
}: ModalRemoveDocProps) => { }: ModalRemoveDocProps) => {
const { toast } = useToastProvider(); const { toast } = useToastProvider();
const { t } = useTranslation();
const { push } = useRouter(); const { push } = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { untitledDocument } = useTrans();
const { const {
mutate: removeDoc, mutate: removeDoc,
isError, isError,
@@ -82,7 +80,7 @@ export const ModalRemoveDoc = ({
</Button> </Button>
</> </>
} }
size={ModalSize.SMALL} size={ModalSize.MEDIUM}
title={ title={
<Text <Text
$size="h6" $size="h6"
@@ -100,11 +98,14 @@ export const ModalRemoveDoc = ({
className="--docs--modal-remove-doc" className="--docs--modal-remove-doc"
> >
{!isError && ( {!isError && (
<Text $size="sm" $variation="600"> <>
{t('Are you sure you want to delete the document "{{title}}"?', { <Text $size="sm" $variation="600" $display="inline-block">
title: doc.title ?? untitledDocument, <Trans t={t} i18nKey="modal-remove-doc">
})} This document and <strong>any sub-documents</strong> will be
</Text> permanently deleted. This action is irreversible.
</Trans>
</Text>
</>
)} )}
{isError && <TextErrors causes={error.cause} />} {isError && <TextErrors causes={error.cause} />}

View File

@@ -74,7 +74,10 @@ export const DocSearchModal = ({
loading={loading} loading={loading}
onFilter={handleInputSearch} onFilter={handleInputSearch}
> >
<Box $height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}> <Box
$padding={{ horizontal: '10px' }}
$height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}
>
{showFilters && ( {showFilters && (
<DocSearchFilters <DocSearchFilters
values={filters} values={filters}

View File

@@ -1,7 +1,6 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query'; import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api'; import { APIError, errorCauses, fetchAPI } from '@/api';
import { Access } from '@/docs/doc-management'; import { Access } from '@/docs/doc-management';
export type DocAccessesParams = { export type DocAccessesParams = {
@@ -9,12 +8,10 @@ export type DocAccessesParams = {
ordering?: string; ordering?: string;
}; };
export type DocAccessesAPIParams = DocAccessesParams & {};
export const getDocAccesses = async ({ export const getDocAccesses = async ({
docId, docId,
ordering, ordering,
}: DocAccessesAPIParams): Promise<Access[]> => { }: DocAccessesParams): Promise<Access[]> => {
let url = `documents/${docId}/accesses/`; let url = `documents/${docId}/accesses/`;
if (ordering) { if (ordering) {
@@ -36,7 +33,7 @@ export const getDocAccesses = async ({
export const KEY_LIST_DOC_ACCESSES = 'docs-accesses'; export const KEY_LIST_DOC_ACCESSES = 'docs-accesses';
export function useDocAccesses( export function useDocAccesses(
params: DocAccessesAPIParams, params: DocAccessesParams,
queryConfig?: UseQueryOptions<Access[], APIError, Access[]>, queryConfig?: UseQueryOptions<Access[], APIError, Access[]>,
) { ) {
return useQuery<Access[], APIError, Access[]>({ return useQuery<Access[], APIError, Access[]>({

View File

@@ -1,91 +1,25 @@
import { Button, Modal, ModalSize, useModal } from '@openfun/cunningham-react'; import { Button } from '@openfun/cunningham-react';
import { Fragment, useMemo } from 'react'; import { Fragment } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, StyledLink, Text } from '@/components'; import { Box, HorizontalSeparator, Icon, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { import { Access, useDocStore } from '../../doc-management';
Access,
RoleImportance,
useDoc,
useDocStore,
} from '../../doc-management';
import SimpleFileIcon from '../../docs-grid/assets/simple-document.svg';
import { DocShareMemberItem } from './DocShareMemberItem'; import { DocShareMemberItem } from './DocShareMember';
const ShareModalStyle = createGlobalStyle`
.c__modal__title {
padding-bottom: 0 !important;
}
.c__modal__scroller {
padding: 15px 15px !important;
}
`;
type Props = { type DocInheritedShareContentProps = {
rawAccesses: Access[]; rawAccesses: Access[];
}; };
const getMaxRoleBetweenAccesses = (access1: Access, access2: Access) => { export const DocInheritedShareContent = ({
const role1 = access1.max_role; rawAccesses,
const role2 = access2.max_role; }: DocInheritedShareContentProps) => {
const roleImportance1 = RoleImportance[role1];
const roleImportance2 = RoleImportance[role2];
return roleImportance1 > roleImportance2 ? role1 : role2;
};
export const DocInheritedShareContent = ({ rawAccesses }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme(); const { spacingsTokens } = useCunninghamTheme();
const { currentDoc } = useDocStore(); 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 // Check if accesses map is empty
const hasAccesses = rawAccesses.length > 0; const hasAccesses = rawAccesses.length > 0;
@@ -94,113 +28,44 @@ export const DocInheritedShareContent = ({ rawAccesses }: Props) => {
} }
return ( return (
<Box $gap={spacingsTokens.sm}> <Box
$gap={spacingsTokens.sm}
$padding={{ top: spacingsTokens.sm }}
className="--docs--doc-inherited-share-content"
>
<HorizontalSeparator $withPadding={false} />
<Box <Box
$gap={spacingsTokens.sm} $gap={spacingsTokens.sm}
$padding={{ $padding={{
horizontal: spacingsTokens.base, horizontal: spacingsTokens.base,
vertical: spacingsTokens.sm,
bottom: '0px',
}} }}
> >
<Text $variation="1000" $weight="bold" $size="sm"> <Box $direction="row" $align="center" $gap={spacingsTokens['4xs']}>
{t('Inherited share')} <Text $variation="1000" $weight="bold" $size="sm">
</Text> {t('People with access via the parent document')}
</Text>
{inheritedData && ( <Box>
<DocInheritedShareContentItem <StyledLink href={`/docs/${rawAccesses[0].document.id}`}>
key={inheritedData?.parentId} <Button
accesses={inheritedData?.members ?? []} size="small"
document_id={inheritedData?.parentId ?? ''} icon={
/> <Icon
)} $theme="greyscale"
$variation="600"
iconName="open_in_new"
/>
}
color="tertiary-text"
/>
</StyledLink>
</Box>
</Box>
{rawAccesses.map((access) => (
<Fragment key={access.id}>
<DocShareMemberItem doc={currentDoc} access={access} isInherited />
</Fragment>
))}
</Box> </Box>
</Box> </Box>
); );
}; };
type DocInheritedShareContentItemProps = {
accesses: Access[];
document_id: string;
};
export const DocInheritedShareContentItem = ({
accesses,
document_id,
}: DocInheritedShareContentItemProps) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const { data: doc, error, isLoading } = useDoc({ id: document_id });
const errorCode = error?.status;
const accessModal = useModal();
if ((!doc && !isLoading && !error) || (error && errorCode !== 403)) {
return null;
}
return (
<>
<Box
$gap={spacingsTokens.sm}
$width="100%"
$direction="row"
$align="center"
$margin={{ bottom: spacingsTokens.sm }}
$justify="space-between"
>
<Box $direction="row" $align="center" $gap={spacingsTokens.sm}>
<SimpleFileIcon />
<Box>
{isLoading ? (
<Box $direction="column" $gap="2px">
<Box className="skeleton" $width="150px" $height="20px" />
<Box className="skeleton" $width="200px" $height="17px" />
</Box>
) : (
<>
<StyledLink href={`/docs/${doc?.id}`}>
<Text $variation="1000" $weight="bold" $size="sm">
{error && errorCode === 403
? t('You do not have permission to view this document')
: (doc?.title ?? t('Untitled document'))}
</Text>
</StyledLink>
<Text $variation="600" $weight="400" $size="xs">
{t('Members of this page have access')}
</Text>
</>
)}
</Box>
</Box>
{!isLoading && (
<Button color="primary-text" size="small" onClick={accessModal.open}>
{t('See access')}
</Button>
)}
</Box>
{accessModal.isOpen && (
<Modal
isOpen
closeOnClickOutside
onClose={accessModal.close}
title={
<Box $align="flex-start">
<Text $variation="1000" $weight="bold" $size="sm">
{t('Access inherited from the parent page')}
</Text>
</Box>
}
size={ModalSize.MEDIUM}
>
<ShareModalStyle />
<Box $padding={{ top: spacingsTokens.sm }}>
{accesses.map((access) => (
<Fragment key={access.id}>
<DocShareMemberItem doc={doc} access={access} isInherited />
</Fragment>
))}
</Box>
</Modal>
)}
</>
);
};

View File

@@ -1,16 +1,30 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { css } from 'styled-components'; import { css } from 'styled-components';
import { DropdownMenu, DropdownMenuOption, Text } from '@/components'; import { DropdownMenu, DropdownMenuOption, Text } from '@/components';
import { Role, useTrans } from '@/docs/doc-management/'; import {
Access,
Doc,
KEY_SUB_PAGE,
Role,
useTrans,
} from '@/docs/doc-management/';
import { useDeleteDocAccess, useDeleteDocInvitation } from '../api';
import { Invitation, isInvitation } from '../types';
type DocRoleDropdownProps = { type DocRoleDropdownProps = {
doc?: Doc;
access?: Access | Invitation;
canUpdate?: boolean; canUpdate?: boolean;
currentRole: Role; currentRole: Role;
message?: string; message?: string;
onSelectRole: (role: Role) => void; onSelectRole: (role: Role) => void;
rolesAllowed?: Role[]; rolesAllowed?: Role[];
isLastOwner?: boolean;
}; };
export const DocRoleDropdown = ({ export const DocRoleDropdown = ({
@@ -18,10 +32,65 @@ export const DocRoleDropdown = ({
currentRole, currentRole,
message, message,
onSelectRole, onSelectRole,
doc,
rolesAllowed, rolesAllowed,
access,
isLastOwner = false,
}: DocRoleDropdownProps) => { }: DocRoleDropdownProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { transRole, translatedRoles } = useTrans(); const { transRole, translatedRoles } = useTrans();
const queryClient = useQueryClient();
const { toast } = useToastProvider();
const { mutate: removeDocInvitation } = useDeleteDocInvitation({
onSuccess: () => {
if (!doc) {
return;
}
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
onError: (error) => {
toast(
error?.data?.role?.[0] ?? t('Error during delete invitation'),
VariantType.ERROR,
{
duration: 4000,
},
);
},
});
const { mutate: removeDocAccess } = useDeleteDocAccess({
onSuccess: () => {
if (!doc) {
return;
}
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
onError: () => {
toast(t('Error while deleting invitation'), VariantType.ERROR, {
duration: 4000,
});
},
});
const onRemove = () => {
const invitation = isInvitation(access);
if (!doc || !access) {
return;
}
if (invitation) {
removeDocInvitation({ invitationId: access.id, docId: doc.id });
} else {
removeDocAccess({ accessId: access.id, docId: doc.id });
}
};
/** /**
* When there is a higher role, the rolesAllowed are truncated * When there is a higher role, the rolesAllowed are truncated
@@ -44,14 +113,18 @@ export const DocRoleDropdown = ({
}, [canUpdate, rolesAllowed, translatedRoles, message, t]); }, [canUpdate, rolesAllowed, translatedRoles, message, t]);
const roles: DropdownMenuOption[] = Object.keys(translatedRoles).map( const roles: DropdownMenuOption[] = Object.keys(translatedRoles).map(
(key) => { (key, index) => {
const isLast = index === Object.keys(translatedRoles).length - 1;
return { return {
label: transRole(key as Role), label: transRole(key as Role),
callback: () => onSelectRole?.(key as Role), callback: () => onSelectRole?.(key as Role),
isSelected: currentRole === (key as Role), isSelected: currentRole === (key as Role),
showSeparator: isLast,
disabled: isLastOwner && key !== 'owner',
}; };
}, },
); );
if (!canUpdate) { if (!canUpdate) {
return ( return (
<Text aria-label="doc-role-text" $variation="600"> <Text aria-label="doc-role-text" $variation="600">
@@ -59,15 +132,27 @@ export const DocRoleDropdown = ({
</Text> </Text>
); );
} }
return ( return (
<DropdownMenu <DropdownMenu
topMessage={topMessage} topMessage={topMessage}
label="doc-role-dropdown" label="doc-role-dropdown"
showArrow={true} showArrow={true}
options={roles} arrowCss={css`
color: var(--c--theme--colors--primary-800) !important;
`}
options={[
...roles,
{
label: t('Remove access'),
disabled: !access?.abilities.destroy,
callback: onRemove,
},
]}
> >
<Text <Text
$variation="600" $theme="primary"
$variation="800"
$css={css` $css={css`
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
`} `}

View File

@@ -122,6 +122,8 @@ export const DocShareInvitationItem = ({
currentRole={invitation.role} currentRole={invitation.role}
onSelectRole={onUpdate} onSelectRole={onUpdate}
canUpdate={canUpdate} canUpdate={canUpdate}
doc={doc}
access={invitation}
/> />
{canUpdate && ( {canUpdate && (

View File

@@ -3,19 +3,14 @@ import { useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import { Box } from '@/components';
Box, import { QuickSearchData } from '@/components/quick-search';
DropdownMenu, import { QuickSearchGroup } from '@/components/quick-search/QuickSearchGroup';
DropdownMenuOption,
IconOptions,
} from '@/components';
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { Access, Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management/'; import { Access, Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management/';
import { useResponsiveStore } from '@/stores';
import { useDeleteDocAccess, useDocAccesses, useUpdateDocAccess } from '../api'; import { useDocAccesses, useUpdateDocAccess } from '../api';
import { useWhoAmI } from '../hooks'; import { useWhoAmI } from '../hooks/';
import { DocRoleDropdown } from './DocRoleDropdown'; import { DocRoleDropdown } from './DocRoleDropdown';
import { SearchUserRow } from './SearchUserRow'; import { SearchUserRow } from './SearchUserRow';
@@ -35,7 +30,6 @@ export const DocShareMemberItem = ({
const { isLastOwner } = useWhoAmI(access); const { isLastOwner } = useWhoAmI(access);
const { toast } = useToastProvider(); const { toast } = useToastProvider();
const { isDesktop } = useResponsiveStore();
const { spacingsTokens } = useCunninghamTheme(); const { spacingsTokens } = useCunninghamTheme();
const message = isLastOwner const message = isLastOwner
@@ -60,22 +54,6 @@ export const DocShareMemberItem = ({
}, },
}); });
const { mutate: removeDocAccess } = useDeleteDocAccess({
onSuccess: () => {
if (!doc) {
return;
}
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
onError: () => {
toast(t('Error while deleting the member.'), VariantType.ERROR, {
duration: 4000,
});
},
});
const onUpdate = (newRole: Role) => { const onUpdate = (newRole: Role) => {
if (!doc) { if (!doc) {
return; return;
@@ -87,22 +65,6 @@ export const DocShareMemberItem = ({
}); });
}; };
const onRemove = () => {
if (!doc) {
return;
}
removeDocAccess({ accessId: access.id, docId: doc.id });
};
const moreActions: DropdownMenuOption[] = [
{
label: t('Delete'),
icon: 'delete',
callback: onRemove,
disabled: !access.abilities.destroy,
},
];
const canUpdate = isInherited ? false : !!doc?.abilities.accesses_manage; const canUpdate = isInherited ? false : !!doc?.abilities.accesses_manage;
return ( return (
@@ -119,20 +81,13 @@ export const DocShareMemberItem = ({
<DocRoleDropdown <DocRoleDropdown
currentRole={isInherited ? access.max_role : access.role} currentRole={isInherited ? access.max_role : access.role}
onSelectRole={onUpdate} onSelectRole={onUpdate}
isLastOwner={isLastOwner}
canUpdate={canUpdate} canUpdate={canUpdate}
message={message} message={message}
rolesAllowed={access.abilities.set_role_to} rolesAllowed={access.abilities.set_role_to}
access={access}
doc={doc}
/> />
{isDesktop && canUpdate && (
<DropdownMenu options={moreActions}>
<IconOptions
isHorizontal
data-testid="doc-share-member-more-actions"
$variation="600"
/>
</DropdownMenu>
)}
</Box> </Box>
} }
/> />
@@ -170,7 +125,7 @@ export const QuickSearchGroupMember = ({
}, [membersQuery, t]); }, [membersQuery, t]);
return ( return (
<Box aria-label={t('List members card')}> <Box aria-label={t('List members card')} $padding={{ bottom: '3xs' }}>
<QuickSearchGroup <QuickSearchGroup
group={membersData} group={membersData}
renderElement={(access) => ( renderElement={(access) => (

View File

@@ -41,10 +41,11 @@ const ShareModalStyle = createGlobalStyle`
type Props = { type Props = {
doc: Doc; doc: Doc;
isRootDoc?: boolean;
onClose: () => void; onClose: () => void;
}; };
export const DocShareModal = ({ doc, onClose }: Props) => { export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const selectedUsersRef = useRef<HTMLDivElement>(null); const selectedUsersRef = useRef<HTMLDivElement>(null);
@@ -58,7 +59,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [listHeight, setListHeight] = useState<string>('400px'); const [listHeight, setListHeight] = useState<string>('400px');
const canShare = doc.abilities.accesses_manage; const canShare = doc.abilities.accesses_manage && isRootDoc;
const canViewAccesses = doc.abilities.accesses_view; const canViewAccesses = doc.abilities.accesses_view;
const showMemberSection = inputValue === '' && selectedUsers.length === 0; const showMemberSection = inputValue === '' && selectedUsers.length === 0;
const showFooter = selectedUsers.length === 0 && !inputValue; const showFooter = selectedUsers.length === 0 && !inputValue;
@@ -114,8 +115,6 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
); );
}, [membersQuery, doc.id]); }, [membersQuery, doc.id]);
const isRootDoc = false;
const showInheritedShareContent = const showInheritedShareContent =
inheritedAccesses.length > 0 && showMemberSection && !isRootDoc; inheritedAccesses.length > 0 && showMemberSection && !isRootDoc;
@@ -149,10 +148,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
> >
<Box ref={selectedUsersRef}> <Box ref={selectedUsersRef}>
{canShare && selectedUsers.length > 0 && ( {canShare && selectedUsers.length > 0 && (
<Box <Box $padding={{ horizontal: 'base' }} $margin={{ top: '12x' }}>
$padding={{ horizontal: 'base' }}
$margin={{ top: '11px' }}
>
<DocShareAddMemberList <DocShareAddMemberList
doc={doc} doc={doc}
selectedUsers={selectedUsers} selectedUsers={selectedUsers}
@@ -165,7 +161,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
/> />
</Box> </Box>
)} )}
{!canViewAccesses && <HorizontalSeparator />} {!canViewAccesses && <HorizontalSeparator customPadding="12px" />}
</Box> </Box>
<Box data-testid="doc-share-quick-search"> <Box data-testid="doc-share-quick-search">
@@ -213,13 +209,15 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
} }
/> />
)} )}
{showMemberSection ? ( {showMemberSection && isRootDoc && (
<> <Box $padding={{ horizontal: 'base' }}>
<QuickSearchGroupAccessRequest doc={doc} /> <QuickSearchGroupAccessRequest doc={doc} />
<QuickSearchGroupInvitation doc={doc} /> <QuickSearchGroupInvitation doc={doc} />
<QuickSearchGroupMember doc={doc} /> <QuickSearchGroupMember doc={doc} />
</> </Box>
) : ( )}
{!showMemberSection && canShare && (
<QuickSearchInviteInputSection <QuickSearchInviteInputSection
searchUsersRawData={searchUsersQuery.data} searchUsersRawData={searchUsersQuery.data}
onSelect={onSelect} onSelect={onSelect}
@@ -232,7 +230,13 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
</Box> </Box>
<Box ref={handleRef}> <Box ref={handleRef}>
{showFooter && <DocShareModalFooter doc={doc} onClose={onClose} />} {showFooter && (
<DocShareModalFooter
doc={doc}
onClose={onClose}
canEditVisibility={canShare}
/>
)}
</Box> </Box>
</Box> </Box>
</Modal> </Modal>

View File

@@ -10,9 +10,14 @@ import { DocVisibility } from './DocVisibility';
type Props = { type Props = {
doc: Doc; doc: Doc;
onClose: () => void; onClose: () => void;
canEditVisibility?: boolean;
}; };
export const DocShareModalFooter = ({ doc, onClose }: Props) => { export const DocShareModalFooter = ({
doc,
onClose,
canEditVisibility = true,
}: Props) => {
const copyDocLink = useCopyDocLink(doc.id); const copyDocLink = useCopyDocLink(doc.id);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -22,10 +27,10 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
`} `}
className="--docs--doc-share-modal-footer" className="--docs--doc-share-modal-footer"
> >
<HorizontalSeparator $withPadding={true} /> <HorizontalSeparator $withPadding={true} customPadding="12px" />
<DocVisibility doc={doc} /> <DocVisibility doc={doc} canEdit={canEditVisibility} />
<HorizontalSeparator /> <HorizontalSeparator customPadding="12px" />
<Box <Box
$direction="row" $direction="row"

View File

@@ -34,14 +34,15 @@ import Undo from './../assets/undo.svg';
interface DocVisibilityProps { interface DocVisibilityProps {
doc: Doc; doc: Doc;
canEdit?: boolean;
} }
export const DocVisibility = ({ doc }: DocVisibilityProps) => { export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { toast } = useToastProvider(); const { toast } = useToastProvider();
const { isDesktop } = useResponsiveStore(); const { isDesktop } = useResponsiveStore();
const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const canManage = doc.abilities.accesses_manage; const canManage = doc.abilities.accesses_manage && canEdit;
const [linkReach, setLinkReach] = useState<LinkReach>(getDocLinkReach(doc)); const [linkReach, setLinkReach] = useState<LinkReach>(getDocLinkReach(doc));
const [docLinkRole, setDocLinkRole] = useState<LinkRole>( const [docLinkRole, setDocLinkRole] = useState<LinkRole>(
doc.computed_link_role ?? LinkRole.READER, doc.computed_link_role ?? LinkRole.READER,

View File

@@ -17,6 +17,14 @@ export interface Invitation {
}; };
} }
/**
* Type guard to check if an object is an Invitation
* Invitation has unique properties: email, issuer, is_expired, and document as a string
*/
export const isInvitation = (obj: unknown): obj is Invitation => {
return obj !== null && typeof obj === 'object' && 'issuer' in obj;
};
export enum OptionType { export enum OptionType {
INVITATION = 'invitation', INVITATION = 'invitation',
NEW_MEMBER = 'new_member', NEW_MEMBER = 'new_member',

View File

@@ -16,6 +16,7 @@ import {
useTrans, useTrans,
} from '@/features/docs/doc-management'; } from '@/features/docs/doc-management';
import { useLeftPanelStore } from '@/features/left-panel'; import { useLeftPanelStore } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
import Logo from './../assets/sub-page-logo.svg'; import Logo from './../assets/sub-page-logo.svg';
import { DocTreeItemActions } from './DocTreeItemActions'; import { DocTreeItemActions } from './DocTreeItemActions';
@@ -37,7 +38,8 @@ export const DocSubPageItem = (props: Props) => {
const { untitledDocument } = useTrans(); const { untitledDocument } = useTrans();
const { node } = props; const { node } = props;
const { spacingsTokens } = useCunninghamTheme(); const { spacingsTokens } = useCunninghamTheme();
const [isHover, setIsHover] = useState(false); const { isDesktop } = useResponsiveStore();
const [actionsOpen, setActionsOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { togglePanel } = useLeftPanelStore(); const { togglePanel } = useLeftPanelStore();
@@ -97,11 +99,22 @@ export const DocSubPageItem = (props: Props) => {
return ( return (
<Box <Box
className="--docs-sub-page-item" className="--docs-sub-page-item"
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
$css={css` $css={css`
&:not(:has(.isSelected)):has(.light-doc-item-actions) { background-color: ${actionsOpen
? 'var(--c--theme--colors--greyscale-100)'
: 'var(--c--theme--colors--greyscale-000)'};
.light-doc-item-actions {
display: ${actionsOpen || !isDesktop ? 'flex' : 'none'};
}
&:hover {
background-color: var(--c--theme--colors--greyscale-100); background-color: var(--c--theme--colors--greyscale-100);
border-radius: 4px;
.light-doc-item-actions {
display: flex;
}
} }
`} `}
> >
@@ -150,19 +163,19 @@ export const DocSubPageItem = (props: Props) => {
)} )}
</Box> </Box>
{isHover && ( <Box
<Box $direction="row"
$direction="row" $align="center"
$align="center" className="light-doc-item-actions"
className="light-doc-item-actions" >
> <DocTreeItemActions
<DocTreeItemActions doc={doc}
doc={doc} isOpen={actionsOpen}
parentId={node.data.parentKey} onOpenChange={setActionsOpen}
onCreateSuccess={afterCreate} parentId={node.data.parentKey}
/> onCreateSuccess={afterCreate}
</Box> />
)} </Box>
</Box> </Box>
</TreeViewItem> </TreeViewItem>
</Box> </Box>

View File

@@ -24,6 +24,7 @@ type DocTreeProps = {
}; };
export const DocTree = ({ initialTargetId }: DocTreeProps) => { export const DocTree = ({ initialTargetId }: DocTreeProps) => {
const { spacingsTokens } = useCunninghamTheme(); const { spacingsTokens } = useCunninghamTheme();
const [rootActionsOpen, setRootActionsOpen] = useState(false);
const treeContext = useTreeContext<Doc>(); const treeContext = useTreeContext<Doc>();
const { currentDoc } = useDocStore(); const { currentDoc } = useDocStore();
const router = useRouter(); const router = useRouter();
@@ -145,11 +146,12 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => {
`} `}
> >
<Box <Box
data-testid="doc-tree-root-item"
$css={css` $css={css`
padding: ${spacingsTokens['2xs']}; padding: ${spacingsTokens['2xs']};
border-radius: 4px; border-radius: 4px;
width: 100%; width: 100%;
background-color: ${rootIsSelected background-color: ${rootIsSelected || rootActionsOpen
? 'var(--c--theme--colors--greyscale-100)' ? 'var(--c--theme--colors--greyscale-100)'
: 'transparent'}; : 'transparent'};
@@ -159,7 +161,7 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => {
.doc-tree-root-item-actions { .doc-tree-root-item-actions {
display: 'flex'; display: 'flex';
opacity: 0; opacity: ${rootActionsOpen ? '1' : '0'};
&:has(.isOpen) { &:has(.isOpen) {
opacity: 1; opacity: 1;
@@ -201,6 +203,8 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => {
}; };
treeContext?.treeData.addChild(null, newDoc); treeContext?.treeData.addChild(null, newDoc);
}} }}
isOpen={rootActionsOpen}
onOpenChange={setRootActionsOpen}
/> />
</div> </div>
</Box> </Box>

View File

@@ -5,7 +5,7 @@ import {
} from '@gouvfr-lasuite/ui-kit'; } from '@gouvfr-lasuite/ui-kit';
import { useModal } from '@openfun/cunningham-react'; import { useModal } from '@openfun/cunningham-react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { Fragment, useState } from 'react'; import { Fragment } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { css } from 'styled-components'; import { css } from 'styled-components';
@@ -21,14 +21,17 @@ type DocTreeItemActionsProps = {
doc: Doc; doc: Doc;
parentId?: string | null; parentId?: string | null;
onCreateSuccess?: (newDoc: Doc) => void; onCreateSuccess?: (newDoc: Doc) => void;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
}; };
export const DocTreeItemActions = ({ export const DocTreeItemActions = ({
doc, doc,
parentId, parentId,
onCreateSuccess, onCreateSuccess,
isOpen,
onOpenChange,
}: DocTreeItemActionsProps) => { }: DocTreeItemActionsProps) => {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const deleteModal = useModal(); const deleteModal = useModal();
@@ -66,7 +69,7 @@ export const DocTreeItemActions = ({
...(!isCurrentParent ...(!isCurrentParent
? [ ? [
{ {
label: t('Convert to doc'), label: t('Move to my docs'),
isDisabled: !doc.abilities.move, isDisabled: !doc.abilities.move,
icon: ( icon: (
<Box <Box
@@ -92,6 +95,7 @@ export const DocTreeItemActions = ({
const { mutate: createChildrenDoc } = useCreateChildrenDoc({ const { mutate: createChildrenDoc } = useCreateChildrenDoc({
onSuccess: (newDoc) => { onSuccess: (newDoc) => {
onCreateSuccess?.(newDoc); onCreateSuccess?.(newDoc);
void router.push(`/docs/${newDoc.id}`);
}, },
}); });
@@ -118,13 +122,13 @@ export const DocTreeItemActions = ({
<DropdownMenu <DropdownMenu
options={options} options={options}
isOpen={isOpen} isOpen={isOpen}
onOpenChange={setIsOpen} onOpenChange={onOpenChange}
> >
<Icon <Icon
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
setIsOpen(!isOpen); onOpenChange?.(!isOpen);
}} }}
iconName="more_horiz" iconName="more_horiz"
variant="filled" variant="filled"

View File

@@ -1,15 +1,22 @@
import { DndContext, DragOverlay, Modifier } from '@dnd-kit/core'; import { DndContext, DragOverlay, Modifier } from '@dnd-kit/core';
import { getEventCoordinates } from '@dnd-kit/utilities'; import { getEventCoordinates } from '@dnd-kit/utilities';
import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit'; import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
import { useModal } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components'; import { AlertModal, Box, Text } from '@/components';
import { Doc, KEY_LIST_DOC } from '@/docs/doc-management'; import { Doc, KEY_LIST_DOC } from '@/docs/doc-management';
import {
getDocAccesses,
getDocInvitations,
useDeleteDocAccess,
useDeleteDocInvitation,
} from '@/docs/doc-share';
import { useMoveDoc } from '@/docs/doc-tree'; import { useMoveDoc } from '@/docs/doc-tree';
import { useDragAndDrop } from '../hooks/useDragAndDrop'; import { DocDragEndData, useDragAndDrop } from '../hooks/useDragAndDrop';
import { DocsGridItem } from './DocsGridItem'; import { DocsGridItem } from './DocsGridItem';
import { Draggable } from './Draggable'; import { Draggable } from './Draggable';
@@ -45,23 +52,75 @@ type DocGridContentListProps = {
}; };
export const DocGridContentList = ({ docs }: DocGridContentListProps) => { export const DocGridContentList = ({ docs }: DocGridContentListProps) => {
const { mutate: handleMove, isError } = useMoveDoc(); const { mutateAsync: handleMove, isError } = useMoveDoc();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const onDrag = (sourceDocumentId: string, targetDocumentId: string) => const modalConfirmation = useModal();
handleMove( const { mutate: handleDeleteInvitation } = useDeleteDocInvitation();
{ const { mutate: handleDeleteAccess } = useDeleteDocAccess();
const onDragData = useRef<DocDragEndData | null>(null);
const handleMoveDoc = async () => {
if (!onDragData.current) {
return;
}
const { sourceDocumentId, targetDocumentId } = onDragData.current;
modalConfirmation.onClose();
if (!sourceDocumentId || !targetDocumentId) {
onDragData.current = null;
return;
}
try {
await handleMove({
sourceDocumentId, sourceDocumentId,
targetDocumentId, targetDocumentId,
position: TreeViewMoveModeEnum.FIRST_CHILD, position: TreeViewMoveModeEnum.FIRST_CHILD,
}, });
{
onSuccess: () => { void queryClient.invalidateQueries({
void queryClient.invalidateQueries({ queryKey: [KEY_LIST_DOC],
queryKey: [KEY_LIST_DOC], });
}); const accesses = await getDocAccesses({
}, docId: sourceDocumentId,
}, });
);
const invitationsResponse = await getDocInvitations({
docId: sourceDocumentId,
page: 1,
});
const invitations = invitationsResponse.results;
await Promise.all([
...invitations.map((invitation) =>
handleDeleteInvitation({
docId: sourceDocumentId,
invitationId: invitation.id,
}),
),
...accesses.map((access) =>
handleDeleteAccess({
docId: sourceDocumentId,
accessId: access.id,
}),
),
]);
} finally {
onDragData.current = null;
}
};
const onDrag = (data: DocDragEndData) => {
onDragData.current = data;
if (data.source.nb_accesses_direct <= 1) {
void handleMoveDoc();
return;
}
modalConfirmation.open();
};
const { const {
selectedDoc, selectedDoc,
@@ -105,37 +164,62 @@ export const DocGridContentList = ({ docs }: DocGridContentListProps) => {
} }
return ( return (
<DndContext <>
sensors={sensors} <DndContext
modifiers={[snapToTopLeft]} sensors={sensors}
onDragStart={handleDragStart} modifiers={[snapToTopLeft]}
onDragEnd={handleDragEnd} onDragStart={handleDragStart}
> onDragEnd={handleDragEnd}
{docs.map((doc) => ( >
<DraggableDocGridItem {docs.map((doc) => (
key={doc.id} <DraggableDocGridItem
doc={doc} key={doc.id}
dragMode={!!selectedDoc} doc={doc}
canDrag={!!canDrag} dragMode={!!selectedDoc}
updateCanDrop={updateCanDrop} canDrag={!!canDrag}
updateCanDrop={updateCanDrop}
/>
))}
<DragOverlay dropAnimation={null}>
<Box
$width="fit-content"
$padding={{ horizontal: 'xs', vertical: '3xs' }}
$radius="12px"
$background={overlayBgColor}
data-testid="drag-doc-overlay"
$height="auto"
role="alert"
>
<Text $size="xs" $variation="000" $weight="500">
{overlayText}
</Text>
</Box>
</DragOverlay>
</DndContext>
{modalConfirmation.isOpen && (
<AlertModal
{...modalConfirmation}
title={t('Move document')}
description={
<span
dangerouslySetInnerHTML={{
__html: t(
'By moving this document to <strong>{{targetDocumentTitle}}</strong>, it will lose its current access rights and inherit the permissions of that document. <strong>This access change cannot be undone.</strong>',
{
targetDocumentTitle:
onDragData.current?.target.title ?? t('Unnamed document'),
},
),
}}
/>
}
confirmLabel={t('Move')}
onConfirm={() => {
void handleMoveDoc();
}}
/> />
))} )}
<DragOverlay dropAnimation={null}> </>
<Box
$width="fit-content"
$padding={{ horizontal: 'xs', vertical: '3xs' }}
$radius="12px"
$background={overlayBgColor}
data-testid="drag-doc-overlay"
$height="auto"
role="alert"
>
<Text $size="xs" $variation="000" $weight="500">
{overlayText}
</Text>
</Box>
</DragOverlay>
</DndContext>
); );
}; };

View File

@@ -11,13 +11,18 @@ import { useState } from 'react';
import { Doc } from '@/docs/doc-management'; import { Doc } from '@/docs/doc-management';
export type DocDragEndData = {
sourceDocumentId: string;
targetDocumentId: string;
source: Doc;
target: Doc;
};
const activationConstraint = { const activationConstraint = {
distance: 20, distance: 20,
}; };
export function useDragAndDrop( export function useDragAndDrop(onDrag: (data: DocDragEndData) => void) {
onDrag: (sourceDocumentId: string, targetDocumentId: string) => void,
) {
const [selectedDoc, setSelectedDoc] = useState<Doc>(); const [selectedDoc, setSelectedDoc] = useState<Doc>();
const [canDrop, setCanDrop] = useState<boolean>(); const [canDrop, setCanDrop] = useState<boolean>();
@@ -49,7 +54,12 @@ export function useDragAndDrop(
return; return;
} }
onDrag(active.id as string, over.id as string); onDrag({
sourceDocumentId: active.id as string,
targetDocumentId: over.id as string,
source: active.data.current as Doc,
target: over.data.current as Doc,
});
}; };
const updateCanDrop = (docCanDrop: boolean, isOver: boolean) => { const updateCanDrop = (docCanDrop: boolean, isOver: boolean) => {

View File

@@ -1,25 +1,13 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Button } from '@openfun/cunningham-react'; import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Doc, useCreateDoc, useDocStore } from '@/docs/doc-management'; import { Icon } from '@/components';
import { isOwnerOrAdmin, useCreateChildrenDoc } from '@/features/docs/doc-tree'; import { useCreateDoc } from '@/features/docs/doc-management';
import { useLeftPanelStore } from '../stores'; import { useLeftPanelStore } from '../stores';
export const LeftPanelHeaderButton = () => { export const LeftPanelHeaderButton = () => {
const router = useRouter();
const isDoc = router.pathname === '/docs/[id]';
if (isDoc) {
return <LeftPanelHeaderDocButton />;
}
return <LeftPanelHeaderHomeButton />;
};
export const LeftPanelHeaderHomeButton = () => {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const { togglePanel } = useLeftPanelStore(); const { togglePanel } = useLeftPanelStore();
@@ -33,45 +21,10 @@ export const LeftPanelHeaderHomeButton = () => {
<Button <Button
color="primary" color="primary"
onClick={() => createDoc()} onClick={() => createDoc()}
icon={<Icon $variation="000" iconName="add" />}
disabled={isDocCreating} disabled={isDocCreating}
> >
{t('New doc')} {t('New doc')}
</Button> </Button>
); );
}; };
export const LeftPanelHeaderDocButton = () => {
const router = useRouter();
const { currentDoc } = useDocStore();
const { t } = useTranslation();
const { togglePanel } = useLeftPanelStore();
const treeContext = useTreeContext<Doc>();
const tree = treeContext?.treeData;
const { mutate: createChildrenDoc, isPending: isDocCreating } =
useCreateChildrenDoc({
onSuccess: (doc) => {
tree?.addRootNode(doc);
tree?.selectNodeById(doc.id);
void router.push(`/docs/${doc.id}`);
togglePanel();
},
});
const onCreateDoc = () => {
if (treeContext && treeContext.root) {
createChildrenDoc({
parentId: treeContext.root.id,
});
}
};
return (
<Button
color="tertiary"
onClick={onCreateDoc}
disabled={(currentDoc && !isOwnerOrAdmin(currentDoc)) || isDocCreating}
>
{t('New page')}
</Button>
);
};