From 5ef0f825e00761249180e022139eb8ca88f81aae Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 16 Aug 2024 16:57:03 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20invitation=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- CHANGELOG.md | 1 + .../app-impress/doc-member-create.spec.ts | 52 +++++++ .../doc-management/components/ModalShare.tsx | 9 +- .../docs/members/invitation-list/api/index.ts | 3 + .../api/useDeleteDocInvitation.ts | 62 +++++++++ .../invitation-list/api/useDocInvitations.tsx | 99 ++++++++++++++ .../api/useUpdateDocInvitation.ts | 71 ++++++++++ .../components/InvitationItem.tsx | 129 ++++++++++++++++++ .../components/InvitationList.tsx | 127 +++++++++++++++++ .../invitation-list/components/index.ts | 1 + .../docs/members/invitation-list/conf.ts | 1 + .../docs/members/invitation-list/index.ts | 2 + .../docs/members/invitation-list/types.ts | 17 +++ .../api/useCreateDocInvitation.tsx | 9 +- 14 files changed, 578 insertions(+), 5 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/members/invitation-list/api/index.ts create mode 100644 src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDeleteDocInvitation.ts create mode 100644 src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDocInvitations.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useUpdateDocInvitation.ts create mode 100644 src/frontend/apps/impress/src/features/docs/members/invitation-list/components/InvitationItem.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/members/invitation-list/components/InvitationList.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/members/invitation-list/components/index.ts create mode 100644 src/frontend/apps/impress/src/features/docs/members/invitation-list/conf.ts create mode 100644 src/frontend/apps/impress/src/features/docs/members/invitation-list/index.ts create mode 100644 src/frontend/apps/impress/src/features/docs/members/invitation-list/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d04afb30..5d76c65a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to - 🌐Internationalize invitation email #167 - ✨(frontend) White branding #164 - ✨Email invitation when add user to doc #171 +- ✨Invitation management #174 ## Fixed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts index 5ef1d05a..8450b6f3 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts @@ -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(); + }); }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalShare.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalShare.tsx index 20aa8469..25f68c83 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalShare.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalShare.tsx @@ -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) => { } > + diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/index.ts b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/index.ts new file mode 100644 index 00000000..b76a3b26 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/index.ts @@ -0,0 +1,3 @@ +export * from './useDeleteDocInvitation'; +export * from './useDocInvitations'; +export * from './useUpdateDocInvitation'; diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDeleteDocInvitation.ts b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDeleteDocInvitation.ts new file mode 100644 index 00000000..5874cd8c --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDeleteDocInvitation.ts @@ -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 => { + 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({ + 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); + } + }, + }); +}; diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDocInvitations.tsx b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDocInvitations.tsx new file mode 100644 index 00000000..55a29112 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useDocInvitations.tsx @@ -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; + +export const getDocInvitations = async ({ + page, + docId, + ordering, +}: DocInvitationsAPIParams): Promise => { + 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; +}; + +export const KEY_LIST_DOC_INVITATIONS = 'docs-invitations'; + +export function useDocInvitations( + params: DocInvitationsAPIParams, + queryConfig?: UseQueryOptions< + DocInvitationsResponse, + APIError, + DocInvitationsResponse + >, +) { + return useQuery({ + 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, + QueryKey, + number + >, +) { + return useInfiniteQuery< + DocInvitationsResponse, + APIError, + InfiniteData, + 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, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useUpdateDocInvitation.ts b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useUpdateDocInvitation.ts new file mode 100644 index 00000000..435ea963 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/api/useUpdateDocInvitation.ts @@ -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 => { + 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; +}; + +type UseUpdateDocInvitation = Partial; + +type UseUpdateDocInvitationOptions = UseMutationOptions< + Invitation, + APIError, + UseUpdateDocInvitation +>; + +export const useUpdateDocInvitation = ( + options?: UseUpdateDocInvitationOptions, +) => { + const queryClient = useQueryClient(); + return useMutation({ + 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); + } + }, + }); +}; diff --git a/src/frontend/apps/impress/src/features/docs/members/invitation-list/components/InvitationItem.tsx b/src/frontend/apps/impress/src/features/docs/members/invitation-list/components/InvitationItem.tsx new file mode 100644 index 00000000..678f77a1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/members/invitation-list/components/InvitationItem.tsx @@ -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 ( + + + + ); + } + + return ( + + + + + + + {t('Invited')} + + {invitation.email} + + + + { + setLocalRole(role); + updateDocInvitation({ + docId, + invitationId: invitation.id, + role, + }); + }} + /> + +