♻️(frontend) improve separation of concerns in DocShareModal

Improve separation of concerns in the DocShareModal
component.
The member and invitation list are now
in a separate component.
It will help us to integrate cleanly the
request access list.
This commit is contained in:
Anthony LC
2025-06-20 17:34:04 +02:00
committed by Manuel Raynaud
parent 394f91387d
commit 411d52c73b
8 changed files with 202 additions and 241 deletions

View File

@@ -66,10 +66,5 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
void options.onSuccess(data, variables, context); void options.onSuccess(data, variables, context);
} }
}, },
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
}
},
}); });
}; };

View File

@@ -1,21 +1,20 @@
import { import { UseQueryOptions, useQuery } from '@tanstack/react-query';
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
UseQueryOptions,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; import {
APIError,
APIList,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
} from '@/api';
import { Access } from '@/docs/doc-management'; import { Access } from '@/docs/doc-management';
export type DocAccessesParam = { export type DocAccessesParams = {
docId: string; docId: string;
ordering?: string; ordering?: string;
}; };
export type DocAccessesAPIParams = DocAccessesParam & { export type DocAccessesAPIParams = DocAccessesParams & {
page: number; page: number;
}; };
@@ -62,33 +61,6 @@ export function useDocAccesses(
* @param queryConfig * @param queryConfig
* @returns * @returns
*/ */
export function useDocAccessesInfinite( export function useDocAccessesInfinite(params: DocAccessesParams) {
param: DocAccessesParam, return useAPIInfiniteQuery(KEY_LIST_DOC_ACCESSES, getDocAccesses, params);
queryConfig?: DefinedInitialDataInfiniteOptions<
AccessesResponse,
APIError,
InfiniteData<AccessesResponse>,
QueryKey,
number
>,
) {
return useInfiniteQuery<
AccessesResponse,
APIError,
InfiniteData<AccessesResponse>,
QueryKey,
number
>({
initialPageParam: 1,
queryKey: [KEY_LIST_DOC_ACCESSES, param],
queryFn: ({ pageParam }) =>
getDocAccesses({
...param,
page: pageParam,
}),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
},
...queryConfig,
});
} }

View File

@@ -1,13 +1,12 @@
import { import { UseQueryOptions, useQuery } from '@tanstack/react-query';
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
UseQueryOptions,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; import {
APIError,
APIList,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
} from '@/api';
import { Invitation } from '@/docs/doc-share/types'; import { Invitation } from '@/docs/doc-share/types';
export type DocInvitationsParams = { export type DocInvitationsParams = {
@@ -66,33 +65,10 @@ export function useDocInvitations(
* @param queryConfig * @param queryConfig
* @returns * @returns
*/ */
export function useDocInvitationsInfinite( export function useDocInvitationsInfinite(params: DocInvitationsParams) {
param: DocInvitationsParams, return useAPIInfiniteQuery(
queryConfig?: DefinedInitialDataInfiniteOptions< KEY_LIST_DOC_INVITATIONS,
DocInvitationsResponse, getDocInvitations,
APIError, params,
InfiniteData<DocInvitationsResponse>, );
QueryKey,
number
>,
) {
return useInfiniteQuery<
DocInvitationsResponse,
APIError,
InfiniteData<DocInvitationsResponse>,
QueryKey,
number
>({
initialPageParam: 1,
queryKey: [KEY_LIST_DOC_INVITATIONS, param],
queryFn: ({ pageParam }) =>
getDocInvitations({
...param,
page: pageParam,
}),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
},
...queryConfig,
});
} }

View File

@@ -68,10 +68,5 @@ export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
void options.onSuccess(data, variables, context); void options.onSuccess(data, variables, context);
} }
}, },
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
}
},
}); });
}; };

View File

