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