(app-desk) integrate grid member action

Integrate the action button dropdown in
the member grid. For the moment it will be
used to update the role of a member.
Manage use cases:
 - Does not display when member's role
 - Does not display when member is an admin
   that wants to update owner role.
This commit is contained in:
Anthony LC
2024-03-08 15:14:20 +01:00
committed by Anthony LC
parent b0d3f73ba2
commit 0648c2e8d3
12 changed files with 413 additions and 9 deletions

View File

@@ -276,6 +276,13 @@ const config = {
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
'primary-text': {
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-active': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
},
secondary: {
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
@@ -309,6 +316,7 @@ const config = {
},
'forms-checkbox': {
'border-radius': '0',
color: 'var(--c--theme--colors--primary-text)',
},
'forms-datepicker': {
'border-radius': '0',

View File

@@ -32,6 +32,7 @@
"@tanstack/react-query-devtools": "5.24.8",
"@testing-library/jest-dom": "6.4.2",
"@testing-library/react": "14.2.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.12",
"@types/luxon": "3.4.2",
"@types/node": "*",

View File

@@ -329,6 +329,22 @@ input:-webkit-autofill:focus {
border-color: var(--c--components--button--primary--border--color-active);
}
.c__button--primary-text:active,
.c__button--primary-text.c__button--active {
border: none;
background-color: var(
--c--components--button--primary-text--background--color-active
);
}
.c__button--primary-text:hover,
.c__button--primary-text:focus-visible {
background-color: var(
--c--components--button--primary-text--background--color-hover
);
color: var(--c--components--button--primary-text--color-hover);
}
.c__button:disabled {
background-color: var(--c--components--button--disabled--background--color);
color: var(--c--components--button--disabled--color);

View File

@@ -409,6 +409,15 @@
--c--components--button--primary--color: #fff;
--c--components--button--primary--color-hover: #fff;
--c--components--button--primary--color-active: #fff;
--c--components--button--primary-text--background--color-hover: var(
--c--theme--colors--primary-100
);
--c--components--button--primary-text--background--color-active: var(
--c--theme--colors--primary-100
);
--c--components--button--primary-text--color-hover: var(
--c--theme--colors--primary-text
);
--c--components--button--secondary--background--color-hover: var(
--c--theme--colors--primary-100
);
@@ -438,6 +447,7 @@
--c--theme--colors--primary-300
);
--c--components--forms-checkbox--border-radius: 0;
--c--components--forms-checkbox--color: var(--c--theme--colors--primary-text);
--c--components--forms-datepicker--border-radius: 0;
--c--components--forms-fileuploader--border-radius: 0;
--c--components--forms-input--border-radius: 4px;

View File

@@ -415,6 +415,13 @@ export const tokens = {
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
'primary-text': {
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-active': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
},
secondary: {
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
@@ -444,7 +451,10 @@ export const tokens = {
'background-color-active': 'var(--c--theme--colors--primary-300)',
},
},
'forms-checkbox': { 'border-radius': '0' },
'forms-checkbox': {
'border-radius': '0',
color: 'var(--c--theme--colors--primary-text)',
},
'forms-datepicker': { 'border-radius': '0' },
'forms-fileuploader': { 'border-radius': '0' },
'forms-input': {

View File

@@ -0,0 +1,75 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { AppWrapper } from '@/tests/utils';
import { Access, Role } from '../api';
import { MemberAction } from '../components/Member/MemberAction';
const access: Access = {
id: '789',
role: Role.ADMIN,
user: {
id: '11',
name: 'username1',
email: 'user1@test.com',
},
abilities: {
set_role_to: [Role.MEMBER, Role.ADMIN],
} as any,
};
describe('MemberAction', () => {
afterEach(() => {
fetchMock.restore();
});
it('checks the render when owner', async () => {
render(
<MemberAction access={access} currentRole={Role.OWNER} teamId="123" />,
{
wrapper: AppWrapper,
},
);
expect(await screen.findByLabelText('Member options')).toBeInTheDocument();
});
it('checks the render when member', () => {
render(
<MemberAction access={access} currentRole={Role.MEMBER} teamId="123" />,
{
wrapper: AppWrapper,
},
);
expect(screen.queryByLabelText('Member options')).not.toBeInTheDocument();
});
it('checks the render when admin', async () => {
render(
<MemberAction access={access} currentRole={Role.ADMIN} teamId="123" />,
{
wrapper: AppWrapper,
},
);
expect(await screen.findByLabelText('Member options')).toBeInTheDocument();
});
it('checks the render when admin to owner', () => {
render(
<MemberAction
access={{ ...access, role: Role.OWNER }}
currentRole={Role.ADMIN}
teamId="123"
/>,
{
wrapper: AppWrapper,
},
);
expect(screen.queryByLabelText('Member options')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,199 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { AppWrapper } from '@/tests/utils';
import { Access, Role } from '../api';
import { MemberGrid } from '../components/Member/MemberGrid';
describe('MemberGrid', () => {
afterEach(() => {
fetchMock.restore();
});
it('renders with no member to display', async () => {
fetchMock.mock(`/api/teams/123456/accesses/?page=1`, {
count: 0,
results: [],
});
render(<MemberGrid teamId="123456" currentRole={Role.ADMIN} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByRole('img')).toHaveAttribute(
'alt',
'Illustration of an empty table',
);
expect(screen.getByText('This table is empty')).toBeInTheDocument();
});
it('checks the render with members', async () => {
const accesses: Access[] = [
{
id: '1',
role: Role.OWNER,
user: {
id: '11',
name: 'username1',
email: 'user1@test.com',
},
abilities: {} as any,
},
{
id: '2',
role: Role.MEMBER,
user: {
id: '22',
name: 'username2',
email: 'user2@test.com',
},
abilities: {} as any,
},
{
id: '32',
role: Role.ADMIN,
user: {
id: '33',
name: 'username3',
email: 'user3@test.com',
},
abilities: {} as any,
},
];
fetchMock.mock(`/api/teams/123456/accesses/?page=1`, {
count: 3,
results: accesses,
});
render(<MemberGrid teamId="123456" currentRole={Role.ADMIN} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByText('username1')).toBeInTheDocument();
expect(screen.getByText('username2')).toBeInTheDocument();
expect(screen.getByText('username3')).toBeInTheDocument();
expect(screen.getByText('user1@test.com')).toBeInTheDocument();
expect(screen.getByText('user2@test.com')).toBeInTheDocument();
expect(screen.getByText('user3@test.com')).toBeInTheDocument();
expect(screen.getByText(Role.OWNER)).toBeInTheDocument();
expect(screen.getByText(Role.ADMIN)).toBeInTheDocument();
expect(screen.getByText(Role.MEMBER)).toBeInTheDocument();
});
it('checks the pagination', async () => {
fetchMock.get(`begin:/api/teams/123456/accesses/?page=`, {
count: 40,
results: Array.from({ length: 20 }, (_, i) => ({
id: i,
role: Role.OWNER,
user: {
id: i,
name: 'username' + i,
email: `user${i}@test.com`,
},
abilities: {} as any,
})),
});
render(<MemberGrid teamId="123456" currentRole={Role.ADMIN} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(fetchMock.lastUrl()).toBe('/api/teams/123456/accesses/?page=1');
expect(
await screen.findByLabelText('You are currently on page 1'),
).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Go to page 2'));
expect(
await screen.findByLabelText('You are currently on page 2'),
).toBeInTheDocument();
expect(fetchMock.lastUrl()).toBe('/api/teams/123456/accesses/?page=2');
});
[
{
role: Role.OWNER,
expected: true,
},
{
role: Role.MEMBER,
expected: false,
},
{
role: Role.ADMIN,
expected: true,
},
].forEach(({ role, expected }) => {
it(`checks action button when ${role}`, async () => {
fetchMock.get(`begin:/api/teams/123456/accesses/?page=`, {
count: 1,
results: [
{
id: 1,
role: Role.ADMIN,
user: {
id: 1,
name: 'username1',
email: `user1@test.com`,
},
abilities: {} as any,
},
],
});
render(<MemberGrid teamId="123456" currentRole={role} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
/* eslint-disable jest/no-conditional-expect */
if (expected) {
expect(
await screen.findAllByRole('button', {
name: 'Member options',
}),
).toBeDefined();
} else {
expect(
screen.queryByRole('button', {
name: 'Member options',
}),
).not.toBeInTheDocument();
}
/* eslint-enable jest/no-conditional-expect */
});
});
it('controls the render when api error', async () => {
fetchMock.mock(`/api/teams/123456/accesses/?page=1`, {
status: 500,
body: {
cause: 'All broken :(',
},
});
render(<MemberGrid teamId="123456" currentRole={Role.ADMIN} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByText('All broken :(')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,59 @@
import { Button } from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DropButton, Text } from '@/components';
import { Access, Role } from '@/features/teams/api';
interface MemberActionProps {
access: Access;
currentRole: Role;
teamId: string;
}
export const MemberAction = ({
access,
currentRole,
teamId,
}: MemberActionProps) => {
const { t } = useTranslation();
const [isDropOpen, setIsDropOpen] = useState(false);
if (
currentRole === Role.MEMBER ||
(access.role === Role.OWNER && currentRole === Role.ADMIN)
) {
return null;
}
return (
<>
<DropButton
button={
<span
aria-label={t('Member options')}
className="material-icons"
style={{
transition: 'all 0.3s ease-in-out',
transform: `rotate(${isDropOpen ? '90' : '0'}deg)`,
}}
>
more_vert
</span>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Button
onClick={() => {
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">edit</span>}
>
<Text $theme="primary">{t('Update the role')}</Text>
</Button>
</DropButton>
</>
);
};

View File

@@ -5,23 +5,25 @@ 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, useTeamAccesses } from '@/features/teams/api/';
import { Role, useTeamAccesses } from '@/features/teams/api/';
import { PAGE_SIZE } from '@/features/teams/conf';
import { MemberAction } from './MemberAction';
interface MemberGridProps {
team: Team;
teamId: string;
currentRole: Role;
}
export const MemberGrid = ({ team }: MemberGridProps) => {
export const MemberGrid = ({ teamId, currentRole }: MemberGridProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const pagination = usePagination({
pageSize: PAGE_SIZE,
});
const { page, pageSize, setPagesCount } = pagination;
const { data, isLoading, error } = useTeamAccesses({
teamId: team.id,
teamId: teamId,
page,
});
@@ -79,6 +81,18 @@ export const MemberGrid = ({ team }: MemberGridProps) => {
field: 'role',
headerName: t('Roles'),
},
{
id: 'column-actions',
renderCell: ({ row }) => {
return (
<MemberAction
teamId={teamId}
access={row}
currentRole={currentRole}
/>
);
},
},
]}
rows={accesses || []}
isLoading={isLoading}

View File

@@ -28,6 +28,7 @@
"Names": "Noms",
"Emails": "Emails",
"Roles": "Rôles",
"Member options": "Options des Membres",
"Sort the teams": "Trier les groupes",
"Sort teams icon": "Icône trier les groupes",
"Add a team": "Ajouter un groupe",

View File

@@ -5,7 +5,7 @@ import { ReactElement } from 'react';
import { Box } from '@/components';
import { TextErrors } from '@/components/TextErrors';
import { MemberGrid, TeamInfo, useTeam } from '@/features/teams/';
import { MemberGrid, Role, TeamInfo, useTeam } from '@/features/teams/';
import { NextPageWithLayout } from '@/types/next';
import TeamLayout from './TeamLayout';
@@ -47,10 +47,16 @@ const Team = ({ id }: TeamProps) => {
);
}
const currentRole = team.abilities.delete
? Role.OWNER
: team.abilities.manage_accesses
? Role.ADMIN
: Role.MEMBER;
return (
<>
<TeamInfo team={team} />
<MemberGrid team={team} />
<MemberGrid teamId={team.id} currentRole={currentRole} />
</>
);
};

View File

@@ -3059,6 +3059,11 @@
"@testing-library/dom" "^9.0.0"
"@types/react-dom" "^18.0.0"
"@testing-library/user-event@14.5.2":
version "14.5.2"
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.2.tgz#db7257d727c891905947bd1c1a99da20e03c2ebd"
integrity sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==
"@tootallnate/once@2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
@@ -8895,7 +8900,7 @@ typed-array-length@^1.0.5:
is-typed-array "^1.1.13"
possible-typed-array-names "^1.0.0"
typescript@*, typescript@5.3.3, typescript@^5.0.4:
typescript@*, typescript@^5.0.4:
version "5.3.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==