✨(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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
32
src/frontend/apps/desk/src/utils/__tests__/string.test.ts
Normal file
32
src/frontend/apps/desk/src/utils/__tests__/string.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/frontend/apps/desk/src/utils/index.ts
Normal file
1
src/frontend/apps/desk/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './string';
|
||||
5
src/frontend/apps/desk/src/utils/string.ts
Normal file
5
src/frontend/apps/desk/src/utils/string.ts
Normal 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,}))$/,
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user