From b69ce001c882ad8d368adb4d5875902586189666 Mon Sep 17 00:00:00 2001 From: daproclaima Date: Mon, 26 Aug 2024 15:58:37 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20allow=20group=20members?= =?UTF-8?q?=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add title above the list of members - allow members filtering with an input - updates translations and related tests - add global useDebounce hook to prevent spamming API with useless requests on inputs sending requests on value change - add component and e2e tests --- CHANGELOG.md | 1 + .../api/hooks/__tests__/useDebounce.test.tsx | 56 +++++++ src/frontend/apps/desk/src/api/hooks/index.ts | 1 + .../apps/desk/src/api/hooks/useDebounce.tsx | 18 +++ src/frontend/apps/desk/src/api/index.ts | 1 + .../__tests__/MemberGrid.test.tsx | 142 ++++++++++++++++++ .../__tests__/useTeamAccesses.test.ts | 103 +++++++++++++ .../api/useTeamsAccesses.tsx | 28 +++- .../assets/icon-magnifying-glass.svg | 3 + .../components/MemberGrid.tsx | 66 +++++--- .../apps/desk/src/i18n/translations.json | 2 + .../apps/e2e/__tests__/app-desk/team.spec.ts | 18 +++ 12 files changed, 418 insertions(+), 21 deletions(-) create mode 100644 src/frontend/apps/desk/src/api/hooks/__tests__/useDebounce.test.tsx create mode 100644 src/frontend/apps/desk/src/api/hooks/index.ts create mode 100644 src/frontend/apps/desk/src/api/hooks/useDebounce.tsx create mode 100644 src/frontend/apps/desk/src/features/teams/member-management/__tests__/useTeamAccesses.test.ts create mode 100644 src/frontend/apps/desk/src/features/teams/member-management/assets/icon-magnifying-glass.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 056a7c7..6c17c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to - ✨(dimail) allow la regie to request a token for another user #416 - ✨(frontend) show username on AccountDropDown #412 - 🥅(frontend) improve add & update group forms error handling #387 +- ✨(frontend) allow group members filtering #363 ### Changed diff --git a/src/frontend/apps/desk/src/api/hooks/__tests__/useDebounce.test.tsx b/src/frontend/apps/desk/src/api/hooks/__tests__/useDebounce.test.tsx new file mode 100644 index 0000000..a6ab897 --- /dev/null +++ b/src/frontend/apps/desk/src/api/hooks/__tests__/useDebounce.test.tsx @@ -0,0 +1,56 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useDebounce } from '../useDebounce'; + +jest.useFakeTimers(); + +describe('useDebounce', () => { + it('should return the initial value immediately', () => { + const { result } = renderHook(() => useDebounce('initial value')); + + expect(result.current).toBe('initial value'); + }); + + it('should return debounced value after default 500ms delay', () => { + const { result, rerender } = renderHook(({ value }) => useDebounce(value), { + initialProps: { value: 'initial' }, + }); + + expect(result.current).toBe('initial'); + + rerender({ value: 'updated' }); + + expect(result.current).toBe('initial'); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(result.current).toBe('updated'); + }); + + it('should reset the debounce timer if value changes before delay', () => { + const { result, rerender } = renderHook(({ value }) => useDebounce(value), { + initialProps: { value: 'initial' }, + }); + + expect(result.current).toBe('initial'); + + rerender({ value: 'updated' }); + rerender({ value: 'updated again' }); + + // Fast forward 400ms (less than debounce delay), value should still be 'initial' + act(() => { + jest.advanceTimersByTime(400); + }); + + expect(result.current).toBe('initial'); + + // Fast forward 500ms (total: 900ms), value should be 'updated again' + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(result.current).toBe('updated again'); + }); +}); diff --git a/src/frontend/apps/desk/src/api/hooks/index.ts b/src/frontend/apps/desk/src/api/hooks/index.ts new file mode 100644 index 0000000..06c4916 --- /dev/null +++ b/src/frontend/apps/desk/src/api/hooks/index.ts @@ -0,0 +1 @@ +export { useDebounce } from './useDebounce'; diff --git a/src/frontend/apps/desk/src/api/hooks/useDebounce.tsx b/src/frontend/apps/desk/src/api/hooks/useDebounce.tsx new file mode 100644 index 0000000..bb7d236 --- /dev/null +++ b/src/frontend/apps/desk/src/api/hooks/useDebounce.tsx @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +const MS_DEBOUNCE = 500; +export const useDebounce = (value: string, delay: number = MS_DEBOUNCE) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +}; diff --git a/src/frontend/apps/desk/src/api/index.ts b/src/frontend/apps/desk/src/api/index.ts index c79db68..defe248 100644 --- a/src/frontend/apps/desk/src/api/index.ts +++ b/src/frontend/apps/desk/src/api/index.ts @@ -3,3 +3,4 @@ export * from './conf'; export * from './fetchApi'; export * from './types'; export * from './utils'; +export * from './hooks'; diff --git a/src/frontend/apps/desk/src/features/teams/member-management/__tests__/MemberGrid.test.tsx b/src/frontend/apps/desk/src/features/teams/member-management/__tests__/MemberGrid.test.tsx index 861d49e..fba41c3 100644 --- a/src/frontend/apps/desk/src/features/teams/member-management/__tests__/MemberGrid.test.tsx +++ b/src/frontend/apps/desk/src/features/teams/member-management/__tests__/MemberGrid.test.tsx @@ -347,4 +347,146 @@ describe('MemberGrid', () => { expect(screen.queryByLabelText('arrow_drop_down')).not.toBeInTheDocument(); expect(screen.queryByLabelText('arrow_drop_up')).not.toBeInTheDocument(); }); + + it('filters members based on the query', async () => { + const accesses: Access[] = [ + { + id: '1', + role: Role.OWNER, + user: { + id: '11', + name: 'albert', + email: 'albert@test.com', + }, + abilities: {} as any, + }, + { + id: '2', + role: Role.MEMBER, + user: { + id: '22', + name: 'bob', + email: 'bob@test.com', + }, + abilities: {} as any, + }, + { + id: '3', + role: Role.ADMIN, + user: { + id: '33', + name: 'charlie', + email: 'charlie@test.com', + }, + abilities: {} as any, + }, + ]; + + // Mocking initial load of all members + fetchMock.get(`end:/teams/123456/accesses/?page=1`, { + count: 3, + results: accesses, + }); + + // Mocking filtered results for 'bob' + fetchMock.get(`end:/teams/123456/accesses/?q=bob`, { + count: 1, + results: [ + { + id: '2', + role: Role.MEMBER, + user: { + id: '22', + name: 'bob', + email: 'bob@test.com', + }, + abilities: {} as any, + }, + ], + }); + + render(, { + wrapper: AppWrapper, + }); + + expect(await screen.findByText('albert')).toBeInTheDocument(); + expect(screen.getByText('bob')).toBeInTheDocument(); + expect(screen.getByText('charlie')).toBeInTheDocument(); + + const searchInput = screen.getByLabelText('Filter member list'); + await userEvent.type(searchInput, 'bob'); + + await waitFor(() => { + expect(screen.queryByText('albert')).not.toBeInTheDocument(); + }); + + expect(screen.queryByText('charlie')).not.toBeInTheDocument(); + expect(screen.getByText('bob')).toBeInTheDocument(); + }); + + it('displays "No members found" when filter returns no results', async () => { + const accesses: Access[] = [ + { + id: '1', + role: Role.OWNER, + user: { + id: '11', + name: 'albert', + email: 'albert@test.com', + }, + abilities: {} as any, + }, + { + id: '2', + role: Role.MEMBER, + user: { + id: '22', + name: 'bob', + email: 'bob@test.com', + }, + abilities: {} as any, + }, + { + id: '3', + role: Role.ADMIN, + user: { + id: '33', + name: 'charlie', + email: 'charlie@test.com', + }, + abilities: {} as any, + }, + ]; + + // Mocking initial load of all members + fetchMock.get(`end:/teams/123456/accesses/?page=1`, { + count: 3, + results: accesses, + }); + + // Mocking empty filtered results + fetchMock.get(`end:/teams/123456/accesses/?q=nonexistent`, { + count: 0, + results: [], + }); + + render(, { + wrapper: AppWrapper, + }); + + expect(await screen.findByText('albert')).toBeInTheDocument(); + expect(screen.getByText('bob')).toBeInTheDocument(); + expect(screen.getByText('charlie')).toBeInTheDocument(); + + const searchInput = screen.getByLabelText('Filter member list'); + await userEvent.type(searchInput, 'nonexistent'); + + await waitFor(() => { + expect(screen.queryByText('albert')).not.toBeInTheDocument(); + }); + expect(screen.queryByText('bob')).not.toBeInTheDocument(); + expect(screen.queryByText('charlie')).not.toBeInTheDocument(); + + expect(screen.getByText('This table is empty')).toBeInTheDocument(); + }); }); diff --git a/src/frontend/apps/desk/src/features/teams/member-management/__tests__/useTeamAccesses.test.ts b/src/frontend/apps/desk/src/features/teams/member-management/__tests__/useTeamAccesses.test.ts new file mode 100644 index 0000000..9ca85ce --- /dev/null +++ b/src/frontend/apps/desk/src/features/teams/member-management/__tests__/useTeamAccesses.test.ts @@ -0,0 +1,103 @@ +import { APIError, fetchAPI } from '@/api'; +import { getTeamAccesses } from '@/features/teams/member-management'; + +jest.mock('@/api', () => ({ + fetchAPI: jest.fn(), +})); + +describe('getTeamAccesses', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should construct the correct URL with only page', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + count: 0, + next: null, + previous: null, + results: [], + }), + }; + + (fetchAPI as jest.Mock).mockResolvedValue(mockResponse); + + await getTeamAccesses({ + page: 1, + teamId: '123', + ordering: undefined, + query: undefined, + }); + + expect(fetchAPI).toHaveBeenCalledWith('teams/123/accesses/?page=1'); + }); + + it('should construct the correct URL with only query', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + count: 0, + next: null, + previous: null, + results: [], + }), + }; + + (fetchAPI as jest.Mock).mockResolvedValue(mockResponse); + + await getTeamAccesses({ + page: 1, + teamId: '123', + ordering: undefined, + query: 'patricia', + }); + + expect(fetchAPI).toHaveBeenCalledWith('teams/123/accesses/?q=patricia'); + }); + + it('should construct the correct URL with ordering and query', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + count: 0, + next: null, + previous: null, + results: [], + }), + }; + + (fetchAPI as jest.Mock).mockResolvedValue(mockResponse); + + await getTeamAccesses({ + page: 1, + teamId: '123', + ordering: 'user__name', + query: 'patricia', + }); + + expect(fetchAPI).toHaveBeenCalledWith( + 'teams/123/accesses/?q=patricia&ordering=user__name', + ); + }); + + it('should throw an APIError when the response is not ok', async () => { + const mockResponse = { + ok: false, + json: jest.fn().mockResolvedValue({}), + }; + + (fetchAPI as jest.Mock).mockResolvedValue(mockResponse); + + await expect( + getTeamAccesses({ + page: 1, + teamId: '123', + ordering: undefined, + query: undefined, + }), + ).rejects.toThrow(APIError); + + expect(fetchAPI).toHaveBeenCalledWith('teams/123/accesses/?page=1'); + }); +}); diff --git a/src/frontend/apps/desk/src/features/teams/member-management/api/useTeamsAccesses.tsx b/src/frontend/apps/desk/src/features/teams/member-management/api/useTeamsAccesses.tsx index ee4f4ea..8416632 100644 --- a/src/frontend/apps/desk/src/features/teams/member-management/api/useTeamsAccesses.tsx +++ b/src/frontend/apps/desk/src/features/teams/member-management/api/useTeamsAccesses.tsx @@ -8,22 +8,42 @@ export type TeamAccessesAPIParams = { page: number; teamId: string; ordering?: string; + query?: string; }; type AccessesResponse = APIList; +/** + * @description Since there cannot be both a page and q params in query params at the same time, the queryString + * building order should not be changed. + * @param page + * @param teamId + * @param ordering + * @param q + */ export const getTeamAccesses = async ({ page, teamId, ordering, + query, }: TeamAccessesAPIParams): Promise => { - let url = `teams/${teamId}/accesses/?page=${page}`; + const url = `teams/${teamId}/accesses/`; - if (ordering) { - url += '&ordering=' + ordering; + let queryString = ''; + + if (page) { + queryString = `?page=${page}`; } - const response = await fetchAPI(url); + if (query) { + queryString = `?q=${query}`; + } + + if (ordering) { + queryString += `&ordering=${ordering}`; + } + + const response = await fetchAPI(`${url}${queryString}`); if (!response.ok) { throw new APIError( diff --git a/src/frontend/apps/desk/src/features/teams/member-management/assets/icon-magnifying-glass.svg b/src/frontend/apps/desk/src/features/teams/member-management/assets/icon-magnifying-glass.svg new file mode 100644 index 0000000..aa90900 --- /dev/null +++ b/src/frontend/apps/desk/src/features/teams/member-management/assets/icon-magnifying-glass.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/apps/desk/src/features/teams/member-management/components/MemberGrid.tsx b/src/frontend/apps/desk/src/features/teams/member-management/components/MemberGrid.tsx index 9f5e0bc..f9408fa 100644 --- a/src/frontend/apps/desk/src/features/teams/member-management/components/MemberGrid.tsx +++ b/src/frontend/apps/desk/src/features/teams/member-management/components/MemberGrid.tsx @@ -1,19 +1,22 @@ import { Button, DataGrid, + Input, SortModel, usePagination, } from '@openfun/cunningham-react'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useDebounce } from '@/api'; import IconUser from '@/assets/icons/icon-user.svg'; -import { Box, Card, TextErrors } from '@/components'; +import { Box, Card, Text, TextErrors } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import { ModalAddMembers } from '@/features/teams/member-add'; import { Role, Team } from '@/features/teams/team-management'; import { useTeamAccesses } from '../api'; +import IconMagnifyingGlass from '../assets/icon-magnifying-glass.svg'; import { PAGE_SIZE } from '../conf'; import { Access } from '../types'; @@ -43,8 +46,8 @@ const defaultOrderingMapping: Record = { */ function formatSortModel( sortModel: SortModelItem, - mapping = defaultOrderingMapping, -) { + mapping: Record = defaultOrderingMapping, +): string { const { field, sort } = sortModel; const orderingField = mapping[field] || field; return sort === 'desc' ? `-${orderingField}` : orderingField; @@ -53,20 +56,24 @@ function formatSortModel( export const MemberGrid = ({ team, currentRole }: MemberGridProps) => { const [isModalMemberOpen, setIsModalMemberOpen] = useState(false); const { t } = useTranslation(); + const [queryValue, setQueryValue] = useState(''); const { colorsTokens } = useCunninghamTheme(); const pagination = usePagination({ pageSize: PAGE_SIZE, }); + const [sortModel, setSortModel] = useState([]); const [accesses, setAccesses] = useState([]); const { page, pageSize, setPagesCount } = pagination; + const membersQuery = useDebounce(queryValue); const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined; const { data, isLoading, error } = useTeamAccesses({ teamId: team.id, page, ordering, + query: membersQuery, }); useEffect(() => { @@ -104,21 +111,46 @@ export const MemberGrid = ({ team, currentRole }: MemberGridProps) => { return ( <> - {currentRole !== Role.MEMBER && ( - - + + + {t('Group members')} + + * { + flex: 0.23 0 auto; + } + `} + > + } + onChange={(event) => setQueryValue(event.target.value)} + /> + {currentRole !== Role.MEMBER && ( + + )} - )} + { await createTeam(page, 'team-top-box', browserName, 1) ).shift(); + await expect(page.getByText('Group members')).toBeVisible(); + await expect(page.getByLabel('Filter member list')).toBeVisible(); + await expect( page.getByRole('heading', { name: teamName, @@ -54,4 +57,19 @@ test.describe('Team', () => { await expect(page.getByText('The team has been updated.')).toBeVisible(); await expect(page.getByText(`Group details`)).toBeVisible(); }); + + test('sorts group members by search term', async ({ + page, + browserName, + request, + }) => { + await createTeam(page, 'team-to-sort', browserName, 1); + + await page.getByLabel(`Open the team options`).click(); + await page.getByLabel('Filter member list').fill('term-to-search'); + + const response = await request.get('teams/?page=1&q=term-to-search'); + + expect(response.ok()).toBeTruthy(); + }); });