(app-desk) integrate multiselect search users

Integrate multiselect search users in the
modal add members.
We are using react-select to implement the
multiselect search users. We are using this
library in waiting for Cunningham to implement
the multiselect asynch component.
This commit is contained in:
Anthony LC
2024-03-14 15:44:20 +01:00
committed by Anthony LC
parent a48dbde0ea
commit b8427d865f
12 changed files with 1104 additions and 638 deletions

View File

@@ -25,6 +25,7 @@
"react-aria-components": "1.1.1",
"react-dom": "18.2.0",
"react-i18next": "14.1.0",
"react-select": "5.8.0",
"styled-components": "6.1.8",
"zustand": "4.5.2"
},

View File

@@ -3,11 +3,17 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { Team } from '@/features/teams/api';
import { AppWrapper } from '@/tests/utils';
import { MemberGrid } from '../components/MemberGrid';
import { Access, Role } from '../types';
const team = {
id: '123456',
name: 'teamName',
} as Team;
describe('MemberGrid', () => {
afterEach(() => {
fetchMock.restore();
@@ -19,7 +25,7 @@ describe('MemberGrid', () => {
results: [],
});
render(<MemberGrid teamId="123456" currentRole={Role.ADMIN} />, {
render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
wrapper: AppWrapper,
});
@@ -72,7 +78,7 @@ describe('MemberGrid', () => {
results: accesses,
});
render(<MemberGrid teamId="123456" currentRole={Role.ADMIN} />, {
render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
wrapper: AppWrapper,
});
@@ -104,7 +110,7 @@ describe('MemberGrid', () => {
})),
});
render(<MemberGrid teamId="123456" currentRole={Role.ADMIN} />, {
render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
wrapper: AppWrapper,
});
@@ -156,7 +162,7 @@ describe('MemberGrid', () => {
],
});
render(<MemberGrid teamId="123456" currentRole={role} />, {
render(<MemberGrid team={team} currentRole={role} />, {
wrapper: AppWrapper,
});
@@ -188,7 +194,7 @@ describe('MemberGrid', () => {
},
});
render(<MemberGrid teamId="123456" currentRole={Role.ADMIN} />, {
render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
wrapper: AppWrapper,
});

View File

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import IconUser from '@/assets/icons/icon-user.svg';
import { Box, Card, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Team } from '@/features/teams/api';
import { useTeamAccesses } from '../api/useTeamsAccesses';
import { PAGE_SIZE } from '../conf';
@@ -14,11 +15,11 @@ import { MemberAction } from './MemberAction';
import { ModalAddMembers } from './ModalAddMembers';
interface MemberGridProps {
teamId: string;
team: Team;
currentRole: Role;
}
export const MemberGrid = ({ teamId, currentRole }: MemberGridProps) => {
export const MemberGrid = ({ team, currentRole }: MemberGridProps) => {
const [isModalMemberOpen, setIsModalMemberOpen] = useState(false);
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
@@ -27,7 +28,7 @@ export const MemberGrid = ({ teamId, currentRole }: MemberGridProps) => {
});
const { page, pageSize, setPagesCount } = pagination;
const { data, isLoading, error } = useTeamAccesses({
teamId: teamId,
teamId: team.id,
page,
});
@@ -113,7 +114,7 @@ export const MemberGrid = ({ teamId, currentRole }: MemberGridProps) => {
renderCell: ({ row }) => {
return (
<MemberAction
teamId={teamId}
teamId={team.id}
access={row}
currentRole={currentRole}
/>
@@ -127,7 +128,10 @@ export const MemberGrid = ({ teamId, currentRole }: MemberGridProps) => {
/>
</Card>
{isModalMemberOpen && (
<ModalAddMembers onClose={() => setIsModalMemberOpen(false)} />
<ModalAddMembers
onClose={() => setIsModalMemberOpen(false)}
team={team}
/>
)}
</>
);

View File

@@ -1,12 +1,27 @@
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box } from '@/components';
import { Team } from '@/features/teams';
import { OptionSelect, SearchMembers } from './SearchMembers';
const GlobalStyle = createGlobalStyle`
.c__modal {
overflow: visible;
}
`;
interface ModalAddMembersProps {
onClose: () => void;
team: Team;
}
export const ModalAddMembers = ({ onClose }: ModalAddMembersProps) => {
export const ModalAddMembers = ({ onClose, team }: ModalAddMembersProps) => {
const { t } = useTranslation();
const [, setSelectedMembers] = useState<OptionSelect>([]);
return (
<Modal
@@ -26,6 +41,11 @@ export const ModalAddMembers = ({ onClose }: ModalAddMembersProps) => {
}
size={ModalSize.MEDIUM}
title={t('Add members to the team')}
></Modal>
>
<GlobalStyle />
<Box className="mb-xl mt-l">
<SearchMembers team={team} setSelectedMembers={setSelectedMembers} />
</Box>
</Modal>
);
};

View File

@@ -0,0 +1,107 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Options } from 'react-select';
import AsyncSelect from 'react-select/async';
import { User } from '@/features/auth';
import { Team } from '@/features/teams';
import { isValidEmail } from '@/utils';
import { KEY_LIST_USER, useUsers } from '../api/useUsers';
export type OptionSelect = Options<{
value: Partial<User> & { email: User['email'] };
label: string;
}>;
interface SearchMembersProps {
team: Team;
setSelectedMembers: (value: OptionSelect) => void;
}
export const SearchMembers = ({
team,
setSelectedMembers,
}: SearchMembersProps) => {
const { t } = useTranslation();
const [input, setInput] = useState('');
const [userQuery, setUserQuery] = useState('');
const resolveOptionsRef = useRef<((value: OptionSelect) => void) | null>(
null,
);
const { data } = useUsers(
{ query: userQuery },
{
enabled: !!userQuery,
queryKey: [KEY_LIST_USER, { query: userQuery }],
},
);
const options = data?.results;
useEffect(() => {
if (!resolveOptionsRef.current || !options) {
return;
}
let users: OptionSelect = options.map((user) => ({
value: user,
label: user.name || '',
}));
if (userQuery && isValidEmail(userQuery)) {
const isFound = !!options.find((user) => user.email === userQuery);
if (!isFound) {
users = [
{
value: { email: userQuery },
label: userQuery,
},
];
}
}
resolveOptionsRef.current(users);
resolveOptionsRef.current = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);
const loadOptions = (): Promise<OptionSelect> => {
return new Promise<OptionSelect>((resolve) => {
resolveOptionsRef.current = resolve;
});
};
const timeout = useRef<NodeJS.Timeout | null>(null);
const onInputChangeHandle = useCallback((newValue: string) => {
setInput(newValue);
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(() => {
setUserQuery(newValue);
}, 1000);
}, []);
return (
<AsyncSelect
aria-label={t('Find a member to add to the team')}
isMulti
loadOptions={loadOptions}
defaultOptions={[]}
onInputChange={onInputChangeHandle}
inputValue={input}
placeholder={t('Search new members (name or email)')}
noOptionsMessage={() =>
t('Invite new members to {{teamName}}', { teamName: team.name })
}
onChange={(value) => {
setInput('');
setUserQuery('');
setSelectedMembers(value);
}}
/>
);
};

View File

@@ -23,8 +23,10 @@
"Emails": "Emails",
"Empty teams icon": "Icône de groupe vide",
"Favorite": "Favoris",
"Find a member to add to the team": "Trouver un membre à ajouter au groupe",
"Freedom Equality Fraternity Logo": "Logo Liberté Égalité Fraternité",
"Groups": "Groupes",
"Invite new members to {{teamName}}": "Invitez de nouveaux membres à rejoindre {{teamName}}",
"Language": "Langue",
"Language Icon": "Icône de langue",
"Last update at": "Dernière modification le",
@@ -45,6 +47,7 @@
"Recents": "Récents",
"Roles": "Rôles",
"Search": "Rechercher",
"Search new members (name or email)": "Rechercher de nouveaux membres (nom ou email)",
"Something bad happens, please refresh the page.": "Une erreur inattendue s'est produite, rechargez la page.",
"Something bad happens, please retry.": "Une erreur inattendue s'est produite, rechargez la page.",
"Sort teams icon": "Icône trier les groupes",

View File

@@ -0,0 +1,32 @@
import '@testing-library/jest-dom';
import { isValidEmail } from '../string';
describe('isValidEmail', () => {
[
{
email: 'test',
expected: false,
},
{
email: 'test@',
expected: false,
},
{
email: 'test@test',
expected: false,
},
{
email: 'test@test.',
expected: false,
},
{
email: 'test@test.test',
expected: true,
},
].forEach(({ email, expected }) => {
it(`asserts that email "${email}" is ${expected ? '' : 'not '}valid `, () => {
expect(isValidEmail(email)).toBe(expected);
});
});
});

View File

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

View File

@@ -0,0 +1,5 @@
export const isValidEmail = (email: string) => {
return !!email.match(
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
);
};

View File

@@ -17,7 +17,52 @@ test.describe('Members Create', () => {
await page.getByLabel('Add members to the team').click();
await expect(page.getByText('Add members to the team')).toBeVisible();
await expect(
page.getByLabel(/Find a member to add to the team/),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Validate' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible();
});
test('it selects 2 users', async ({ page, browserName }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/users/?q=test') && response.status() === 200,
);
await createTeam(page, 'member-modal-search-user', browserName, 1);
await page.getByLabel('Add members to the team').click();
const inputSearch = page.getByLabel(/Find a member to add to the team/);
for (let i = 0; i < 2; i++) {
await inputSearch.fill('test');
const response = await responsePromise;
const users = (await response.json()).results as {
name: string;
}[];
await page.getByText(users[i].name).click();
await expect(
page.getByText(`${users[i].name}`, { exact: true }),
).toBeVisible();
await expect(page.getByLabel(`Remove ${users[i].name}`)).toBeVisible();
}
});
test('it selects non existing email', async ({ page, browserName }) => {
await createTeam(page, 'member-modal-search-user', browserName, 1);
await page.getByLabel('Add members to the team').click();
const inputSearch = page.getByLabel(/Find a member to add to the team/);
await inputSearch.fill('test@test.fr');
await page.getByRole('option', { name: 'test@test.fr' }).click();
await expect(page.getByText('test@test.fr', { exact: true })).toBeVisible();
await expect(page.getByLabel(`Remove test@test.fr`)).toBeVisible();
});
});

View File

@@ -19,6 +19,9 @@ module.exports = {
files: ['*.spec.*', '*.test.*', '**/__mock__/**/*'],
extends: ['plugin:playwright/recommended'],
plugins: ['playwright'],
rules: {
'@typescript-eslint/no-unsafe-member-access': 'off',
},
},
],
ignorePatterns: ['node_modules'],

File diff suppressed because it is too large Load Diff