diff --git a/CHANGELOG.md b/CHANGELOG.md index 8811023a..d46e346f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/apps/impress/src/components/DropButton.tsx b/src/frontend/apps/impress/src/components/DropButton.tsx index 90fee617..9326184a 100644 --- a/src/frontend/apps/impress/src/components/DropButton.tsx +++ b/src/frontend/apps/impress/src/components/DropButton.tsx @@ -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) => { - 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 ( - - {button} + <> + onOpenChangeHandler(true)} + aria-label={label} + > + {button} + + {children} - + ); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx index b7bc24f5..eb9a7016 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx @@ -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, }; }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/component/DocRoleDropdown.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/component/DocRoleDropdown.tsx new file mode 100644 index 00000000..352b2a3e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/component/DocRoleDropdown.tsx @@ -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 ( + + {transRole(currentRole)} + + ); + } + + return ( + + + {transRole(currentRole)} + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/component/DocShareAddMemberList.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/component/DocShareAddMemberList.tsx new file mode 100644 index 00000000..6b8d5a1f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/component/DocShareAddMemberList.tsx @@ -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.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 ( + + + {selectedUsers.map((user) => ( + + ))} + + + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/component/DocShareAddMemberListItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/component/DocShareAddMemberListItem.tsx new file mode 100644 index 00000000..361db237 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/component/DocShareAddMemberListItem.tsx @@ -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 ( + + {user.full_name || user.email} + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/component/DocShareModalInviteUserByEmail.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/component/DocShareModalInviteUserByEmail.tsx new file mode 100644 index 00000000..8ea54cc6 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/component/DocShareModalInviteUserByEmail.tsx @@ -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 ( + + + + {t('Add')} + + + + } + /> + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/component/DocVisibility.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/component/DocVisibility.tsx new file mode 100644 index 00000000..0421c475 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/component/DocVisibility.tsx @@ -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(doc.link_reach); + const [docLinkRole, setDocLinkRole] = useState(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 ( + + + {t('Link parameters')} + + + + + + + + {linkReachChoices[linkReach].label} + + + + {isDesktop && ( + + {description} + + )} + + {showLinkRoleOptions && ( + + {linkReach !== LinkReach.RESTRICTED && ( + + + {linkModeTranslations[docLinkRole]} + + + )} + + )} + + {!isDesktop && ( + + {description} + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/hooks/useTranslatedShareSettings.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/hooks/useTranslatedShareSettings.tsx new file mode 100644 index 00000000..7388e443 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/hooks/useTranslatedShareSettings.tsx @@ -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, + }; +}; diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDeleteDocInvitation.ts b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDeleteDocInvitation.ts index 5874cd8c..ee281b3a 100644 --- a/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDeleteDocInvitation.ts +++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDeleteDocInvitation.ts @@ -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, DeleteDocInvitationProps >; @@ -42,7 +46,11 @@ export const useDeleteDocInvitation = ( options?: UseDeleteDocInvitationOptions, ) => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation< + void, + APIError, + DeleteDocInvitationProps + >({ mutationFn: deleteDocInvitation, ...options, onSuccess: (data, variables, context) => { diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useUpdateDocInvitation.ts b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useUpdateDocInvitation.ts index 435ea963..1678ba9b 100644 --- a/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useUpdateDocInvitation.ts +++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useUpdateDocInvitation.ts @@ -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; type UseUpdateDocInvitationOptions = UseMutationOptions< Invitation, - APIError, + APIError, UseUpdateDocInvitation >; @@ -51,7 +55,11 @@ export const useUpdateDocInvitation = ( options?: UseUpdateDocInvitationOptions, ) => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation< + Invitation, + APIError, + UpdateDocInvitationProps + >({ mutationFn: updateDocInvitation, ...options, onSuccess: (data, variables, context) => { diff --git a/src/frontend/apps/impress/src/utils/children.ts b/src/frontend/apps/impress/src/utils/children.ts new file mode 100644 index 00000000..048c76ce --- /dev/null +++ b/src/frontend/apps/impress/src/utils/children.ts @@ -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; +};