diff --git a/src/frontend/apps/desk/cunningham.ts b/src/frontend/apps/desk/cunningham.ts
index 238c3f7..484b10f 100644
--- a/src/frontend/apps/desk/cunningham.ts
+++ b/src/frontend/apps/desk/cunningham.ts
@@ -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',
diff --git a/src/frontend/apps/desk/package.json b/src/frontend/apps/desk/package.json
index b1d995e..8772751 100644
--- a/src/frontend/apps/desk/package.json
+++ b/src/frontend/apps/desk/package.json
@@ -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": "*",
diff --git a/src/frontend/apps/desk/src/cunningham/cunningham-style.css b/src/frontend/apps/desk/src/cunningham/cunningham-style.css
index b8fdf40..6793987 100644
--- a/src/frontend/apps/desk/src/cunningham/cunningham-style.css
+++ b/src/frontend/apps/desk/src/cunningham/cunningham-style.css
@@ -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);
diff --git a/src/frontend/apps/desk/src/cunningham/cunningham-tokens.css b/src/frontend/apps/desk/src/cunningham/cunningham-tokens.css
index 7a88966..7005796 100644
--- a/src/frontend/apps/desk/src/cunningham/cunningham-tokens.css
+++ b/src/frontend/apps/desk/src/cunningham/cunningham-tokens.css
@@ -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;
diff --git a/src/frontend/apps/desk/src/cunningham/cunningham-tokens.ts b/src/frontend/apps/desk/src/cunningham/cunningham-tokens.ts
index 84b7949..4551568 100644
--- a/src/frontend/apps/desk/src/cunningham/cunningham-tokens.ts
+++ b/src/frontend/apps/desk/src/cunningham/cunningham-tokens.ts
@@ -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': {
diff --git a/src/frontend/apps/desk/src/features/teams/__tests__/MemberAction.test.tsx b/src/frontend/apps/desk/src/features/teams/__tests__/MemberAction.test.tsx
new file mode 100644
index 0000000..b34f1dc
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/teams/__tests__/MemberAction.test.tsx
@@ -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(
+ ,
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ expect(await screen.findByLabelText('Member options')).toBeInTheDocument();
+ });
+
+ it('checks the render when member', () => {
+ render(
+ ,
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ expect(screen.queryByLabelText('Member options')).not.toBeInTheDocument();
+ });
+
+ it('checks the render when admin', async () => {
+ render(
+ ,
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ expect(await screen.findByLabelText('Member options')).toBeInTheDocument();
+ });
+
+ it('checks the render when admin to owner', () => {
+ render(
+ ,
+ {
+ wrapper: AppWrapper,
+ },
+ );
+
+ expect(screen.queryByLabelText('Member options')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/frontend/apps/desk/src/features/teams/__tests__/MemberGrid.test.tsx b/src/frontend/apps/desk/src/features/teams/__tests__/MemberGrid.test.tsx
new file mode 100644
index 0000000..99838d1
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/teams/__tests__/MemberGrid.test.tsx
@@ -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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ wrapper: AppWrapper,
+ });
+
+ expect(screen.getByRole('status')).toBeInTheDocument();
+
+ expect(await screen.findByText('All broken :(')).toBeInTheDocument();
+ });
+});
diff --git a/src/frontend/apps/desk/src/features/teams/components/Member/MemberAction.tsx b/src/frontend/apps/desk/src/features/teams/components/Member/MemberAction.tsx
new file mode 100644
index 0000000..cc4e775
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/teams/components/Member/MemberAction.tsx
@@ -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 (
+ <>
+
+ more_vert
+
+ }
+ onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
+ isOpen={isDropOpen}
+ >
+
+
+ >
+ );
+};
diff --git a/src/frontend/apps/desk/src/features/teams/components/Member/MemberGrid.tsx b/src/frontend/apps/desk/src/features/teams/components/Member/MemberGrid.tsx
index 615c0a5..124e9ec 100644
--- a/src/frontend/apps/desk/src/features/teams/components/Member/MemberGrid.tsx
+++ b/src/frontend/apps/desk/src/features/teams/components/Member/MemberGrid.tsx
@@ -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 (
+
+ );
+ },
+ },
]}
rows={accesses || []}
isLoading={isLoading}
diff --git a/src/frontend/apps/desk/src/i18n/translations.json b/src/frontend/apps/desk/src/i18n/translations.json
index f6069e8..b88aad1 100644
--- a/src/frontend/apps/desk/src/i18n/translations.json
+++ b/src/frontend/apps/desk/src/i18n/translations.json
@@ -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",
diff --git a/src/frontend/apps/desk/src/pages/teams/[id].tsx b/src/frontend/apps/desk/src/pages/teams/[id].tsx
index 8bbdee7..6ec6e98 100644
--- a/src/frontend/apps/desk/src/pages/teams/[id].tsx
+++ b/src/frontend/apps/desk/src/pages/teams/[id].tsx
@@ -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 (
<>
-
+
>
);
};
diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock
index 2e97487..12174d8 100644
--- a/src/frontend/yarn.lock
+++ b/src/frontend/yarn.lock
@@ -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==