(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-aria-components": "1.1.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-i18next": "14.1.0", "react-i18next": "14.1.0",
"react-select": "5.8.0",
"styled-components": "6.1.8", "styled-components": "6.1.8",
"zustand": "4.5.2" "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 userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
import { Team } from '@/features/teams/api';
import { AppWrapper } from '@/tests/utils'; import { AppWrapper } from '@/tests/utils';
import { MemberGrid } from '../components/MemberGrid'; import { MemberGrid } from '../components/MemberGrid';
import { Access, Role } from '../types'; import { Access, Role } from '../types';
const team = {
id: '123456',
name: 'teamName',
} as Team;
describe('MemberGrid', () => { describe('MemberGrid', () => {
afterEach(() => { afterEach(() => {
fetchMock.restore(); fetchMock.restore();
@@ -19,7 +25,7 @@ describe('MemberGrid', () => {
results: [], results: [],
}); });
render(<MemberGrid teamId="123456" currentRole={Role.ADMIN} />, { render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -72,7 +78,7 @@ describe('MemberGrid', () => {
results: accesses, results: accesses,
}); });
render(<MemberGrid teamId="123456" currentRole={Role.ADMIN} />, { render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -104,7 +110,7 @@ describe('MemberGrid', () => {
})), })),
}); });
render(<MemberGrid teamId="123456" currentRole={Role.ADMIN} />, { render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -156,7 +162,7 @@ describe('MemberGrid', () => {
], ],
}); });
render(<MemberGrid teamId="123456" currentRole={role} />, { render(<MemberGrid team={team} currentRole={role} />, {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -188,7 +194,7 @@ describe('MemberGrid', () => {
}, },
}); });
render(<MemberGrid teamId="123456" currentRole={Role.ADMIN} />, { render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });

View File

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import IconUser from '@/assets/icons/icon-user.svg'; import IconUser from '@/assets/icons/icon-user.svg';
import { Box, Card, TextErrors } from '@/components'; import { Box, Card, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { Team } from '@/features/teams/api';
import { useTeamAccesses } from '../api/useTeamsAccesses'; import { useTeamAccesses } from '../api/useTeamsAccesses';
import { PAGE_SIZE } from '../conf'; import { PAGE_SIZE } from '../conf';
@@ -14,11 +15,11 @@ import { MemberAction } from './MemberAction';
import { ModalAddMembers } from './ModalAddMembers'; import { ModalAddMembers } from './ModalAddMembers';
interface MemberGridProps { interface MemberGridProps {
teamId: string; team: Team;
currentRole: Role; currentRole: Role;
} }
export const MemberGrid = ({ teamId, currentRole }: MemberGridProps) => { export const MemberGrid = ({ team, currentRole }: MemberGridProps) => {
const [isModalMemberOpen, setIsModalMemberOpen] = useState(false); const [isModalMemberOpen, setIsModalMemberOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme(); const { colorsTokens } = useCunninghamTheme();
@@ -27,7 +28,7 @@ export const MemberGrid = ({ teamId, currentRole }: MemberGridProps) => {
}); });
const { page, pageSize, setPagesCount } = pagination; const { page, pageSize, setPagesCount } = pagination;
const { data, isLoading, error } = useTeamAccesses({ const { data, isLoading, error } = useTeamAccesses({
teamId: teamId, teamId: team.id,
page, page,
}); });
@@ -113,7 +114,7 @@ export const MemberGrid = ({ teamId, currentRole }: MemberGridProps) => {
renderCell: ({ row }) => { renderCell: ({ row }) => {
return ( return (
<MemberAction <MemberAction
teamId={teamId} teamId={team.id}
access={row} access={row}
currentRole={currentRole} currentRole={currentRole}
/> />
@@ -127,7 +128,10 @@ export const MemberGrid = ({ teamId, currentRole }: MemberGridProps) => {
/> />
</Card> </Card>
{isModalMemberOpen && ( {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 { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { interface ModalAddMembersProps {
onClose: () => void; onClose: () => void;
team: Team;
} }
export const ModalAddMembers = ({ onClose }: ModalAddMembersProps) => { export const ModalAddMembers = ({ onClose, team }: ModalAddMembersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [, setSelectedMembers] = useState<OptionSelect>([]);
return ( return (
<Modal <Modal
@@ -26,6 +41,11 @@ export const ModalAddMembers = ({ onClose }: ModalAddMembersProps) => {
} }
size={ModalSize.MEDIUM} size={ModalSize.MEDIUM}
title={t('Add members to the team')} 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", "Emails": "Emails",
"Empty teams icon": "Icône de groupe vide", "Empty teams icon": "Icône de groupe vide",
"Favorite": "Favoris", "Favorite": "Favoris",
"Find a member to add to the team": "Trouver un membre à ajouter au groupe",
"Freedom Equality Fraternity Logo": "Logo Liberté Égalité Fraternité", "Freedom Equality Fraternity Logo": "Logo Liberté Égalité Fraternité",
"Groups": "Groupes", "Groups": "Groupes",
"Invite new members to {{teamName}}": "Invitez de nouveaux membres à rejoindre {{teamName}}",
"Language": "Langue", "Language": "Langue",
"Language Icon": "Icône de langue", "Language Icon": "Icône de langue",
"Last update at": "Dernière modification le", "Last update at": "Dernière modification le",
@@ -45,6 +47,7 @@
"Recents": "Récents", "Recents": "Récents",
"Roles": "Rôles", "Roles": "Rôles",
"Search": "Rechercher", "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 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.", "Something bad happens, please retry.": "Une erreur inattendue s'est produite, rechargez la page.",
"Sort teams icon": "Icône trier les groupes", "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 page.getByLabel('Add members to the team').click();
await expect(page.getByText('Add members to the team')).toBeVisible(); 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: 'Validate' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Cancel' })).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__/**/*'], files: ['*.spec.*', '*.test.*', '**/__mock__/**/*'],
extends: ['plugin:playwright/recommended'], extends: ['plugin:playwright/recommended'],
plugins: ['playwright'], plugins: ['playwright'],
rules: {
'@typescript-eslint/no-unsafe-member-access': 'off',
},
}, },
], ],
ignorePatterns: ['node_modules'], ignorePatterns: ['node_modules'],

File diff suppressed because it is too large Load Diff