✨(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:
committed by
Anthony LC
parent
1c5270e301
commit
44909faa67
68
src/frontend/apps/impress/src/components/AlertModal.tsx
Normal file
68
src/frontend/apps/impress/src/components/AlertModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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[]>({
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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;
|
||||||
`}
|
`}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user