(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:
Anthony LC
2024-08-16 16:57:03 +02:00
committed by Anthony LC
parent 3e5dae4ff1
commit 5ef0f825e0
14 changed files with 578 additions and 5 deletions

View File

@@ -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();
});
});

View File

@@ -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>
</>

View File

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

View File

@@ -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);
}
},
});
};

View File

@@ -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,
});
}

View File

@@ -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);
}
},
});
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

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

View File

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

View File

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

View File

@@ -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;
};
}

View File

@@ -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],
});
},
});
}