(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:
Nathan Panchout
2024-12-16 10:19:53 +01:00
committed by Anthony LC
parent ceaf1e28f9
commit eb35fdc7a9
16 changed files with 1118 additions and 20 deletions

View File

@@ -15,6 +15,7 @@ and this project adheres to
- 🔧(helm) add option to disable default tls setting by @dominikkaminski #519 - 🔧(helm) add option to disable default tls setting by @dominikkaminski #519
- 💄(frontend) Add left panel #420 - 💄(frontend) Add left panel #420
- 💄(frontend) add filtering to left panel #475 - 💄(frontend) add filtering to left panel #475
- ✨(frontend) new share modal ui #489
## Changed ## Changed
@@ -45,9 +46,6 @@ and this project adheres to
- ⚡️(e2e) reduce flakiness on e2e tests #511 - ⚡️(e2e) reduce flakiness on e2e tests #511
## Fixed ## Fixed
- 🐛(frontend) update doc editor height #481 - 🐛(frontend) update doc editor height #481
- 💄(frontend) add doc search #485 - 💄(frontend) add doc search #485

View File

@@ -1,5 +1,11 @@
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react'; import {
import { Button, DialogTrigger, Popover } from 'react-aria-components'; PropsWithChildren,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import { Button, Popover } from 'react-aria-components';
import styled from 'styled-components'; import styled from 'styled-components';
const StyledPopover = styled(Popover)` const StyledPopover = styled(Popover)`
@@ -8,7 +14,7 @@ const StyledPopover = styled(Popover)`
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1); box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
border: 1px solid #dddddd; border: 1px solid #dddddd;
opacity: 0;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
`; `;
@@ -21,6 +27,7 @@ const StyledButton = styled(Button)`
font-family: Marianne, Arial, serif; font-family: Marianne, Arial, serif;
font-weight: 500; font-weight: 500;
font-size: 0.938rem; font-size: 0.938rem;
padding: 0;
text-wrap: nowrap; text-wrap: nowrap;
`; `;
@@ -28,6 +35,7 @@ export interface DropButtonProps {
button: ReactNode; button: ReactNode;
isOpen?: boolean; isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void; onOpenChange?: (isOpen: boolean) => void;
label?: string;
} }
export const DropButton = ({ export const DropButton = ({
@@ -35,10 +43,12 @@ export const DropButton = ({
isOpen = false, isOpen = false,
onOpenChange, onOpenChange,
children, children,
label,
}: PropsWithChildren<DropButtonProps>) => { }: PropsWithChildren<DropButtonProps>) => {
const [opacity, setOpacity] = useState(false);
const [isLocalOpen, setIsLocalOpen] = useState(isOpen); const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
const triggerRef = useRef(null);
useEffect(() => { useEffect(() => {
setIsLocalOpen(isOpen); setIsLocalOpen(isOpen);
}, [isOpen]); }, [isOpen]);
@@ -46,21 +56,25 @@ export const DropButton = ({
const onOpenChangeHandler = (isOpen: boolean) => { const onOpenChangeHandler = (isOpen: boolean) => {
setIsLocalOpen(isOpen); setIsLocalOpen(isOpen);
onOpenChange?.(isOpen); onOpenChange?.(isOpen);
setTimeout(() => {
setOpacity(isOpen);
}, 10);
}; };
return ( return (
<DialogTrigger onOpenChange={onOpenChangeHandler} isOpen={isLocalOpen}> <>
<StyledButton>{button}</StyledButton> <StyledButton
ref={triggerRef}
onPress={() => onOpenChangeHandler(true)}
aria-label={label}
>
{button}
</StyledButton>
<StyledPopover <StyledPopover
style={{ opacity: opacity ? 1 : 0 }} triggerRef={triggerRef}
isOpen={isLocalOpen} isOpen={isLocalOpen}
onOpenChange={onOpenChangeHandler} onOpenChange={onOpenChangeHandler}
> >
{children} {children}
</StyledPopover> </StyledPopover>
</DialogTrigger> </>
); );
}; };

View File

@@ -6,10 +6,32 @@ export const useTrans = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const translatedRoles = { const translatedRoles = {
[Role.ADMIN]: t('Administrator'),
[Role.READER]: t('Reader'), [Role.READER]: t('Reader'),
[Role.OWNER]: t('Owner'),
[Role.EDITOR]: t('Editor'), [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 { return {
@@ -17,5 +39,7 @@ export const useTrans = () => {
return translatedRoles[role]; return translatedRoles[role];
}, },
untitledDocument: t('Untitled document'), untitledDocument: t('Untitled document'),
translatedRoles,
getNotAllowedMessage,
}; };
}; };

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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,
};
};

View File

@@ -13,6 +13,10 @@ interface DeleteDocInvitationProps {
invitationId: string; invitationId: string;
} }
type RemoveDocInvitationError = {
role?: string[];
};
export const deleteDocInvitation = async ({ export const deleteDocInvitation = async ({
docId, docId,
invitationId, invitationId,
@@ -34,7 +38,7 @@ export const deleteDocInvitation = async ({
type UseDeleteDocInvitationOptions = UseMutationOptions< type UseDeleteDocInvitationOptions = UseMutationOptions<
void, void,
APIError, APIError<RemoveDocInvitationError>,
DeleteDocInvitationProps DeleteDocInvitationProps
>; >;
@@ -42,7 +46,11 @@ export const useDeleteDocInvitation = (
options?: UseDeleteDocInvitationOptions, options?: UseDeleteDocInvitationOptions,
) => { ) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<void, APIError, DeleteDocInvitationProps>({ return useMutation<
void,
APIError<RemoveDocInvitationError>,
DeleteDocInvitationProps
>({
mutationFn: deleteDocInvitation, mutationFn: deleteDocInvitation,
...options, ...options,
onSuccess: (data, variables, context) => { onSuccess: (data, variables, context) => {

View File

@@ -17,6 +17,10 @@ interface UpdateDocInvitationProps {
role: Role; role: Role;
} }
type UpdateDocInvitationError = {
role?: string[];
};
export const updateDocInvitation = async ({ export const updateDocInvitation = async ({
docId, docId,
invitationId, invitationId,
@@ -43,7 +47,7 @@ type UseUpdateDocInvitation = Partial<Invitation>;
type UseUpdateDocInvitationOptions = UseMutationOptions< type UseUpdateDocInvitationOptions = UseMutationOptions<
Invitation, Invitation,
APIError, APIError<UpdateDocInvitationError>,
UseUpdateDocInvitation UseUpdateDocInvitation
>; >;
@@ -51,7 +55,11 @@ export const useUpdateDocInvitation = (
options?: UseUpdateDocInvitationOptions, options?: UseUpdateDocInvitationOptions,
) => { ) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<Invitation, APIError, UpdateDocInvitationProps>({ return useMutation<
Invitation,
APIError<UpdateDocInvitationError>,
UpdateDocInvitationProps
>({
mutationFn: updateDocInvitation, mutationFn: updateDocInvitation,
...options, ...options,
onSuccess: (data, variables, context) => { onSuccess: (data, variables, context) => {

View 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;
};