✨(frontend) allow group members filtering
- 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
This commit is contained in:
committed by
Sebastien Nobour
parent
c0cd136618
commit
b69ce001c8
@@ -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
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
1
src/frontend/apps/desk/src/api/hooks/index.ts
Normal file
1
src/frontend/apps/desk/src/api/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useDebounce } from './useDebounce';
|
||||
18
src/frontend/apps/desk/src/api/hooks/useDebounce.tsx
Normal file
18
src/frontend/apps/desk/src/api/hooks/useDebounce.tsx
Normal file
@@ -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;
|
||||
};
|
||||
@@ -3,3 +3,4 @@ export * from './conf';
|
||||
export * from './fetchApi';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
export * from './hooks';
|
||||
|
||||
@@ -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(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
|
||||
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(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -8,22 +8,42 @@ export type TeamAccessesAPIParams = {
|
||||
page: number;
|
||||
teamId: string;
|
||||
ordering?: string;
|
||||
query?: string;
|
||||
};
|
||||
|
||||
type AccessesResponse = APIList<Access>;
|
||||
|
||||
/**
|
||||
* @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<AccessesResponse> => {
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.7539 14.2549H14.9639L14.6839 13.9849C15.6639 12.8449 16.2539 11.3649 16.2539 9.75488C16.2539 6.16488 13.3439 3.25488 9.75391 3.25488C6.16391 3.25488 3.25391 6.16488 3.25391 9.75488C3.25391 13.3449 6.16391 16.2549 9.75391 16.2549C11.3639 16.2549 12.8439 15.6649 13.9839 14.6849L14.2539 14.9649V15.7549L19.2539 20.7449L20.7439 19.2549L15.7539 14.2549ZM9.75391 14.2549C7.26391 14.2549 5.25391 12.2449 5.25391 9.75488C5.25391 7.26488 7.26391 5.25488 9.75391 5.25488C12.2439 5.25488 14.2539 7.26488 14.2539 9.75488C14.2539 12.2449 12.2439 14.2549 9.75391 14.2549Z" fill="#000091"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 697 B |
@@ -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<string, string> = {
|
||||
*/
|
||||
function formatSortModel(
|
||||
sortModel: SortModelItem,
|
||||
mapping = defaultOrderingMapping,
|
||||
) {
|
||||
mapping: Record<string, string> = 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<string>('');
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const pagination = usePagination({
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const [sortModel, setSortModel] = useState<SortModel>([]);
|
||||
const [accesses, setAccesses] = useState<Access[]>([]);
|
||||
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 && (
|
||||
<Box $margin={{ all: 'big', bottom: 'small' }} $align="flex-end">
|
||||
<Button
|
||||
aria-label={t('Add members to the team')}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
minWidth: '8rem',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={() => setIsModalMemberOpen(true)}
|
||||
>
|
||||
{t('Add a member')}
|
||||
</Button>
|
||||
<Box $margin={{ horizontal: 'big', bottom: 'small' }}>
|
||||
<Text
|
||||
$theme="primary"
|
||||
$weight="bold"
|
||||
$size="h3"
|
||||
$margin={{ bottom: 'big' }}
|
||||
>
|
||||
{t('Group members')}
|
||||
</Text>
|
||||
<Box
|
||||
$display="flex"
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$align="center"
|
||||
$gap="1rem"
|
||||
$css={`
|
||||
& > * {
|
||||
flex: 0.23 0 auto;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Input
|
||||
label={t('Filter member list')}
|
||||
rightIcon={<IconMagnifyingGlass />}
|
||||
onChange={(event) => setQueryValue(event.target.value)}
|
||||
/>
|
||||
{currentRole !== Role.MEMBER && (
|
||||
<Button
|
||||
aria-label={t('Add members to the team')}
|
||||
style={{
|
||||
minWidth: '8rem',
|
||||
maxWidth: 'fit-content',
|
||||
}}
|
||||
onClick={() => setIsModalMemberOpen(true)}
|
||||
>
|
||||
{t('Add a member')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Card
|
||||
$padding={{ bottom: 'small' }}
|
||||
$margin={{ all: 'big', top: 'none' }}
|
||||
|
||||
@@ -58,11 +58,13 @@
|
||||
"Example: saint-laurent.fr": "Exemple : saint-laurent.fr",
|
||||
"Failed to add {{name}} in the team": "Impossible d'ajouter {{name}} au groupe",
|
||||
"Failed to create the invitation for {{email}}": "Impossible de créer l'invitation pour {{email}}",
|
||||
"Filter member list": "Filtrer la liste des membres",
|
||||
"Find a member to add to the team": "Trouver un membre à ajouter au groupe",
|
||||
"First name": "Prénom",
|
||||
"Freedom Equality Fraternity Logo": "Logo Liberté Égalité Fraternité",
|
||||
"French Interministerial Directorate for Digital Affairs (DINUM), 20 avenue de Ségur 75007 Paris.": "Direction interministérielle des affaires numériques (DINUM), 20 avenue de Segur 75007 Paris.",
|
||||
"Group details": "Détails du groupe",
|
||||
"Group members": "Membres du groupe",
|
||||
"Groups": "Groupes",
|
||||
"If you are unable to access content or a service, you can contact the manager of La Régie (Suite Territoriale) \n to be directed to an accessible alternative or obtain the content in another form.": "Si vous n’arrivez pas à accéder à un contenu ou à un service, vous pouvez contacter le responsable de La Régie (Suite Territoriale) pour être orienté vers une alternative accessible ou obtenir le contenu sous une autre forme.",
|
||||
"Illustration:": "Illustration :",
|
||||
|
||||
@@ -16,6 +16,9 @@ test.describe('Team', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user