🔥(frontend) remove unused document management components

- Deleted `DocVisibility`, `ModalShare`, `InvitationList`, `MemberList`,
and related components to streamline the document management feature.
- Updated component exports to reflect the removal of these components.
- Cleaned up associated assets and styles to improve code
maintainability.
This commit is contained in:
Nathan Panchout
2024-12-16 10:20:16 +01:00
committed by Anthony LC
parent 8456f47260
commit a5f6cb542d
42 changed files with 58 additions and 1344 deletions

View File

@@ -1,144 +0,0 @@
import {
Radio,
RadioGroup,
Select,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Card, IconBG } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { KEY_DOC, KEY_LIST_DOC, useUpdateDocLink } from '../api';
import { Doc, LinkReach, LinkRole } from '../types';
interface DocVisibilityProps {
doc: Doc;
}
export const DocVisibility = ({ doc }: DocVisibilityProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { colorsTokens } = useCunninghamTheme();
const api = useUpdateDocLink({
onSuccess: () => {
toast(
t('The document visibility has been updated.'),
VariantType.SUCCESS,
{
duration: 4000,
},
);
},
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const transLinkReach = {
[LinkReach.RESTRICTED]: {
label: t('Restricted'),
description: t('Only for people with access'),
},
[LinkReach.AUTHENTICATED]: {
label: t('Authenticated'),
description: t('Only for authenticated users'),
},
[LinkReach.PUBLIC]: {
label: t('Public'),
description: t('Anyone on the internet with the link can view'),
},
};
const linkRoleList = [
{
label: t('Read only'),
value: LinkRole.READER,
},
{
label: t('Can read and edit'),
value: LinkRole.EDITOR,
},
];
const showLinkRoleOptions = doc.link_reach !== LinkReach.RESTRICTED;
return (
<Card
$margin="tiny"
$padding={{ horizontal: 'small', vertical: 'tiny' }}
aria-label={t('Doc visibility card')}
$direction="row"
$align="center"
$justify="space-between"
$gap="1rem"
$wrap="wrap"
>
<IconBG iconName="public" />
<Box
$wrap="wrap"
$gap="1rem"
$direction="row"
$align="center"
$flex="1"
$css={`
& .c__field__footer .c__field__text {
${!doc.abilities.link_configuration && `color: ${colorsTokens()['greyscale-400']};`};
}
`}
>
<Box $shrink="0" $flex="auto" $maxWidth="20rem">
<Select
label={t('Visibility')}
options={Object.values(LinkReach).map((linkReach) => ({
label: transLinkReach[linkReach].label,
value: linkReach,
}))}
onChange={(evt) =>
api.mutate({
link_reach: evt.target.value as LinkReach,
id: doc.id,
})
}
value={doc.link_reach}
clearable={false}
text={transLinkReach[doc.link_reach].description}
disabled={!doc.abilities.link_configuration}
/>
</Box>
{showLinkRoleOptions && (
<Box
$css={`
& .c__checkbox{
padding: 0.15rem 0.25rem;
}
`}
>
<RadioGroup
compact
style={{
display: 'flex',
}}
text={t('How people can interact with the document')}
>
{linkRoleList.map((radio) => (
<Radio
key={radio.value}
label={radio.label}
value={radio.value}
onChange={() =>
api.mutate({
link_role: radio.value,
id: doc.id,
})
}
checked={doc.link_role === radio.value}
disabled={!doc.abilities.link_configuration}
/>
))}
</RadioGroup>
</Box>
)}
</Box>
</Card>
);
};

View File

@@ -1,138 +0,0 @@
import {
Button,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, Card, IconBG, SideModal, Text } from '@/components';
import { InvitationList } from '@/features/docs/members/invitation-list';
import { AddMembers } from '@/features/docs/members/members-add';
import { MemberList } from '@/features/docs/members/members-list';
import { useResponsiveStore } from '@/stores';
import { Doc } from '../types';
import { currentDocRole } from '../utils';
import { DocVisibility } from './DocVisibility';
const ModalShareStyle = createGlobalStyle`
& .c__modal__scroller{
background: #FAFAFA;
padding: 1.5rem .5rem;
.c__modal__title{
padding: 0;
margin: 0;
}
.c__modal__close{
margin-right: 1rem;
button{
border-bottom: 1px solid #E0E0E0;
border-left: 1px solid #E0E0E0;
}
}
}
`;
interface ModalShareProps {
onClose: () => void;
doc: Doc;
}
export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
const { t } = useTranslation();
const { isMobile, isSmallMobile } = useResponsiveStore();
const width = isSmallMobile ? '100vw' : isMobile ? '90vw' : '70vw';
const { toast } = useToastProvider();
return (
<>
<ModalShareStyle />
<SideModal
isOpen
closeOnClickOutside
hideCloseButton={!isSmallMobile}
onClose={onClose}
width={width}
$css="min-width: 320px;max-width: 777px;"
>
<Box aria-label={t('Share modal')} $margin={{ bottom: 'small' }}>
<Box $shrink="0">
<Card
$direction="row"
$align="center"
$margin={{ horizontal: 'tiny', top: 'none', bottom: 'big' }}
$padding="tiny"
$gap="1rem"
>
<IconBG
$isMaterialIcon
$size="48px"
iconName="share"
$margin="none"
/>
<Box
$justify="space-between"
$direction="row"
$align="center"
$width="100%"
$gap="1rem"
$wrap="wrap"
>
<Box $align="flex-start">
<Text as="h3" $size="26px" $margin="none">
{t('Share')}
</Text>
<Text $size="small" $weight="normal" $textAlign="left">
{doc.title}
</Text>
</Box>
<Box $margin={{ right: '1.5rem' }} $shrink="0">
<Button
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="primary"
icon={<span className="material-icons">copy</span>}
>
{t('Copy link')}
</Button>
</Box>
</Box>
</Card>
<DocVisibility doc={doc} />
{doc.abilities.accesses_manage && (
<AddMembers
doc={doc}
currentRole={currentDocRole(doc.abilities)}
/>
)}
</Box>
<Box $minHeight="0">
{doc.abilities.accesses_view && (
<>
<InvitationList doc={doc} />
<MemberList doc={doc} />
</>
)}
</Box>
</Box>
</SideModal>
</>
);
};

View File

@@ -1,2 +1 @@
export * from './ModalRemoveDoc';
export * from './ModalShare';

View File

@@ -0,0 +1,9 @@
export * from './useDeleteDocAccess';
export * from './useDocAccesses';
export * from './useUpdateDocAccess';
export * from './useCreateDocAccess';
export * from './useUsers';
export * from './useCreateDocInvitation';
export * from './useDeleteDocInvitation';
export * from './useDocInvitations';
export * from './useUpdateDocInvitation';

View File

@@ -9,7 +9,7 @@ import {
KEY_LIST_DOC,
Role,
} from '@/features/docs/doc-management';
import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/members/members-list';
import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/doc-share';
import { ContentLanguage } from '@/i18n/types';
import { useBroadcastStore } from '@/stores';

View File

@@ -3,11 +3,9 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/core/auth';
import { Doc, Role } from '@/features/docs/doc-management';
import { OptionType } from '@/features/docs/members/members-add/types';
import { Invitation, OptionType } from '@/features/docs/doc-share/types';
import { ContentLanguage } from '@/i18n/types';
import { Invitation } from '../types';
import { KEY_LIST_DOC_INVITATIONS } from './useDocInvitations';
interface CreateDocInvitationParams {

View File

@@ -6,7 +6,7 @@ import {
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_DOC, KEY_LIST_DOC } from '@/features/docs/doc-management';
import { KEY_LIST_USER } from '@/features/docs/members/members-add';
import { KEY_LIST_USER } from '@/features/docs/doc-share';
import { useBroadcastStore } from '@/stores';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';

View File

@@ -8,8 +8,7 @@ import {
} from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { Invitation } from '../types';
import { Invitation } from '@/features/docs/doc-share/types';
export type DocInvitationsParams = {
docId: string;

View File

@@ -6,8 +6,7 @@ import {
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Role } from '@/features/docs/doc-management';
import { Invitation } from '../types';
import { Invitation } from '@/features/docs/doc-share/types';
import { KEY_LIST_DOC_INVITATIONS } from './useDocInvitations';

View File

@@ -12,9 +12,11 @@ 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 {
useCreateDocAccess,
useCreateDocInvitation,
} from '@/features/docs/doc-share';
import { OptionType } from '@/features/docs/doc-share/types';
import { useLanguage } from '@/i18n/hooks/useLanguage';
import { DocRoleDropdown } from './DocRoleDropdown';

View File

@@ -9,12 +9,12 @@ import {
} from '@/components';
import { User } from '@/core';
import { Doc, Role } from '@/features/docs/doc-management';
import { SearchUserRow } from '@/features/docs/doc-share/component/SearchUserRow';
import {
useDeleteDocInvitation,
useUpdateDocInvitation,
} from '@/features/docs/members/invitation-list';
import { Invitation } from '@/features/docs/members/invitation-list/types';
} from '@/features/docs/doc-share';
import { SearchUserRow } from '@/features/docs/doc-share/component/SearchUserRow';
import { Invitation } from '@/features/docs/doc-share/types';
import { DocRoleDropdown } from './DocRoleDropdown';

View File

@@ -8,14 +8,11 @@ import {
IconOptions,
} from '@/components';
import { SearchUserRow } from '@/features/docs/doc-share/component/SearchUserRow';
import {
useDeleteDocAccess,
useUpdateDocAccess,
} from '@/features/docs/members/members-list';
import { useWhoAmI } from '@/features/docs/members/members-list/hooks/useWhoAmI';
import { useWhoAmI } from '@/features/docs/doc-share/hooks/useWhoAmI';
import { useResponsiveStore } from '@/stores';
import { Access, Doc, Role } from '../../doc-management/types';
import { useDeleteDocAccess, useUpdateDocAccess } from '../index';
import { DocRoleDropdown } from './DocRoleDropdown';

View File

@@ -13,10 +13,13 @@ import {
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 {
KEY_LIST_USER,
useDocAccessesInfinite,
useDocInvitationsInfinite,
useUsers,
} from '@/features/docs/doc-share';
import { Invitation } from '@/features/docs/doc-share/types';
import { useResponsiveStore } from '@/stores';
import { isValidEmail } from '@/utils';

View File

@@ -11,15 +11,16 @@ import {
Text,
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useResponsiveStore } from '@/stores';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
LinkReach,
LinkRole,
useUpdateDocLink,
} from '../../doc-management/api';
import { useTranslatedShareSettings } from '../hooks/useTranslatedShareSettings';
import { Doc, LinkReach, LinkRole } from '../../doc-management/types';
} from '@/features/docs';
import { useTranslatedShareSettings } from '@/features/docs/doc-share';
import { useResponsiveStore } from '@/stores';
interface DocVisibilityProps {
doc: Doc;

View File

@@ -0,0 +1,2 @@
export * from './useTranslatedShareSettings';
export * from './useWhoAmI';

View File

@@ -0,0 +1,2 @@
export * from './api';
export * from './hooks';

View File

@@ -1,4 +1,21 @@
import { User } from '@/core/auth';
import { Role } from '@/features/docs';
export interface Invitation {
id: string;
role: Role;
document: string;
created_at: string;
is_expired: boolean;
issuer: string;
email: string;
abilities: {
destroy: boolean;
retrieve: boolean;
partial_update: boolean;
update: boolean;
};
}
export enum OptionType {
INVITATION = 'invitation',

View File

@@ -1,4 +0,0 @@
export * from './useCreateDocInvitation';
export * from './useDeleteDocInvitation';
export * from './useDocInvitations';
export * from './useUpdateDocInvitation';

View File

@@ -1,146 +0,0 @@
import {
Button,
Loader,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, IconBG, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, Role } from '@/features/docs/doc-management';
import { ChooseRole } from '@/features/docs/members/members-add/';
import { useResponsiveStore } from '@/stores';
import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api';
import { Invitation } from '../types';
interface InvitationItemProps {
role: Role;
currentRole: Role;
invitation: Invitation;
doc: Doc;
}
export const InvitationItem = ({
doc,
role,
invitation,
currentRole,
}: InvitationItemProps) => {
const canDelete = invitation.abilities.destroy;
const canUpdate = invitation.abilities.partial_update;
const { t } = useTranslation();
const { isSmallMobile, screenWidth } = useResponsiveStore();
const [localRole, setLocalRole] = useState(role);
const { colorsTokens } = useCunninghamTheme();
const { toast } = useToastProvider();
const { mutate: updateDocInvitation, error: errorUpdate } =
useUpdateDocInvitation({
onSuccess: () => {
toast(t('The role has been updated.'), VariantType.SUCCESS, {
duration: 4000,
});
},
});
const { mutate: removeDocInvitation, error: errorDelete } =
useDeleteDocInvitation({
onSuccess: () => {
toast(t('The invitation has been removed.'), VariantType.SUCCESS, {
duration: 4000,
});
},
});
if (!invitation.email) {
return (
<Box className="m-auto">
<Loader />
</Box>
);
}
return (
<Box $width="100%" $gap="0.7rem">
<Box $direction="row" $gap="1rem" $wrap="wrap">
<Box
$align="center"
$direction="row"
$gap="1rem"
$justify="space-between"
$width="100%"
$wrap="wrap"
$css={`flex: ${isSmallMobile ? '100%' : '70%'};`}
>
<IconBG iconName="account_circle" $size="2rem" />
<Box $css="flex:1;">
<Text
$size="t"
$background={colorsTokens()['info-600']}
$color="white"
$radius="2px"
$padding="xtiny"
$css="align-self: flex-start;"
>
{t('Invited')}
</Text>
<Text $justify="center">{invitation.email}</Text>
</Box>
<Box
$direction="row"
$gap="1rem"
$align="center"
$justify="space-between"
$css="flex:1;"
$wrap={screenWidth < 400 ? 'wrap' : 'nowrap'}
>
<Box $minWidth="13rem" $css={isSmallMobile ? 'flex:1;' : ''}>
<ChooseRole
label={t('Role')}
defaultRole={localRole}
currentRole={currentRole}
disabled={!canUpdate}
setRole={(role) => {
setLocalRole(role);
updateDocInvitation({
docId: doc.id,
invitationId: invitation.id,
role,
});
}}
/>
</Box>
{doc.abilities.accesses_manage && (
<Box $margin={isSmallMobile ? 'auto' : ''}>
<Button
color="tertiary-text"
icon={
<Text
$isMaterialIcon
$theme={!canDelete ? 'greyscale' : 'primary'}
$variation={!canDelete ? '500' : 'text'}
>
delete
</Text>
}
disabled={!canDelete}
onClick={() =>
removeDocInvitation({
docId: doc.id,
invitationId: invitation.id,
})
}
/>
</Box>
)}
</Box>
</Box>
</Box>
{(errorUpdate || errorDelete) && (
<TextErrors causes={errorUpdate?.cause || errorDelete?.cause} />
)}
</Box>
);
};

View File

@@ -1,142 +0,0 @@
import { Loader } from '@openfun/cunningham-react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import { Box, Card, InfiniteScroll, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, currentDocRole } from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { useDocInvitationsInfinite } from '../api';
import { Invitation } from '../types';
import { InvitationItem } from './InvitationItem';
interface InvitationListStateProps {
isLoading: boolean;
error: APIError | null;
invitations?: Invitation[];
doc: Doc;
}
const InvitationListState = ({
invitations,
error,
isLoading,
doc,
}: InvitationListStateProps) => {
const { colorsTokens } = useCunninghamTheme();
const { isSmallMobile } = useResponsiveStore();
if (error) {
return <TextErrors causes={error.cause} />;
}
if (isLoading || !invitations) {
return (
<Box $align="center" className="m-l">
<Loader />
</Box>
);
}
return invitations?.map((invitation, index) => {
if (!invitation.email) {
return null;
}
return (
<Box
key={`${invitation.id}-${index}`}
$background={!(index % 2) ? 'white' : colorsTokens()['greyscale-000']}
$direction="row"
$padding={isSmallMobile ? 'tiny' : 'small'}
$align="center"
$gap="1rem"
$radius="4px"
as="li"
>
<InvitationItem
invitation={invitation}
role={invitation.role}
doc={doc}
currentRole={currentDocRole(doc.abilities)}
/>
</Box>
);
});
};
interface InvitationListProps {
doc: Doc;
}
export const InvitationList = ({ doc }: InvitationListProps) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [, setRefInitialized] = useState(false);
const {
data,
isLoading,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useDocInvitationsInfinite({
docId: doc.id,
});
const invitations = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return acc.concat(page.results);
}, [] as Invitation[]);
}, [data?.pages]);
/**
* The "return null;" statement below blocks a necessary rerender
* for the InfiniteScroll component to work properly.
* This useEffect is a workaround to force the rerender.
*/
useEffect(() => {
if (containerRef.current) {
setRefInitialized(true);
}
}, [invitations?.length]);
if (!invitations?.length) {
return null;
}
return (
<Card
$margin="tiny"
$overflow="auto"
$maxHeight="50vh"
$padding="tiny"
aria-label={t('List invitation card')}
>
<Box ref={containerRef} $overflow="auto">
<InfiniteScroll
hasMore={hasNextPage}
isLoading={isFetchingNextPage}
next={() => {
void fetchNextPage();
}}
scrollContainer={containerRef.current}
as="ul"
role="listbox"
$padding="none"
$margin="none"
>
<InvitationListState
isLoading={isLoading}
error={error}
invitations={invitations}
doc={doc}
/>
</InfiniteScroll>
</Box>
</Card>
);
};

