✨(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
|
- 🔧(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
|
||||||
|
|||||||
@@ -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>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
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