✨(frontend) enhance document sharing features and role management
- Introduced new hooks and components for improved document sharing functionality, including `useTranslatedShareSettings` and `DocShareModal`. - Added role management capabilities with `DocRoleDropdown` and `DocShareAddMemberList` components, allowing users to manage document access and roles effectively. - Implemented user invitation handling with `DocShareInvitationItem` and `DocShareMemberItem` components, enhancing the user experience for managing document collaborators. - Updated translation handling for role and visibility settings to ensure consistency across the application. - Refactored existing components to integrate new features and improve overall code organization.
This commit is contained in:
committed by
Anthony LC
parent
ceaf1e28f9
commit
eb35fdc7a9
@@ -15,6 +15,7 @@ and this project adheres to
|
||||
- 🔧(helm) add option to disable default tls setting by @dominikkaminski #519
|
||||
- 💄(frontend) Add left panel #420
|
||||
- 💄(frontend) add filtering to left panel #475
|
||||
- ✨(frontend) new share modal ui #489
|
||||
|
||||
## Changed
|
||||
|
||||
@@ -45,9 +46,6 @@ and this project adheres to
|
||||
- ⚡️(e2e) reduce flakiness on e2e tests #511
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Fixed
|
||||
- 🐛(frontend) update doc editor height #481
|
||||
- 💄(frontend) add doc search #485
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
|
||||
import { Button, DialogTrigger, Popover } from 'react-aria-components';
|
||||
import {
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Button, Popover } from 'react-aria-components';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledPopover = styled(Popover)`
|
||||
@@ -8,7 +14,7 @@ const StyledPopover = styled(Popover)`
|
||||
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
|
||||
|
||||
border: 1px solid #dddddd;
|
||||
opacity: 0;
|
||||
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
@@ -21,6 +27,7 @@ const StyledButton = styled(Button)`
|
||||
font-family: Marianne, Arial, serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.938rem;
|
||||
padding: 0;
|
||||
text-wrap: nowrap;
|
||||
`;
|
||||
|
||||
@@ -28,6 +35,7 @@ export interface DropButtonProps {
|
||||
button: ReactNode;
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const DropButton = ({
|
||||
@@ -35,10 +43,12 @@ export const DropButton = ({
|
||||
isOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
label,
|
||||
}: PropsWithChildren<DropButtonProps>) => {
|
||||
const [opacity, setOpacity] = useState(false);
|
||||
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
|
||||
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLocalOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
@@ -46,21 +56,25 @@ export const DropButton = ({
|
||||
const onOpenChangeHandler = (isOpen: boolean) => {
|
||||
setIsLocalOpen(isOpen);
|
||||
onOpenChange?.(isOpen);
|
||||
setTimeout(() => {
|
||||
setOpacity(isOpen);
|
||||
}, 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogTrigger onOpenChange={onOpenChangeHandler} isOpen={isLocalOpen}>
|
||||
<StyledButton>{button}</StyledButton>
|
||||
<>
|
||||
<StyledButton
|
||||
ref={triggerRef}
|
||||
onPress={() => onOpenChangeHandler(true)}
|
||||
aria-label={label}
|
||||
>
|
||||
{button}
|
||||
</StyledButton>
|
||||
|
||||
<StyledPopover
|
||||
style={{ opacity: opacity ? 1 : 0 }}
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isLocalOpen}
|
||||
onOpenChange={onOpenChangeHandler}
|
||||
>
|
||||
{children}
|
||||
</StyledPopover>
|
||||
</DialogTrigger>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,10 +6,32 @@ export const useTrans = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const translatedRoles = {
|
||||
[Role.ADMIN]: t('Administrator'),
|
||||
[Role.READER]: t('Reader'),
|
||||
[Role.OWNER]: t('Owner'),
|
||||
[Role.EDITOR]: t('Editor'),
|
||||
[Role.ADMIN]: t('Administrator'),
|
||||
[Role.OWNER]: t('Owner'),
|
||||
};
|
||||
|
||||
const getNotAllowedMessage = (
|
||||
canUpdate: boolean,
|
||||
isLastOwner: boolean,
|
||||
isOtherOwner: boolean,
|
||||
) => {
|
||||
if (!canUpdate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isLastOwner) {
|
||||
return t(
|
||||
'You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.',
|
||||
);
|
||||
}
|
||||
|
||||
if (isOtherOwner) {
|
||||
return t('You cannot update the role or remove other owner.');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -17,5 +39,7 @@ export const useTrans = () => {
|
||||
return translatedRoles[role];
|
||||
},
|
||||
untitledDocument: t('Untitled document'),
|
||||
translatedRoles,
|
||||
getNotAllowedMessage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { DropdownMenu, DropdownMenuOption, Text } from '@/components';
|
||||
|
||||
import { useTrans } from '../../doc-management/hooks';
|
||||
import { Role } from '../../doc-management/types';
|
||||
|
||||
type Props = {
|
||||
currentRole: Role;
|
||||
onSelectRole?: (role: Role) => void;
|
||||
canUpdate?: boolean;
|
||||
isLastOwner?: boolean;
|
||||
isOtherOwner?: boolean;
|
||||
};
|
||||
export const DocRoleDropdown = ({
|
||||
canUpdate = true,
|
||||
currentRole,
|
||||
onSelectRole,
|
||||
isLastOwner,
|
||||
isOtherOwner,
|
||||
}: Props) => {
|
||||
const { transRole, translatedRoles, getNotAllowedMessage } = useTrans();
|
||||
|
||||
const roles: DropdownMenuOption[] = Object.keys(translatedRoles).map(
|
||||
(key) => {
|
||||
return {
|
||||
label: transRole(key as Role),
|
||||
callback: () => onSelectRole?.(key as Role),
|
||||
disabled: isLastOwner || isOtherOwner,
|
||||
isSelected: currentRole === (key as Role),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
if (!canUpdate) {
|
||||
return (
|
||||
<Text aria-label="doc-role-text" $variation="600">
|
||||
{transRole(currentRole)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
topMessage={getNotAllowedMessage(
|
||||
canUpdate,
|
||||
!!isLastOwner,
|
||||
!!isOtherOwner,
|
||||
)}
|
||||
label="doc-role-dropdown"
|
||||
showArrow={true}
|
||||
options={roles}
|
||||
>
|
||||
<Text
|
||||
$variation="600"
|
||||
$css={css`
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
`}
|
||||
>
|
||||
{transRole(currentRole)}
|
||||
</Text>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
Button,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { APIError } from '@/api';
|
||||
import { Box } from '@/components';
|
||||
import { User } from '@/core';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc, Role } from '@/features/docs';
|
||||
import { useCreateDocInvitation } from '@/features/docs/members/invitation-list';
|
||||
import { useCreateDocAccess } from '@/features/docs/members/members-add';
|
||||
import { OptionType } from '@/features/docs/members/members-add/types';
|
||||
import { useLanguage } from '@/i18n/hooks/useLanguage';
|
||||
|
||||
import { DocRoleDropdown } from './DocRoleDropdown';
|
||||
import { DocShareAddMemberListItem } from './DocShareAddMemberListItem';
|
||||
|
||||
type APIErrorUser = APIError<{
|
||||
value: string;
|
||||
type: OptionType;
|
||||
}>;
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
selectedUsers: User[];
|
||||
onRemoveUser?: (user: User) => void;
|
||||
onSubmit?: (selectedUsers: User[], role: Role) => void;
|
||||
afterInvite?: () => void;
|
||||
};
|
||||
export const DocShareAddMemberList = ({
|
||||
doc,
|
||||
selectedUsers,
|
||||
onRemoveUser,
|
||||
afterInvite,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const { contentLanguage } = useLanguage();
|
||||
const [invitationRole, setInvitationRole] = useState<Role>(Role.EDITOR);
|
||||
const canShare = doc.abilities.accesses_manage;
|
||||
const spacing = spacingsTokens();
|
||||
const color = colorsTokens();
|
||||
|
||||
const { mutateAsync: createInvitation } = useCreateDocInvitation();
|
||||
const { mutateAsync: createDocAccess } = useCreateDocAccess();
|
||||
|
||||
const onError = (dataError: APIErrorUser) => {
|
||||
let messageError =
|
||||
dataError['data']?.type === OptionType.INVITATION
|
||||
? t(`Failed to create the invitation for {{email}}.`, {
|
||||
email: dataError['data']?.value,
|
||||
})
|
||||
: t(`Failed to add the member in the document.`);
|
||||
|
||||
if (
|
||||
dataError.cause?.[0] ===
|
||||
'Document invitation with this Email address and Document already exists.'
|
||||
) {
|
||||
messageError = t('"{{email}}" is already invited to the document.', {
|
||||
email: dataError['data']?.value,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
dataError.cause?.[0] ===
|
||||
'This email is already associated to a registered user.'
|
||||
) {
|
||||
messageError = t('"{{email}}" is already member of the document.', {
|
||||
email: dataError['data']?.value,
|
||||
});
|
||||
}
|
||||
|
||||
toast(messageError, VariantType.ERROR, {
|
||||
duration: 4000,
|
||||
});
|
||||
};
|
||||
|
||||
const onInvite = async () => {
|
||||
setIsLoading(true);
|
||||
const promises = selectedUsers.map((user) => {
|
||||
const isInvitationMode = user.id === user.email;
|
||||
|
||||
const payload = {
|
||||
role: invitationRole,
|
||||
docId: doc.id,
|
||||
contentLanguage,
|
||||
};
|
||||
|
||||
return isInvitationMode
|
||||
? createInvitation({
|
||||
...payload,
|
||||
email: user.email,
|
||||
})
|
||||
: createDocAccess({
|
||||
...payload,
|
||||
memberId: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
const settledPromises = await Promise.allSettled(promises);
|
||||
settledPromises.forEach((settledPromise) => {
|
||||
if (settledPromise.status === 'rejected') {
|
||||
onError(settledPromise.reason as APIErrorUser);
|
||||
}
|
||||
});
|
||||
afterInvite?.();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-testid="doc-share-add-member-list"
|
||||
$direction="row"
|
||||
$padding={spacing.sm}
|
||||
$align="center"
|
||||
$background={color['greyscale-050']}
|
||||
$radius={spacing['3xs']}
|
||||
$css={css`
|
||||
border: 1px solid ${color['greyscale-200']};
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$wrap="wrap"
|
||||
$flex={1}
|
||||
$gap={spacing.xs}
|
||||
>
|
||||
{selectedUsers.map((user) => (
|
||||
<DocShareAddMemberListItem
|
||||
key={user.id}
|
||||
user={user}
|
||||
onRemoveUser={onRemoveUser}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box $direction="row" $align="center" $gap={spacing.xs}>
|
||||
<DocRoleDropdown
|
||||
canUpdate={canShare}
|
||||
currentRole={invitationRole}
|
||||
onSelectRole={setInvitationRole}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void onInvite()}
|
||||
size="small"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('Invite')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { User } from '@/core';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
onRemoveUser?: (user: User) => void;
|
||||
};
|
||||
export const DocShareAddMemberListItem = ({ user, onRemoveUser }: Props) => {
|
||||
const { spacingsTokens, colorsTokens, fontSizesTokens } =
|
||||
useCunninghamTheme();
|
||||
const spacing = spacingsTokens();
|
||||
const color = colorsTokens();
|
||||
const fontSize = fontSizesTokens();
|
||||
return (
|
||||
<Box
|
||||
data-testid={`doc-share-add-member-${user.email}`}
|
||||
$radius={spacing['3xs']}
|
||||
$direction="row"
|
||||
$height="fit-content"
|
||||
$justify="center"
|
||||
$align="center"
|
||||
$gap={spacing.xs}
|
||||
$background={color['greyscale-250']}
|
||||
$padding={{ horizontal: spacing['2xs'], vertical: spacing['3xs'] }}
|
||||
$css={css`
|
||||
color: ${color['greyscale-1000']};
|
||||
font-size: ${fontSize['xs']};
|
||||
`}
|
||||
>
|
||||
<Text $margin={{ top: '-3px' }}>{user.full_name || user.email}</Text>
|
||||
<Button
|
||||
color="primary-text"
|
||||
size="nano"
|
||||
onClick={() => onRemoveUser?.(user)}
|
||||
icon={<Icon $variation="500" $size="sm" iconName="close" />}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Box,
|
||||
DropdownMenu,
|
||||
DropdownMenuOption,
|
||||
IconOptions,
|
||||
} from '@/components';
|
||||
import { User } from '@/core';
|
||||
import { Doc, Role } from '@/features/docs/doc-management';
|
||||
import {
|
||||
useDeleteDocInvitation,
|
||||
useUpdateDocInvitation,
|
||||
} from '@/features/docs/members/invitation-list';
|
||||
import { Invitation } from '@/features/docs/members/invitation-list/types';
|
||||
import { SearchUserRow } from '@/features/users/components/SearchUserRow';
|
||||
|
||||
import { DocRoleDropdown } from './DocRoleDropdown';
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
invitation: Invitation;
|
||||
};
|
||||
export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const fakeUser: User = {
|
||||
id: invitation.email,
|
||||
full_name: invitation.email,
|
||||
email: invitation.email,
|
||||
short_name: invitation.email,
|
||||
};
|
||||
|
||||
const { toast } = useToastProvider();
|
||||
const canUpdate = doc.abilities.accesses_manage;
|
||||
|
||||
const { mutate: updateDocInvitation } = useUpdateDocInvitation({
|
||||
onError: (error) => {
|
||||
toast(
|
||||
error?.data?.role?.[0] ?? t('Error during update invitation'),
|
||||
VariantType.ERROR,
|
||||
{
|
||||
duration: 4000,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: removeDocInvitation } = useDeleteDocInvitation({
|
||||
onError: (error) => {
|
||||
toast(
|
||||
error?.data?.role?.[0] ?? t('Error during delete invitation'),
|
||||
VariantType.ERROR,
|
||||
{
|
||||
duration: 4000,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const onUpdate = (newRole: Role) => {
|
||||
updateDocInvitation({
|
||||
docId: doc.id,
|
||||
role: newRole,
|
||||
invitationId: invitation.id,
|
||||
});
|
||||
};
|
||||
|
||||
const onRemove = () => {
|
||||
removeDocInvitation({ invitationId: invitation.id, docId: doc.id });
|
||||
};
|
||||
|
||||
const moreActions: DropdownMenuOption[] = [
|
||||
{
|
||||
label: t('Delete'),
|
||||
icon: 'delete',
|
||||
callback: onRemove,
|
||||
disabled: !canUpdate,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Box
|
||||
$width="100%"
|
||||
data-testid={`doc-share-invitation-row-${invitation.email}`}
|
||||
>
|
||||
<SearchUserRow
|
||||
alwaysShowRight={true}
|
||||
user={fakeUser}
|
||||
right={
|
||||
<Box $direction="row" $align="center">
|
||||
<DocRoleDropdown
|
||||
currentRole={invitation.role}
|
||||
onSelectRole={onUpdate}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
|
||||
{canUpdate && (
|
||||
<DropdownMenu
|
||||
data-testid="doc-share-invitation-more-actions"
|
||||
options={moreActions}
|
||||
>
|
||||
<IconOptions $variation="600" />
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Box,
|
||||
DropdownMenu,
|
||||
DropdownMenuOption,
|
||||
IconOptions,
|
||||
} from '@/components';
|
||||
import {
|
||||
useDeleteDocAccess,
|
||||
useUpdateDocAccess,
|
||||
} from '@/features/docs/members/members-list';
|
||||
import { useWhoAmI } from '@/features/docs/members/members-list/hooks/useWhoAmI';
|
||||
import { SearchUserRow } from '@/features/users/components/SearchUserRow';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { Access, Doc, Role } from '../../doc-management/types';
|
||||
|
||||
import { DocRoleDropdown } from './DocRoleDropdown';
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
access: Access;
|
||||
};
|
||||
export const DocShareMemberItem = ({ doc, access }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { isLastOwner, isOtherOwner } = useWhoAmI(access);
|
||||
const { toast } = useToastProvider();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const isNotAllowed =
|
||||
isOtherOwner || !!isLastOwner || !doc.abilities.accesses_manage;
|
||||
|
||||
const { mutate: updateDocAccess } = useUpdateDocAccess({
|
||||
onError: () => {
|
||||
toast(t('Error during invitation update'), VariantType.ERROR, {
|
||||
duration: 4000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: removeDocAccess } = useDeleteDocAccess({
|
||||
onError: () => {
|
||||
toast(t('Error while deleting invitation'), VariantType.ERROR, {
|
||||
duration: 4000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onUpdate = (newRole: Role) => {
|
||||
updateDocAccess({
|
||||
docId: doc.id,
|
||||
role: newRole,
|
||||
accessId: access.id,
|
||||
});
|
||||
};
|
||||
|
||||
const onRemove = () => {
|
||||
removeDocAccess({ accessId: access.id, docId: doc.id });
|
||||
};
|
||||
|
||||
const moreActions: DropdownMenuOption[] = [
|
||||
{
|
||||
label: t('Delete'),
|
||||
icon: 'delete',
|
||||
callback: onRemove,
|
||||
disabled: isNotAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
$width="100%"
|
||||
data-testid={`doc-share-member-row-${access.user.email}`}
|
||||
>
|
||||
<SearchUserRow
|
||||
alwaysShowRight={true}
|
||||
user={access.user}
|
||||
right={
|
||||
<Box $direction="row" $align="center">
|
||||
<DocRoleDropdown
|
||||
currentRole={access.role}
|
||||
onSelectRole={onUpdate}
|
||||
canUpdate={doc.abilities.accesses_manage}
|
||||
isLastOwner={isLastOwner}
|
||||
isOtherOwner={!!isOtherOwner}
|
||||
/>
|
||||
|
||||
{isDesktop && doc.abilities.accesses_manage && (
|
||||
<DropdownMenu options={moreActions}>
|
||||
<IconOptions
|
||||
data-testid="doc-share-member-more-actions"
|
||||
$variation="600"
|
||||
/>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { LoadMoreText } from '@/components/LoadMoreText';
|
||||
import {
|
||||
QuickSearch,
|
||||
QuickSearchData,
|
||||
} from '@/components/quick-search/QuickSearch';
|
||||
import { QuickSearchGroup } from '@/components/quick-search/QuickSearchGroup';
|
||||
import { User } from '@/core';
|
||||
import { Access, Doc } from '@/features/docs';
|
||||
import { useDocInvitationsInfinite } from '@/features/docs/members/invitation-list';
|
||||
import { Invitation } from '@/features/docs/members/invitation-list/types';
|
||||
import { KEY_LIST_USER, useUsers } from '@/features/docs/members/members-add';
|
||||
import { useDocAccessesInfinite } from '@/features/docs/members/members-list';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
import { isValidEmail } from '@/utils';
|
||||
|
||||
import { DocShareAddMemberList } from './DocShareAddMemberList';
|
||||
import { DocShareInvitationItem } from './DocShareInvitationItem';
|
||||
import { DocShareMemberItem } from './DocShareMemberItem';
|
||||
import { DocShareModalFooter } from './DocShareModalFooter';
|
||||
import { DocShareModalInviteUserRow } from './DocShareModalInviteUserByEmail';
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
onClose: () => void;
|
||||
};
|
||||
export const DocShareModal = ({ doc, onClose }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
|
||||
const [userQuery, setUserQuery] = useState('');
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const canShare = doc.abilities.accesses_manage;
|
||||
const showMemberSection = inputValue === '' && selectedUsers.length === 0;
|
||||
|
||||
const onSelect = (user: User) => {
|
||||
setSelectedUsers((prev) => [...prev, user]);
|
||||
setUserQuery('');
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
const membersQuery = useDocAccessesInfinite({
|
||||
docId: doc.id,
|
||||
});
|
||||
|
||||
const invitationQuery = useDocInvitationsInfinite({
|
||||
docId: doc.id,
|
||||
});
|
||||
|
||||
const searchUsersQuery = useUsers(
|
||||
{ query: userQuery, docId: doc.id },
|
||||
{
|
||||
enabled: !!userQuery,
|
||||
queryKey: [KEY_LIST_USER, { query: userQuery }],
|
||||
},
|
||||
);
|
||||
|
||||
const membersData: QuickSearchData<Access> = useMemo(() => {
|
||||
const members =
|
||||
membersQuery.data?.pages.flatMap((page) => page.results) || [];
|
||||
|
||||
const count = membersQuery.data?.pages[0]?.count ?? 1;
|
||||
|
||||
return {
|
||||
groupName: t('Share with {{count}} users', {
|
||||
count: count,
|
||||
}),
|
||||
elements: members,
|
||||
endActions: membersQuery.hasNextPage
|
||||
? [
|
||||
{
|
||||
content: <LoadMoreText data-testid="load-more-members" />,
|
||||
onSelect: () => void membersQuery.fetchNextPage(),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
}, [membersQuery, t]);
|
||||
|
||||
const invitationsData: QuickSearchData<Invitation> = useMemo(() => {
|
||||
const invitations =
|
||||
invitationQuery.data?.pages.flatMap((page) => page.results) || [];
|
||||
|
||||
return {
|
||||
groupName: t('Pending invitations'),
|
||||
elements: invitations,
|
||||
endActions: invitationQuery.hasNextPage
|
||||
? [
|
||||
{
|
||||
content: <LoadMoreText data-testid="load-more-invitations" />,
|
||||
onSelect: () => void invitationQuery.fetchNextPage(),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
}, [invitationQuery, t]);
|
||||
|
||||
const searchUserData: QuickSearchData<User> = useMemo(() => {
|
||||
const users = searchUsersQuery.data?.results || [];
|
||||
const isEmail = isValidEmail(userQuery);
|
||||
const newUser: User = {
|
||||
id: userQuery,
|
||||
full_name: '',
|
||||
email: userQuery,
|
||||
short_name: '',
|
||||
};
|
||||
|
||||
return {
|
||||
groupName: t('Search user result', { count: users.length }),
|
||||
elements: users,
|
||||
endActions:
|
||||
isEmail && users.length === 0
|
||||
? [
|
||||
{
|
||||
content: <DocShareModalInviteUserRow user={newUser} />,
|
||||
onSelect: () => void onSelect(newUser),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
}, [searchUsersQuery.data, t, userQuery]);
|
||||
|
||||
const onFilter = useDebouncedCallback((str: string) => {
|
||||
setUserQuery(str);
|
||||
}, 300);
|
||||
|
||||
const onRemoveUser = (row: User) => {
|
||||
setSelectedUsers((prevState) => {
|
||||
const index = prevState.findIndex((value) => value.id === row.id);
|
||||
if (index < 0) {
|
||||
return prevState;
|
||||
}
|
||||
const newArray = [...prevState];
|
||||
newArray.splice(index, 1);
|
||||
return newArray;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
data-testid="doc-share-modal"
|
||||
aria-label={t('Share modal')}
|
||||
size={isDesktop ? ModalSize.LARGE : ModalSize.FULL}
|
||||
onClose={onClose}
|
||||
title={<Box $align="flex-start">{t('Share the document')}</Box>}
|
||||
>
|
||||
<Box
|
||||
aria-label={t('Share modal')}
|
||||
$direction="column"
|
||||
$justify="space-between"
|
||||
>
|
||||
<Box
|
||||
$flex={1}
|
||||
className="toto"
|
||||
$css={css`
|
||||
overflow-y: auto;
|
||||
[cmdk-list] {
|
||||
overflow-y: auto;
|
||||
height: ${isDesktop
|
||||
? '400px'
|
||||
: 'calc(100vh - 49px - 68px - 237px)'};
|
||||
}
|
||||
`}
|
||||
>
|
||||
{canShare && selectedUsers.length > 0 && (
|
||||
<Box
|
||||
$padding={{ horizontal: 'base' }}
|
||||
$margin={{ vertical: '11px' }}
|
||||
>
|
||||
<DocShareAddMemberList
|
||||
doc={doc}
|
||||
selectedUsers={selectedUsers}
|
||||
onRemoveUser={onRemoveUser}
|
||||
afterInvite={() => {
|
||||
setUserQuery('');
|
||||
setInputValue('');
|
||||
setSelectedUsers([]);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box data-testid="doc-share-quick-search">
|
||||
<QuickSearch
|
||||
onFilter={(str) => {
|
||||
setInputValue(str);
|
||||
onFilter(str);
|
||||
}}
|
||||
inputValue={inputValue}
|
||||
showInput={canShare}
|
||||
loading={searchUsersQuery.isLoading}
|
||||
placeholder={t('Type a name or email')}
|
||||
>
|
||||
{!showMemberSection && inputValue !== '' && (
|
||||
<QuickSearchGroup
|
||||
group={searchUserData}
|
||||
onSelect={onSelect}
|
||||
renderElement={(user) => (
|
||||
<DocShareModalInviteUserRow user={user} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showMemberSection && (
|
||||
<>
|
||||
{invitationsData.elements.length > 0 && (
|
||||
<Box aria-label={t('List invitation card')}>
|
||||
<QuickSearchGroup
|
||||
group={invitationsData}
|
||||
renderElement={(invitation) => (
|
||||
<DocShareInvitationItem
|
||||
doc={doc}
|
||||
invitation={invitation}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box aria-label={t('List members card')}>
|
||||
<QuickSearchGroup
|
||||
group={membersData}
|
||||
renderElement={(access) => (
|
||||
<DocShareMemberItem doc={doc} access={access} />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</QuickSearch>
|
||||
</Box>
|
||||
</Box>
|
||||
{selectedUsers.length === 0 && !inputValue && (
|
||||
<DocShareModalFooter doc={doc} onClose={onClose} />
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Button,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { HorizontalSeparator } from '@/components/separators/HorizontalSeparator';
|
||||
import { Doc } from '@/features/docs';
|
||||
|
||||
import { DocVisibility } from './DocVisibility';
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const DocShareModalFooter = ({ doc, onClose }: Props) => {
|
||||
const canShare = doc.abilities.accesses_manage;
|
||||
const { toast } = useToastProvider();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box
|
||||
$css={css`
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
>
|
||||
<HorizontalSeparator />
|
||||
{canShare && (
|
||||
<>
|
||||
<DocVisibility doc={doc} />
|
||||
<HorizontalSeparator />
|
||||
</>
|
||||
)}
|
||||
<Box $direction="row" $justify="space-between" $padding="base">
|
||||
<Button
|
||||
fullWidth={false}
|
||||
onClick={() => {
|
||||
navigator.clipboard
|
||||
.writeText(window.location.href)
|
||||
.then(() => {
|
||||
toast(t('Link Copied !'), VariantType.SUCCESS, {
|
||||
duration: 3000,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast(t('Failed to copy link'), VariantType.ERROR, {
|
||||
duration: 3000,
|
||||
});
|
||||
});
|
||||
}}
|
||||
color="tertiary"
|
||||
icon={<span className="material-icons">add_link</span>}
|
||||
>
|
||||
{t('Copy link')}
|
||||
</Button>
|
||||
<Button onClick={onClose} color="primary">
|
||||
{t('Ok')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { User } from '@/core';
|
||||
import { SearchUserRow } from '@/features/users/components/SearchUserRow';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
};
|
||||
export const DocShareModalInviteUserRow = ({ user }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box $width="100%" data-testid={`search-user-row-${user.email}`}>
|
||||
<SearchUserRow
|
||||
user={user}
|
||||
right={
|
||||
<Box
|
||||
className="right-hover"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$css={css`
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: var(--c--theme--font--sizes--sm);
|
||||
color: var(--c--theme--colors--greyscale-400);
|
||||
`}
|
||||
>
|
||||
<Text $theme="primary" $variation="600">
|
||||
{t('Add')}
|
||||
</Text>
|
||||
<Icon $theme="primary" $variation="600" iconName="add" />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import {
|
||||
Box,
|
||||
DropdownMenu,
|
||||
DropdownMenuOption,
|
||||
Icon,
|
||||
Text,
|
||||
} from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import {
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
useUpdateDocLink,
|
||||
} from '../../doc-management/api';
|
||||
import { useTranslatedShareSettings } from '../hooks/useTranslatedShareSettings';
|
||||
import { Doc, LinkReach, LinkRole } from '../../doc-management/types';
|
||||
|
||||
interface DocVisibilityProps {
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
export const DocVisibility = ({ doc }: DocVisibilityProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const spacing = spacingsTokens();
|
||||
const colors = colorsTokens();
|
||||
const [linkReach, setLinkReach] = useState<LinkReach>(doc.link_reach);
|
||||
const [docLinkRole, setDocLinkRole] = useState<LinkRole>(doc.link_role);
|
||||
const { linkModeTranslations, linkReachChoices, linkReachTranslations } =
|
||||
useTranslatedShareSettings();
|
||||
|
||||
const api = useUpdateDocLink({
|
||||
onSuccess: () => {
|
||||
toast(
|
||||
t('The document visibility has been updated.'),
|
||||
VariantType.SUCCESS,
|
||||
{
|
||||
duration: 4000,
|
||||
},
|
||||
);
|
||||
},
|
||||
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
|
||||
});
|
||||
|
||||
const updateReach = (link_reach: LinkReach) => {
|
||||
api.mutate({ id: doc.id, link_reach });
|
||||
setLinkReach(link_reach);
|
||||
};
|
||||
|
||||
const updateLinkRole = (link_role: LinkRole) => {
|
||||
api.mutate({ id: doc.id, link_role });
|
||||
setDocLinkRole(link_role);
|
||||
};
|
||||
|
||||
const linkReachOptions: DropdownMenuOption[] = Object.keys(
|
||||
linkReachTranslations,
|
||||
).map((key) => ({
|
||||
label: linkReachTranslations[key as LinkReach],
|
||||
icon: linkReachChoices[key as LinkReach].icon,
|
||||
callback: () => updateReach(key as LinkReach),
|
||||
isSelected: linkReach === (key as LinkReach),
|
||||
}));
|
||||
|
||||
const linkMode: DropdownMenuOption[] = Object.keys(linkModeTranslations).map(
|
||||
(key) => ({
|
||||
label: linkModeTranslations[key as LinkRole],
|
||||
callback: () => updateLinkRole(key as LinkRole),
|
||||
isSelected: docLinkRole === (key as LinkRole),
|
||||
}),
|
||||
);
|
||||
|
||||
const showLinkRoleOptions = doc.link_reach !== LinkReach.RESTRICTED;
|
||||
const description =
|
||||
docLinkRole === LinkRole.READER
|
||||
? linkReachChoices[linkReach].descriptionReadOnly
|
||||
: linkReachChoices[linkReach].descriptionEdit;
|
||||
|
||||
return (
|
||||
<Box
|
||||
$padding={{ horizontal: isDesktop ? 'base' : 'sm' }}
|
||||
aria-label={t('Doc visibility card')}
|
||||
$gap={spacing['base']}
|
||||
>
|
||||
<Text $weight="700" $variation="1000">
|
||||
{t('Link parameters')}
|
||||
</Text>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$justify="space-between"
|
||||
$gap={spacing['xs']}
|
||||
$width="100%"
|
||||
$wrap="nowrap"
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align={isDesktop ? 'center' : undefined}
|
||||
$gap={spacing['3xs']}
|
||||
>
|
||||
<DropdownMenu
|
||||
label={t('Visibility')}
|
||||
arrowCss={css`
|
||||
color: ${colors['primary-800']} !important;
|
||||
`}
|
||||
showArrow={true}
|
||||
options={linkReachOptions}
|
||||
>
|
||||
<Box $direction="row" $align="center" $gap={spacing['3xs']}>
|
||||
<Icon
|
||||
$theme="primary"
|
||||
$variation="800"
|
||||
iconName={linkReachChoices[linkReach].icon}
|
||||
/>
|
||||
<Text $theme="primary" $variation="800">
|
||||
{linkReachChoices[linkReach].label}
|
||||
</Text>
|
||||
</Box>
|
||||
</DropdownMenu>
|
||||
{isDesktop && (
|
||||
<Text $size="xs" $variation="600">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{showLinkRoleOptions && (
|
||||
<Box $direction="row" $align="center" $gap={spacing['3xs']}>
|
||||
{linkReach !== LinkReach.RESTRICTED && (
|
||||
<DropdownMenu
|
||||
showArrow={true}
|
||||
options={linkMode}
|
||||
label={t('Visibility mode')}
|
||||
>
|
||||
<Text $weight="initial" $variation="600">
|
||||
{linkModeTranslations[docLinkRole]}
|
||||
</Text>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{!isDesktop && (
|
||||
<Text $size="xs" $variation="600">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { LinkReach, LinkRole } from '@/features/docs/doc-management/types';
|
||||
|
||||
export const useTranslatedShareSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const linkReachTranslations = {
|
||||
[LinkReach.RESTRICTED]: t('Private'),
|
||||
[LinkReach.AUTHENTICATED]: t('Connected'),
|
||||
[LinkReach.PUBLIC]: t('Public'),
|
||||
};
|
||||
|
||||
const linkModeTranslations = {
|
||||
[LinkRole.READER]: t('Reading'),
|
||||
[LinkRole.EDITOR]: t('Edition'),
|
||||
};
|
||||
|
||||
const linkReachChoices = {
|
||||
[LinkReach.RESTRICTED]: {
|
||||
label: linkReachTranslations[LinkReach.RESTRICTED],
|
||||
icon: 'lock',
|
||||
value: LinkReach.RESTRICTED,
|
||||
descriptionReadOnly: t('Only invited people can access'),
|
||||
descriptionEdit: t('Only invited people can access'),
|
||||
},
|
||||
[LinkReach.AUTHENTICATED]: {
|
||||
label: linkReachTranslations[LinkReach.AUTHENTICATED],
|
||||
icon: 'corporate_fare',
|
||||
value: LinkReach.AUTHENTICATED,
|
||||
descriptionReadOnly: t(
|
||||
'Anyone with the link can see the document provided they are logged in',
|
||||
),
|
||||
descriptionEdit: t(
|
||||
'Anyone with the link can edit provided they are logged in',
|
||||
),
|
||||
},
|
||||
[LinkReach.PUBLIC]: {
|
||||
label: linkReachTranslations[LinkReach.PUBLIC],
|
||||
icon: 'public',
|
||||
value: LinkReach.PUBLIC,
|
||||
descriptionReadOnly: t('Anyone with the link can see the document'),
|
||||
descriptionEdit: t('Anyone with the link can edit the document'),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
linkReachTranslations,
|
||||
linkModeTranslations,
|
||||
linkReachChoices,
|
||||
};
|
||||
};
|
||||
@@ -13,6 +13,10 @@ interface DeleteDocInvitationProps {
|
||||
invitationId: string;
|
||||
}
|
||||
|
||||
type RemoveDocInvitationError = {
|
||||
role?: string[];
|
||||
};
|
||||
|
||||
export const deleteDocInvitation = async ({
|
||||
docId,
|
||||
invitationId,
|
||||
@@ -34,7 +38,7 @@ export const deleteDocInvitation = async ({
|
||||
|
||||
type UseDeleteDocInvitationOptions = UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
APIError<RemoveDocInvitationError>,
|
||||
DeleteDocInvitationProps
|
||||
>;
|
||||
|
||||
@@ -42,7 +46,11 @@ export const useDeleteDocInvitation = (
|
||||
options?: UseDeleteDocInvitationOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, APIError, DeleteDocInvitationProps>({
|
||||
return useMutation<
|
||||
void,
|
||||
APIError<RemoveDocInvitationError>,
|
||||
DeleteDocInvitationProps
|
||||
>({
|
||||
mutationFn: deleteDocInvitation,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
|
||||
@@ -17,6 +17,10 @@ interface UpdateDocInvitationProps {
|
||||
role: Role;
|
||||
}
|
||||
|
||||
type UpdateDocInvitationError = {
|
||||
role?: string[];
|
||||
};
|
||||
|
||||
export const updateDocInvitation = async ({
|
||||
docId,
|
||||
invitationId,
|
||||
@@ -43,7 +47,7 @@ type UseUpdateDocInvitation = Partial<Invitation>;
|
||||
|
||||
type UseUpdateDocInvitationOptions = UseMutationOptions<
|
||||
Invitation,
|
||||
APIError,
|
||||
APIError<UpdateDocInvitationError>,
|
||||
UseUpdateDocInvitation
|
||||
>;
|
||||
|
||||
@@ -51,7 +55,11 @@ export const useUpdateDocInvitation = (
|
||||
options?: UseUpdateDocInvitationOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Invitation, APIError, UpdateDocInvitationProps>({
|
||||
return useMutation<
|
||||
Invitation,
|
||||
APIError<UpdateDocInvitationError>,
|
||||
UpdateDocInvitationProps
|
||||
>({
|
||||
mutationFn: updateDocInvitation,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
|
||||
9
src/frontend/apps/impress/src/utils/children.ts
Normal file
9
src/frontend/apps/impress/src/utils/children.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Children, ReactNode } from 'react';
|
||||
|
||||
export const hasChildrens = (element: ReactNode): boolean => {
|
||||
let hasChildren = false;
|
||||
Children.forEach(element, (child: ReactNode) => {
|
||||
hasChildren = hasChildren || !!child;
|
||||
});
|
||||
return hasChildren;
|
||||
};
|
||||
Reference in New Issue
Block a user