View File

@@ -1 +0,0 @@
export * from './InvitationList';

View File

@@ -1 +0,0 @@
export const PAGE_SIZE = 20;

View File

@@ -1,2 +0,0 @@
export * from './api';
export * from './components';

View File

@@ -1,17 +0,0 @@
import { Role } from '@/features/docs/doc-management';
export interface Invitation {
id: string;
role: Role;
document: string;
created_at: string;
is_expired: boolean;
issuer: string;
email: string;
abilities: {
destroy: boolean;
retrieve: boolean;
partial_update: boolean;
update: boolean;
};
}

View File

@@ -1,2 +0,0 @@
export * from './useCreateDocAccess';
export * from './useUsers';

View File

@@ -1,14 +0,0 @@
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M21.34 26.04C20.9 26.02 20.46 26 20 26C15.16 26 10.64 27.34 6.78 29.64C5.02 30.68 4 32.64 4 34.7V40H22.52C20.94 37.74 20 34.98 20 32C20 29.86 20.5 27.86 21.34 26.04Z"
fill="currentColor"
/>
<path
d="M20 24C24.4183 24 28 20.4183 28 16C28 11.5817 24.4183 8 20 8C15.5817 8 12 11.5817 12 16C12 20.4183 15.5817 24 20 24Z"
fill="currentColor"
/>
<path
d="M33 24C28.032 24 24 28.032 24 33C24 37.968 28.032 42 33 42C37.968 42 42 37.968 42 33C42 28.032 37.968 24 33 24ZM36.6 33.9H33.9V36.6C33.9 37.095 33.495 37.5 33 37.5C32.505 37.5 32.1 37.095 32.1 36.6V33.9H29.4C28.905 33.9 28.5 33.495 28.5 33C28.5 32.505 28.905 32.1 29.4 32.1H32.1V29.4C32.1 28.905 32.505 28.5 33 28.5C33.495 28.5 33.9 28.905 33.9 29.4V32.1H36.6C37.095 32.1 37.5 32.505 37.5 33C37.5 33.495 37.095 33.9 36.6 33.9Z"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 925 B

