✨(frontend) invitation list
- Display the list of invitations for a document in the share modal. - We can now cancel an invitation. - We can now update the role of a invited user.
This commit is contained in:
@@ -124,6 +124,15 @@ test.describe('Document create member', () => {
|
||||
expect(responseAddUser.request().headers()['content-language']).toBe(
|
||||
'en-us',
|
||||
);
|
||||
|
||||
const listInvitation = page.getByLabel('List invitation card');
|
||||
await expect(listInvitation.locator('li').getByText(email)).toBeVisible();
|
||||
await expect(
|
||||
listInvitation.locator('li').getByText('Invited'),
|
||||
).toBeVisible();
|
||||
|
||||
const listMember = page.getByLabel('List members card');
|
||||
await expect(listMember.locator('li').getByText(user.email)).toBeVisible();
|
||||
});
|
||||
|
||||
test('it try to add twice the same user', async ({ page, browserName }) => {
|
||||
@@ -255,4 +264,47 @@ test.describe('Document create member', () => {
|
||||
responseCreateInvitation.request().headers()['content-language'],
|
||||
).toBe('fr-fr');
|
||||
});
|
||||
|
||||
test('it manages invitation', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'user-invitation', browserName, 1);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
await inputSearch.fill(email);
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Administrator' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
|
||||
// Check invitation sent
|
||||
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
|
||||
const responseCreateInvitation = await responsePromiseCreateInvitation;
|
||||
expect(responseCreateInvitation.ok()).toBeTruthy();
|
||||
|
||||
const listInvitation = page.getByLabel('List invitation card');
|
||||
const li = listInvitation.locator('li').filter({
|
||||
hasText: email,
|
||||
});
|
||||
await expect(li.getByText(email)).toBeVisible();
|
||||
|
||||
await li.getByRole('combobox', { name: /Role/ }).click();
|
||||
await li.getByRole('option', { name: 'Reader' }).click();
|
||||
await expect(page.getByText(`The role has been updated.`)).toBeVisible();
|
||||
await li.getByText('delete').click();
|
||||
await expect(
|
||||
page.getByText(`The invitation has been removed.`),
|
||||
).toBeVisible();
|
||||
await expect(listInvitation.locator('li').getByText(email)).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,11 @@ import { t } from 'i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { Box, Card, Text } from '@/components';
|
||||
import { SideModal } from '@/components/SideModal';
|
||||
import { Box, Card, 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 { AddMembers } from '../../members/members-add';
|
||||
import { MemberList } from '../../members/members-list/components/MemberList';
|
||||
import { Doc } from '../types';
|
||||
import { currentDocRole } from '../utils';
|
||||
|
||||
@@ -67,6 +67,7 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
|
||||
}
|
||||
>
|
||||
<AddMembers doc={doc} currentRole={currentDocRole(doc.abilities)} />
|
||||
<InvitationList doc={doc} />
|
||||
<MemberList doc={doc} />
|
||||
</SideModal>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './useDeleteDocInvitation';
|
||||
export * from './useDocInvitations';
|
||||
export * from './useUpdateDocInvitation';
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { KEY_LIST_DOC_INVITATIONS } from './useDocInvitations';
|
||||
|
||||
interface DeleteDocInvitationProps {
|
||||
docId: string;
|
||||
invitationId: string;
|
||||
}
|
||||
|
||||
export const deleteDocInvitation = async ({
|
||||
docId,
|
||||
invitationId,
|
||||
}: DeleteDocInvitationProps): Promise<void> => {
|
||||
const response = await fetchAPI(
|
||||
`documents/${docId}/invitations/${invitationId}/`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to delete the invitation',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type UseDeleteDocInvitationOptions = UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
DeleteDocInvitationProps
|
||||
>;
|
||||
|
||||
export const useDeleteDocInvitation = (
|
||||
options?: UseDeleteDocInvitationOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, APIError, DeleteDocInvitationProps>({
|
||||
mutationFn: deleteDocInvitation,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_INVITATIONS],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
options.onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (options?.onError) {
|
||||
options.onError(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
DefinedInitialDataInfiniteOptions,
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
UseQueryOptions,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { Invitation } from '../types';
|
||||
|
||||
export type DocInvitationsParams = {
|
||||
docId: string;
|
||||
ordering?: string;
|
||||
};
|
||||
|
||||
export type DocInvitationsAPIParams = DocInvitationsParams & {
|
||||
page: number;
|
||||
};
|
||||
|
||||
type DocInvitationsResponse = APIList<Invitation>;
|
||||
|
||||
export const getDocInvitations = async ({
|
||||
page,
|
||||
docId,
|
||||
ordering,
|
||||
}: DocInvitationsAPIParams): Promise<DocInvitationsResponse> => {
|
||||
let url = `documents/${docId}/invitations/?page=${page}`;
|
||||
|
||||
if (ordering) {
|
||||
url += '&ordering=' + ordering;
|
||||
}
|
||||
|
||||
const response = await fetchAPI(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the doc accesses',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocInvitationsResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_DOC_INVITATIONS = 'docs-invitations';
|
||||
|
||||
export function useDocInvitations(
|
||||
params: DocInvitationsAPIParams,
|
||||
queryConfig?: UseQueryOptions<
|
||||
DocInvitationsResponse,
|
||||
APIError,
|
||||
DocInvitationsResponse
|
||||
>,
|
||||
) {
|
||||
return useQuery<DocInvitationsResponse, APIError, DocInvitationsResponse>({
|
||||
queryKey: [KEY_LIST_DOC_INVITATIONS, params],
|
||||
queryFn: () => getDocInvitations(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param param Used for infinite scroll pagination
|
||||
* @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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { Role } from '@/features/docs/doc-management';
|
||||
|
||||
import { Invitation } from '../types';
|
||||
|
||||
import { KEY_LIST_DOC_INVITATIONS } from './useDocInvitations';
|
||||
|
||||
interface UpdateDocInvitationProps {
|
||||
docId: string;
|
||||
invitationId: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export const updateDocInvitation = async ({
|
||||
docId,
|
||||
invitationId,
|
||||
role,
|
||||
}: UpdateDocInvitationProps): Promise<Invitation> => {
|
||||
const response = await fetchAPI(
|
||||
`documents/${docId}/invitations/${invitationId}/`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
role,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to update role', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<Invitation>;
|
||||
};
|
||||
|
||||
type UseUpdateDocInvitation = Partial<Invitation>;
|
||||
|
||||
type UseUpdateDocInvitationOptions = UseMutationOptions<
|
||||
Invitation,
|
||||
APIError,
|
||||
UseUpdateDocInvitation
|
||||
>;
|
||||
|
||||
export const useUpdateDocInvitation = (
|
||||
options?: UseUpdateDocInvitationOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Invitation, APIError, UpdateDocInvitationProps>({
|
||||
mutationFn: updateDocInvitation,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_INVITATIONS],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
options.onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (options?.onError) {
|
||||
options.onError(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
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 { Role } from '@/features/docs/doc-management';
|
||||
import { ChooseRole } from '@/features/docs/members/members-add/';
|
||||
|
||||
import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api';
|
||||
import { Invitation } from '../types';
|
||||
|
||||
interface InvitationItemProps {
|
||||
role: Role;
|
||||
currentRole: Role;
|
||||
invitation: Invitation;
|
||||
docId: string;
|
||||
}
|
||||
|
||||
export const InvitationItem = ({
|
||||
docId,
|
||||
role,
|
||||
invitation,
|
||||
currentRole,
|
||||
}: InvitationItemProps) => {
|
||||
const canDelete = invitation.abilities.destroy;
|
||||
const canUpdate = invitation.abilities.partial_update;
|
||||
const { t } = useTranslation();
|
||||
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">
|
||||
<IconBG iconName="account_circle" $size="2rem" />
|
||||
<Box
|
||||
$align="center"
|
||||
$direction="row"
|
||||
$gap="1rem"
|
||||
$justify="space-between"
|
||||
$width="100%"
|
||||
$wrap="wrap"
|
||||
>
|
||||
<Box>
|
||||
<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">
|
||||
<Box $minWidth="13rem">
|
||||
<ChooseRole
|
||||
label={t('Role')}
|
||||
defaultRole={localRole}
|
||||
currentRole={currentRole}
|
||||
disabled={!canUpdate}
|
||||
setRole={(role) => {
|
||||
setLocalRole(role);
|
||||
updateDocInvitation({
|
||||
docId,
|
||||
invitationId: invitation.id,
|
||||
role,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
color="tertiary-text"
|
||||
icon={
|
||||
<Text
|
||||
$isMaterialIcon
|
||||
$theme={!canDelete ? 'greyscale' : 'primary'}
|
||||
$variation={!canDelete ? '500' : 'text'}
|
||||
>
|
||||
delete
|
||||
</Text>
|
||||
}
|
||||
disabled={!canDelete}
|
||||
onClick={() =>
|
||||
removeDocInvitation({ docId, invitationId: invitation.id })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
{(errorUpdate || errorDelete) && (
|
||||
<TextErrors causes={errorUpdate?.cause || errorDelete?.cause} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
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 { Doc, currentDocRole } from '@/features/docs/doc-management';
|
||||
|
||||
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();
|
||||
|
||||
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="small"
|
||||
$align="center"
|
||||
$gap="1rem"
|
||||
$radius="4px"
|
||||
as="li"
|
||||
>
|
||||
<InvitationItem
|
||||
invitation={invitation}
|
||||
role={invitation.role}
|
||||
docId={doc.id}
|
||||
currentRole={currentDocRole(doc.abilities)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
interface InvitationListProps {
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
export const InvitationList = ({ doc }: InvitationListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
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]);
|
||||
|
||||
if (!invitations?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
$margin="tiny"
|
||||
$padding="tiny"
|
||||
$maxHeight="60%"
|
||||
$overflow="auto"
|
||||
aria-label={t('List invitation card')}
|
||||
>
|
||||
<Box ref={containerRef} $overflow="auto">
|
||||
<InfiniteScroll
|
||||
hasMore={hasNextPage}
|
||||
isLoading={isFetchingNextPage}
|
||||
next={() => {
|
||||
void fetchNextPage();
|
||||
}}
|
||||
scrollContainer={containerRef.current}
|
||||
as="ul"
|
||||
className="p-0 mt-0"
|
||||
role="listbox"
|
||||
>
|
||||
<InvitationListState
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
invitations={invitations}
|
||||
doc={doc}
|
||||
/>
|
||||
</InfiniteScroll>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './InvitationList';
|
||||
@@ -0,0 +1 @@
|
||||
export const PAGE_SIZE = 20;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
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 { ContentLanguage } from '@/i18n/types';
|
||||
|
||||
import { KEY_LIST_DOC_INVITATIONS } from '../../invitation-list';
|
||||
import { DocInvitation, OptionType } from '../types';
|
||||
|
||||
interface CreateDocInvitationParams {
|
||||
@@ -45,7 +46,13 @@ export const createDocInvitation = async ({
|
||||
};
|
||||
|
||||
export function useCreateInvitation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<DocInvitation, APIError, CreateDocInvitationParams>({
|
||||
mutationFn: createDocInvitation,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_INVITATIONS],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user