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==