View File

@@ -1,203 +0,0 @@
import {
Button,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import { Box, Card, IconBG } from '@/components';
import { Doc, Role } from '@/features/docs/doc-management';
import { useCreateDocInvitation } from '@/features/docs/members/invitation-list/';
import { useLanguage } from '@/i18n/hooks/useLanguage';
import { useResponsiveStore } from '@/stores';
import { useCreateDocAccess } from '../api';
import {
OptionInvitation,
OptionNewMember,
OptionSelect,
OptionType,
isOptionNewMember,
} from '../types';
import { ChooseRole } from './ChooseRole';
import { OptionsSelect, SearchUsers } from './SearchUsers';
type APIErrorUser = APIError<{
value: string;
type: OptionType;
}>;
interface ModalAddMembersProps {
currentRole: Role;
doc: Doc;
}
export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
const { contentLanguage } = useLanguage();
const { t } = useTranslation();
const { isSmallMobile } = useResponsiveStore();
const [selectedUsers, setSelectedUsers] = useState<OptionsSelect>([]);
const [selectedRole, setSelectedRole] = useState<Role>();
const { toast } = useToastProvider();
const { mutateAsync: createInvitation } = useCreateDocInvitation();
const { mutateAsync: createDocAccess } = useCreateDocAccess();
const [resetKey, setResetKey] = useState(1);
const [isPending, setIsPending] = useState<boolean>(false);
const switchActions = (selectedUsers: OptionsSelect, selectedRole: Role) =>
selectedUsers.map(async (selectedUser) => {
switch (selectedUser.type) {
case OptionType.INVITATION:
await createInvitation({
email: selectedUser.value.email,
role: selectedRole,
docId: doc.id,
contentLanguage,
});
break;
case OptionType.NEW_MEMBER:
await createDocAccess({
role: selectedRole,
docId: doc.id,
memberId: selectedUser.value.id,
contentLanguage,
});
break;
}
return selectedUser;
});
const toastOptions = {
duration: 4000,
};
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, toastOptions);
};
const onSuccess = (option: OptionSelect) => {
const message = !isOptionNewMember(option)
? t('Invitation sent to {{email}}.', {
email: option.value.email,
})
: t('User {{email}} added to the document.', {
email: option.value.email,
});
toast(message, VariantType.SUCCESS, toastOptions);
};
const handleValidate = async () => {
setIsPending(true);
if (!selectedRole) {
return;
}
const settledPromises = await Promise.allSettled<
OptionInvitation | OptionNewMember
>(switchActions(selectedUsers, selectedRole));
setIsPending(false);
setResetKey(resetKey + 1);
setSelectedUsers([]);
setSelectedRole(undefined);
settledPromises.forEach((settledPromise) => {
switch (settledPromise.status) {
case 'rejected':
onError(settledPromise.reason as APIErrorUser);
break;
case 'fulfilled':
onSuccess(settledPromise.value);
break;
}
});
};
return (
<Card
$gap="1rem"
$padding={{ horizontal: 'small', vertical: 'tiny' }}
$margin="tiny"
$direction="row"
$align="center"
$wrap="wrap"
>
<IconBG iconName="group_add" />
<Box
$gap="0.7rem"
$direction="row"
$wrap={isSmallMobile ? 'wrap' : 'nowrap'}
$css="flex: 70%;"
>
<Box $gap="0.7rem" $direction="row" $wrap="wrap" $css="flex: 80%;">
<Box $css="flex: auto;" $width="15rem">
<SearchUsers
key={resetKey}
doc={doc}
setSelectedUsers={setSelectedUsers}
selectedUsers={selectedUsers}
disabled={isPending || !doc.abilities.accesses_manage}
/>
</Box>
<Box $css="flex: auto;">
<ChooseRole
key={resetKey}
currentRole={currentRole}
disabled={isPending || !doc.abilities.accesses_manage}
setRole={setSelectedRole}
/>
</Box>
</Box>
<Box $align="center" $justify="center" $css="flex: auto;">
<Button
color="primary"
disabled={
!selectedUsers.length ||
isPending ||
!selectedRole ||
!doc.abilities.accesses_manage
}
onClick={() => void handleValidate()}
style={{ height: '100%', maxHeight: '55px' }}
>
{t('Validate')}
</Button>
</Box>
</Box>
</Card>
);
};