@@ -1,30 +1,44 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react'; import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { import {
Box, Box,
DropdownMenu, DropdownMenu,
DropdownMenuOption, DropdownMenuOption,
Icon,
IconOptions, IconOptions,
LoadMoreText,
Text,
} from '@/components'; } from '@/components';
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { Doc, Role } from '@/docs/doc-management'; import { Doc, Role } from '@/docs/doc-management';
import { User } from '@/features/auth'; import { User } from '@/features/auth';
import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api'; import {
useDeleteDocInvitation,
useDocInvitationsInfinite,
useUpdateDocInvitation,
} from '../api';
import { Invitation } from '../types'; import { Invitation } from '../types';
import { DocRoleDropdown } from './DocRoleDropdown'; import { DocRoleDropdown } from './DocRoleDropdown';
import { SearchUserRow } from './SearchUserRow'; import { SearchUserRow } from './SearchUserRow';
type Props = { type DocShareInvitationItemProps = {
doc: Doc; doc: Doc;
invitation: Invitation; invitation: Invitation;
}; };
export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
const DocShareInvitationItem = ({
doc,
invitation,
}: DocShareInvitationItemProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme(); const { spacingsTokens } = useCunninghamTheme();
const fakeUser: User = { const invitedUser: User = {
id: invitation.email, id: invitation.email,
full_name: invitation.email, full_name: invitation.email,
email: invitation.email, email: invitation.email,
@@ -79,6 +93,7 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
disabled: !canUpdate, disabled: !canUpdate,
}, },
]; ];
return ( return (
<Box <Box
$width="100%" $width="100%"
@@ -88,7 +103,7 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
<SearchUserRow <SearchUserRow
isInvitation={true} isInvitation={true}
alwaysShowRight={true} alwaysShowRight={true}
user={fakeUser} user={invitedUser}
right={ right={
<Box $direction="row" $align="center" $gap={spacingsTokens['2xs']}> <Box $direction="row" $align="center" $gap={spacingsTokens['2xs']}>
<DocRoleDropdown <DocRoleDropdown
@@ -111,3 +126,85 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
</Box> </Box>
); );
}; };
type DocShareModalInviteUserRowProps = {
user: User;
};
export const DocShareModalInviteUserRow = ({
user,
}: DocShareModalInviteUserRowProps) => {
const { t } = useTranslation();
return (
<Box
$width="100%"
data-testid={`search-user-row-${user.email}`}
className="--docs--doc-share-modal-invite-user-row"
>
<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="800">
{t('Add')}
</Text>
<Icon $theme="primary" $variation="800" iconName="add" />
</Box>
}
/>
</Box>
);
};
interface QuickSearchGroupInvitationProps {
doc: Doc;
}
export const QuickSearchGroupInvitation = ({
doc,
}: QuickSearchGroupInvitationProps) => {
const { t } = useTranslation();
const { data, hasNextPage, fetchNextPage } = useDocInvitationsInfinite({
docId: doc.id,
});
const invitationsData: QuickSearchData<Invitation> = useMemo(() => {
const invitations = data?.pages.flatMap((page) => page.results) || [];
return {
groupName: t('Pending invitations'),
elements: invitations,
endActions: hasNextPage
? [
{
content: <LoadMoreText data-testid="load-more-invitations" />,
onSelect: () => void fetchNextPage(),
},
]
: undefined,
};
}, [data?.pages, fetchNextPage, hasNextPage, t]);
if (!invitationsData.elements.length) {
return null;
}
return (
<Box aria-label={t('List invitation card')}>
<QuickSearchGroup
group={invitationsData}
renderElement={(invitation) => (
<DocShareInvitationItem doc={doc} invitation={invitation} />
)}
/>
</Box>
);
};

View File

