✨(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-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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
"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",
|
||||||
|
|||||||
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 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user