View File

@@ -1,37 +0,0 @@
import { Select } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Role, useTrans } from '@/features/docs/doc-management';
interface ChooseRoleProps {
currentRole: Role;
disabled: boolean;
defaultRole?: Role;
setRole: (role: Role) => void;
label?: string;
}
export const ChooseRole = ({
defaultRole,
disabled,
currentRole,
setRole,
label,
}: ChooseRoleProps) => {
const { t } = useTranslation();
const { transRole } = useTrans();
return (
<Select
label={label || t('Choose a role')}
options={Object.values(Role).map((role) => ({
label: transRole(role),
value: role,
disabled: currentRole !== Role.OWNER && role === Role.OWNER,
}))}
onChange={(evt) => setRole(evt.target.value as Role)}
disabled={disabled}
value={defaultRole}
/>
);
};

View File

@@ -1,161 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InputActionMeta, Options } from 'react-select';
import AsyncSelect from 'react-select/async';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import { isValidEmail } from '@/utils';
import { KEY_LIST_USER, useUsers } from '../api/useUsers';
import { OptionSelect, OptionType } from '../types';
export type OptionsSelect = Options<OptionSelect>;
interface SearchUsersProps {
doc: Doc;
selectedUsers: OptionsSelect;
setSelectedUsers: (value: OptionsSelect) => void;
disabled?: boolean;
}
export const SearchUsers = ({
doc,
selectedUsers,
setSelectedUsers,
disabled,
}: SearchUsersProps) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const [input, setInput] = useState('');
const [userQuery, setUserQuery] = useState('');
const resolveOptionsRef = useRef<((value: OptionsSelect) => void) | null>(
null,
);
const { data } = useUsers(
{ query: userQuery, docId: doc.id },
{
enabled: !!userQuery,
queryKey: [KEY_LIST_USER, { query: userQuery }],
},
);
const options = data?.results;
const optionsSelect = useMemo(() => {
if (!resolveOptionsRef.current || !options) {
return;
}
const optionsFiltered = options.filter(
(user) =>
!selectedUsers?.find(
(selectedUser) => selectedUser.value.email === user.email,
),
);
let users: OptionsSelect = optionsFiltered.map((user) => ({
value: user,
label: user.email,
type: OptionType.NEW_MEMBER,
}));
if (userQuery && isValidEmail(userQuery)) {
const isFoundUser = !!optionsFiltered.find(
(user) => user.email === userQuery,
);
const isFoundEmail = !!selectedUsers.find(
(selectedUser) => selectedUser.value.email === userQuery,
);
if (!isFoundUser && !isFoundEmail) {
users = [
...users,
{
value: { email: userQuery },
label: userQuery,
type: OptionType.INVITATION,
},
];
}
}
resolveOptionsRef.current(users);
resolveOptionsRef.current = null;
return users;
}, [options, selectedUsers, userQuery]);
const loadOptions = (): Promise<OptionsSelect> => {
return new Promise<OptionsSelect>((resolve) => {
resolveOptionsRef.current = resolve;
});
};
const timeout = useRef<NodeJS.Timeout | null>(null);
const onInputChangeHandle = useCallback(
(newValue: string, actionMeta: InputActionMeta) => {
if (
actionMeta.action === 'input-blur' ||
actionMeta.action === 'menu-close'
) {
return;
}
setInput(newValue);
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(() => {
setUserQuery(newValue);
}, 1000);
},
[],
);
return (
<AsyncSelect
styles={{
placeholder: (base) => ({
...base,
fontSize: '14px',
color: disabled
? colorsTokens()['greyscale-300']
: colorsTokens()['primary-600'],
}),
control: (base) => ({
...base,
minHeight: '45px',
borderColor: disabled
? colorsTokens()['greyscale-300']
: colorsTokens()['primary-600'],
backgroundColor: 'white',
}),
input: (base) => ({
...base,
minHeight: '45px',
fontSize: '14px',
}),
}}
isDisabled={disabled}
aria-label={t('Find a member to add to the document')}
isMulti
loadOptions={loadOptions}
defaultOptions={optionsSelect}
onInputChange={onInputChangeHandle}
inputValue={input}
placeholder={t('Search by email')}
noOptionsMessage={() =>
input
? t("We didn't find a mail matching, try to be more accurate")
: t('Invite new members to {{title}}', { title: doc.title })
}
onChange={(value) => {
setInput('');
setUserQuery('');
setSelectedUsers(value);
}}
/>
);
};

