♻️(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);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
}
},
});
};

View File

@@ -1,21 +1,20 @@
import {
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
UseQueryOptions,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { UseQueryOptions, 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';
export type DocAccessesParam = {
export type DocAccessesParams = {
docId: string;
ordering?: string;
};
export type DocAccessesAPIParams = DocAccessesParam & {
export type DocAccessesAPIParams = DocAccessesParams & {
page: number;
};
@@ -62,33 +61,6 @@ export function useDocAccesses(
* @param queryConfig
* @returns
*/
export function useDocAccessesInfinite(
param: DocAccessesParam,
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,
});
export function useDocAccessesInfinite(params: DocAccessesParams) {
return useAPIInfiniteQuery(KEY_LIST_DOC_ACCESSES, getDocAccesses, params);
}

View File

@@ -1,13 +1,12 @@
import {
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
UseQueryOptions,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { UseQueryOptions, 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';
export type DocInvitationsParams = {
@@ -66,33 +65,10 @@ export function useDocInvitations(
* @param queryConfig
* @returns
*/
export function useDocInvitationsInfinite(
param: DocInvitationsParams,
queryConfig?: DefinedInitialDataInfiniteOptions<
DocInvitationsResponse,
APIError,
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,
});
export function useDocInvitationsInfinite(params: DocInvitationsParams) {
return useAPIInfiniteQuery(
KEY_LIST_DOC_INVITATIONS,
getDocInvitations,
params,
);
}

View File

@@ -68,10 +68,5 @@ export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
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 { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import {
Box,
DropdownMenu,
DropdownMenuOption,
Icon,
IconOptions,
LoadMoreText,
Text,
} from '@/components';
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, Role } from '@/docs/doc-management';
import { User } from '@/features/auth';
import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api';
import {
useDeleteDocInvitation,
useDocInvitationsInfinite,
useUpdateDocInvitation,
} from '../api';
import { Invitation } from '../types';
import { DocRoleDropdown } from './DocRoleDropdown';
import { SearchUserRow } from './SearchUserRow';
type Props = {
type DocShareInvitationItemProps = {
doc: Doc;
invitation: Invitation;
};
export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
const DocShareInvitationItem = ({
doc,
invitation,
}: DocShareInvitationItemProps) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const fakeUser: User = {
const invitedUser: User = {
id: invitation.email,
full_name: invitation.email,
email: invitation.email,
@@ -79,6 +93,7 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
disabled: !canUpdate,
},
];
return (
<Box
$width="100%"
@@ -88,7 +103,7 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
<SearchUserRow
isInvitation={true}
alwaysShowRight={true}
user={fakeUser}
user={invitedUser}
right={
<Box $direction="row" $align="center" $gap={spacingsTokens['2xs']}>
<DocRoleDropdown
@@ -111,3 +126,85 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
</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 { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -6,13 +7,19 @@ import {
DropdownMenu,
DropdownMenuOption,
IconOptions,
LoadMoreText,
} from '@/components';
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham';
import { Access, Doc, Role } from '@/docs/doc-management/';
import { useResponsiveStore } from '@/stores';
import { useDeleteDocAccess, useUpdateDocAccess } from '../api';
import { useWhoAmI } from '../hooks/';
import {
useDeleteDocAccess,
useDocAccessesInfinite,
useUpdateDocAccess,
} from '../api';
import { useWhoAmI } from '../hooks';
import { DocRoleDropdown } from './DocRoleDropdown';
import { SearchUserRow } from './SearchUserRow';
@@ -21,7 +28,8 @@ type Props = {
doc: Doc;
access: Access;
};
export const DocShareMemberItem = ({ doc, access }: Props) => {
const DocShareMemberItem = ({ doc, access }: Props) => {
const { t } = useTranslation();
const { isLastOwner } = useWhoAmI(access);
const { toast } = useToastProvider();
@@ -36,7 +44,7 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
const { mutate: updateDocAccess } = useUpdateDocAccess({
onError: () => {
toast(t('Error during invitation update'), VariantType.ERROR, {
toast(t('Error while updating the member role.'), VariantType.ERROR, {
duration: 4000,
});
},
@@ -44,7 +52,7 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
const { mutate: removeDocAccess } = useDeleteDocAccess({
onError: () => {
toast(t('Error while deleting invitation'), VariantType.ERROR, {
toast(t('Error while deleting the member.'), VariantType.ERROR, {
duration: 4000,
});
},
@@ -105,3 +113,52 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
</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 { useDebouncedCallback } from 'use-debounce';
import { Box, HorizontalSeparator, LoadMoreText, Text } from '@/components';
import { Box, HorizontalSeparator, Text } from '@/components';
import {
QuickSearch,
QuickSearchData,
QuickSearchGroup,
} from '@/components/quick-search/';
import { User } from '@/features/auth';
import { Access, Doc } from '@/features/docs';
import { Doc } from '@/features/docs';
import { useResponsiveStore } from '@/stores';
import { isValidEmail } from '@/utils';
import {
KEY_LIST_USER,
useDocAccessesInfinite,
useDocInvitationsInfinite,
useUsers,
} from '../api';
import { Invitation } from '../types';
import { KEY_LIST_USER, useUsers } from '../api';
import { DocShareAddMemberList } from './DocShareAddMemberList';
import { DocShareInvitationItem } from './DocShareInvitationItem';
import { DocShareMemberItem } from './DocShareMemberItem';
import {
DocShareModalInviteUserRow,
QuickSearchGroupInvitation,
} from './DocShareInvitation';
import { QuickSearchGroupMember } from './DocShareMember';
import { DocShareModalFooter } from './DocShareModalFooter';
import { DocShareModalInviteUserRow } from './DocShareModalInviteUserByEmail';
const ShareModalStyle = createGlobalStyle`
.c__modal__title {
@@ -66,10 +62,6 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
setInputValue('');
};
const membersQuery = useDocAccessesInfinite({
docId: doc.id,
});
const searchUsersQuery = useUsers(
{ 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) => {
setUserQuery(str);
}, 300);
@@ -205,10 +172,10 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
placeholder={t('Type a name or email')}
>
{showMemberSection ? (
<QuickSearchMemberSection
doc={doc}
membersData={membersData}
/>
<>
<QuickSearchGroupInvitation doc={doc} />
<QuickSearchGroupMember doc={doc} />
</>
) : (
<QuickSearchInviteInputSection
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>
);
};