@@ -1,4 +1,5 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react'; import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -6,13 +7,19 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuOption, DropdownMenuOption,
IconOptions, IconOptions,
LoadMoreText,
} from '@/components'; } from '@/components';
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { Access, Doc, Role } from '@/docs/doc-management/'; import { Access, Doc, Role } from '@/docs/doc-management/';
import { useResponsiveStore } from '@/stores'; import { useResponsiveStore } from '@/stores';
import { useDeleteDocAccess, useUpdateDocAccess } from '../api'; import {
import { useWhoAmI } from '../hooks/'; useDeleteDocAccess,
useDocAccessesInfinite,
useUpdateDocAccess,
} from '../api';
import { useWhoAmI } from '../hooks';
import { DocRoleDropdown } from './DocRoleDropdown'; import { DocRoleDropdown } from './DocRoleDropdown';
import { SearchUserRow } from './SearchUserRow'; import { SearchUserRow } from './SearchUserRow';
@@ -21,7 +28,8 @@ type Props = {
doc: Doc; doc: Doc;
access: Access; access: Access;
}; };
export const DocShareMemberItem = ({ doc, access }: Props) => {
const DocShareMemberItem = ({ doc, access }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { isLastOwner } = useWhoAmI(access); const { isLastOwner } = useWhoAmI(access);
const { toast } = useToastProvider(); const { toast } = useToastProvider();
@@ -36,7 +44,7 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
const { mutate: updateDocAccess } = useUpdateDocAccess({ const { mutate: updateDocAccess } = useUpdateDocAccess({
onError: () => { onError: () => {
toast(t('Error during invitation update'), VariantType.ERROR, { toast(t('Error while updating the member role.'), VariantType.ERROR, {
duration: 4000, duration: 4000,
}); });
}, },
@@ -44,7 +52,7 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
const { mutate: removeDocAccess } = useDeleteDocAccess({ const { mutate: removeDocAccess } = useDeleteDocAccess({
onError: () => { onError: () => {
toast(t('Error while deleting invitation'), VariantType.ERROR, { toast(t('Error while deleting the member.'), VariantType.ERROR, {
duration: 4000, duration: 4000,
}); });
}, },
@@ -105,3 +113,52 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
</Box> </Box>
); );
}; };
interface QuickSearchGroupMemberProps {
doc: Doc;
}
export const QuickSearchGroupMember = ({
doc,
}: QuickSearchGroupMemberProps) => {
const { t } = useTranslation();
const membersQuery = useDocAccessesInfinite({
docId: doc.id,
});
const membersData: QuickSearchData<Access> = useMemo(() => {
const members =
membersQuery.data?.pages.flatMap((page) => page.results) || [];
const count = membersQuery.data?.pages[0]?.count ?? 1;
return {
groupName:
count === 1
? t('Document owner')
: 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]);
return (
<Box aria-label={t('List members card')}>
<QuickSearchGroup
group={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
)}
/>
</Box>
);
};

View File

@@ -4,30 +4,26 @@ import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components'; import { createGlobalStyle, css } from 'styled-components';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { Box, HorizontalSeparator, LoadMoreText, Text } from '@/components'; import { Box, HorizontalSeparator, Text } from '@/components';
import { import {
QuickSearch, QuickSearch,
QuickSearchData, QuickSearchData,
QuickSearchGroup, QuickSearchGroup,
} from '@/components/quick-search/'; } from '@/components/quick-search/';
import { User } from '@/features/auth'; import { User } from '@/features/auth';
import { Access, Doc } from '@/features/docs'; import { Doc } from '@/features/docs';
import { useResponsiveStore } from '@/stores'; import { useResponsiveStore } from '@/stores';
import { isValidEmail } from '@/utils'; import { isValidEmail } from '@/utils';
import { import { KEY_LIST_USER, useUsers } from '../api';
KEY_LIST_USER,
useDocAccessesInfinite,
useDocInvitationsInfinite,
useUsers,
} from '../api';
import { Invitation } from '../types';
import { DocShareAddMemberList } from './DocShareAddMemberList'; import { DocShareAddMemberList } from './DocShareAddMemberList';
import { DocShareInvitationItem } from './DocShareInvitationItem'; import {
import { DocShareMemberItem } from './DocShareMemberItem'; DocShareModalInviteUserRow,
QuickSearchGroupInvitation,
} from './DocShareInvitation';
import { QuickSearchGroupMember } from './DocShareMember';
import { DocShareModalFooter } from './DocShareModalFooter'; import { DocShareModalFooter } from './DocShareModalFooter';
import { DocShareModalInviteUserRow } from './DocShareModalInviteUserByEmail';
const ShareModalStyle = createGlobalStyle` const ShareModalStyle = createGlobalStyle`
.c__modal__title { .c__modal__title {
@@ -66,10 +62,6 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
setInputValue(''); setInputValue('');
}; };
const membersQuery = useDocAccessesInfinite({
docId: doc.id,
});
const searchUsersQuery = useUsers( const searchUsersQuery = useUsers(
{ query: userQuery, docId: doc.id }, { query: userQuery, docId: doc.id },
{ {
@@ -78,31 +70,6 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
}, },
); );
const membersData: QuickSearchData<Access> = useMemo(() => {
const members =
membersQuery.data?.pages.flatMap((page) => page.results) || [];
const count = membersQuery.data?.pages[0]?.count ?? 1;
return {
groupName:
count === 1
? t('Document owner')
: 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 onFilter = useDebouncedCallback((str: string) => { const onFilter = useDebouncedCallback((str: string) => {
setUserQuery(str); setUserQuery(str);
}, 300); }, 300);
@@ -205,10 +172,10 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
placeholder={t('Type a name or email')} placeholder={t('Type a name or email')}
> >
{showMemberSection ? ( {showMemberSection ? (
<QuickSearchMemberSection <>
doc={doc} <QuickSearchGroupInvitation doc={doc} />
membersData={membersData} <QuickSearchGroupMember doc={doc} />
/> </>
) : ( ) : (
<QuickSearchInviteInputSection <QuickSearchInviteInputSection
searchUsersRawData={searchUsersQuery.data} searchUsersRawData={searchUsersQuery.data}
@@ -279,59 +246,3 @@ const QuickSearchInviteInputSection = ({
/> />
); );
}; };
interface QuickSearchMemberSectionProps {
doc: Doc;
membersData: QuickSearchData<Access>;
}
const QuickSearchMemberSection = ({
doc,
membersData,
}: QuickSearchMemberSectionProps) => {
const { t } = useTranslation();
const { data, hasNextPage, fetchNextPage } = useDocInvitationsInfinite({
docId: doc.id,
});
const invitationsData: QuickSearchData<Invitation> = useMemo(() => {
const invitations = data?.pages.flatMap((page) => page.results) || [];
return {
groupName: t('Pending invitations'),
elements: invitations,
endActions: hasNextPage
? [
{
content: <LoadMoreText data-testid="load-more-invitations" />,
onSelect: () => void fetchNextPage(),
},
]
: undefined,
};
}, [data?.pages, fetchNextPage, hasNextPage, t]);
return (
<>
{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>
</>
);
};

View File

@@ -1,42 +0,0 @@
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { User } from '@/features/auth';
import { SearchUserRow } from './SearchUserRow';
type Props = {
user: User;
};
export const DocShareModalInviteUserRow = ({ user }: Props) => {
const { t } = useTranslation();
return (
<Box
$width="100%"
data-testid={`search-user-row-${user.email}`}
className="--docs--doc-share-modal-invite-user-row"
>
<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="800">
{t('Add')}
</Text>
<Icon $theme="primary" $variation="800" iconName="add" />
</Box>
}
/>
</Box>
);
};