View File

@@ -1,2 +0,0 @@
export * from './AddMembers';
export * from './ChooseRole';

View File

@@ -1,2 +0,0 @@
export * from './api';
export * from './components';

View File

@@ -1,3 +0,0 @@
export * from './useDeleteDocAccess';
export * from './useDocAccesses';
export * from './useUpdateDocAccess';

View File

@@ -1,166 +0,0 @@
import {
Alert,
Button,
Loader,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, IconBG, Text, TextErrors } from '@/components';
import { Access, Doc, Role } from '@/features/docs/doc-management';
import { ChooseRole } from '@/features/docs/members/members-add/';
import { useResponsiveStore } from '@/stores';
import { useDeleteDocAccess, useUpdateDocAccess } from '../api';
import { useWhoAmI } from '../hooks/useWhoAmI';
interface MemberItemProps {
role: Role;
currentRole: Role;
access: Access;
doc: Doc;
}
export const MemberItem = ({
doc,
role,
access,
currentRole,
}: MemberItemProps) => {
const { isMyself, isLastOwner, isOtherOwner } = useWhoAmI(access);
const { t } = useTranslation();
const { isSmallMobile, screenWidth } = useResponsiveStore();
const [localRole, setLocalRole] = useState(role);
const { toast } = useToastProvider();
const { push } = useRouter();
const { mutate: updateDocAccess, error: errorUpdate } = useUpdateDocAccess({
onSuccess: () => {
toast(t('The role has been updated'), VariantType.SUCCESS, {
duration: 4000,
});
},
});
const { mutate: removeDocAccess, error: errorDelete } = useDeleteDocAccess({
onSuccess: () => {
toast(
t('The member has been removed from the document'),
VariantType.SUCCESS,
{
duration: 4000,
},
);
if (isMyself) {
void push('/');
}
},
});
const isNotAllowed =
isOtherOwner || isLastOwner || !doc.abilities.accesses_manage;
if (!access.user) {
return (
<Box className="m-auto">
<Loader />
</Box>
);
}
return (
<Box $width="100%">
<Box $direction="row" $gap="1rem" $wrap="wrap">
<Box
$align="center"
$direction="row"
$gap="1rem"
$justify="space-between"
$width="100%"
$wrap="wrap"
$css={`flex: ${isSmallMobile ? '100%' : '70%'};`}
>
<IconBG iconName="account_circle" $size="2rem" />
<Box $justify="center" $css="flex:1;">
{access.user.full_name && <Text>{access.user.full_name}</Text>}
<Text>{access.user.email}</Text>
</Box>
<Box
$direction="row"
$gap="1rem"
$align="center"
$justify="space-between"
$css="flex:1;"
$wrap={screenWidth < 400 ? 'wrap' : 'nowrap'}
>
<Box $minWidth="13rem" $css={isSmallMobile ? 'flex:1;' : ''}>
<ChooseRole
label={t('Role')}
defaultRole={localRole}
currentRole={currentRole}
disabled={isNotAllowed}
setRole={(role) => {
setLocalRole(role);
updateDocAccess({
docId: doc.id,
accessId: access.id,
role,
});
}}
/>
</Box>
{doc.abilities.accesses_manage && (
<Box $margin={isSmallMobile ? 'auto' : ''}>
<Button
color="tertiary-text"
icon={
<Text $isMaterialIcon $color="inherit">
delete
</Text>
}
disabled={isNotAllowed}
onClick={() =>
removeDocAccess({ docId: doc.id, accessId: access.id })
}
/>
</Box>
)}
</Box>
</Box>
</Box>
{(errorUpdate || errorDelete) && (
<Box $margin={{ top: 'tiny' }}>
<TextErrors causes={errorUpdate?.cause || errorDelete?.cause} />
</Box>
)}
{(isLastOwner || isOtherOwner) && doc.abilities.accesses_manage && (
<Box $margin={{ top: 'tiny' }}>
<Alert
canClose={false}
type={VariantType.WARNING}
icon={
<Text className="material-icons" $theme="warning">
warning
</Text>
}
>
{isLastOwner && (
<Box $direction="column" $gap="0.2rem">
<Text $theme="warning">
{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.',
)}
</Text>
</Box>
)}
{isOtherOwner &&
t('You cannot update the role or remove other owner.')}
</Alert>
</Box>
)}
</Box>
);
};

View File

@@ -1,125 +0,0 @@
import { Loader } from '@openfun/cunningham-react';
import React, { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import { Box, Card, InfiniteScroll, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Access, Doc, currentDocRole } from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { useDocAccessesInfinite } from '../api';
import { MemberItem } from './MemberItem';
interface MemberListStateProps {
isLoading: boolean;
error: APIError | null;
accesses?: Access[];
doc: Doc;
}
const MemberListState = ({
accesses,
error,
isLoading,
doc,
}: MemberListStateProps) => {
const { colorsTokens } = useCunninghamTheme();
const { isSmallMobile } = useResponsiveStore();
if (error) {
return <TextErrors causes={error.cause} />;
}
if (isLoading || !accesses) {
return (
<Box $align="center" className="m-l">
<Loader />
</Box>
);
}
return accesses?.map((access, index) => {
if (!access.user) {
return null;
}
return (
<Box
key={`${access.id}-${index}`}
$background={!(index % 2) ? 'white' : colorsTokens()['greyscale-000']}
$direction="row"
$padding={isSmallMobile ? 'tiny' : 'small'}
$align="center"
$gap="1rem"
$radius="4px"
as="li"
>
<MemberItem
access={access}
role={access.role}
doc={doc}
currentRole={currentDocRole(doc.abilities)}
/>
</Box>
);
});
};
interface MemberListProps {
doc: Doc;
}
export const MemberList = ({ doc }: MemberListProps) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const {
data,
isLoading,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useDocAccessesInfinite({
docId: doc.id,
});
const accesses = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return acc.concat(page.results);
}, [] as Access[]);
}, [data?.pages]);
return (
<Card
$margin="tiny"
$overflow="auto"
$maxHeight="80vh"
$padding="tiny"
aria-label={t('List members card')}
>
<Box ref={containerRef} $overflow="auto">
<InfiniteScroll
hasMore={hasNextPage}
isLoading={isFetchingNextPage}
next={() => {
void fetchNextPage();
}}
scrollContainer={containerRef.current}
as="ul"
$padding="none"
$margin="none"
role="listbox"
>
<MemberListState
isLoading={isLoading}
error={error}
accesses={accesses}
doc={doc}
/>
</InfiniteScroll>
</Box>
</Card>
);
};

View File

@@ -1 +0,0 @@
export * from './MemberList';

View File

@@ -1 +0,0 @@
export const PAGE_SIZE = 20;

View File

@@ -1,2 +0,0 @@
export * from './api';
export * from './components';