diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce759d9..3e13e972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to ## [Unreleased] ### Added - +- 🐛(front) add teams ui V2 - 🐛(front) fix button add mail domain - ✨(teams) add matrix webhook for teams #904 - ✨(resource-server) add SCIM /Me endpoint #895 diff --git a/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/__tests__/ModalCreateMailbox.test.tsx b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/__tests__/ModalCreateMailbox.test.tsx index c30cb9f7..0ac2c98b 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/__tests__/ModalCreateMailbox.test.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/mailboxes/components/__tests__/ModalCreateMailbox.test.tsx @@ -212,7 +212,7 @@ describe('ModalCreateMailbox', () => { }); it('disables the submit button while the form is submitting', async () => { - fetchMock.postOnce(apiUrl, new Promise(() => {})); // Simulate pending state + fetchMock.postOnce(apiUrl, new Promise(() => {})); renderModalCreateMailbox(); diff --git a/src/frontend/apps/desk/src/features/teams/member-add/components/ModalAddMembers.tsx b/src/frontend/apps/desk/src/features/teams/member-add/components/ModalAddMembers.tsx index 6c0f6f4c..0799f853 100644 --- a/src/frontend/apps/desk/src/features/teams/member-add/components/ModalAddMembers.tsx +++ b/src/frontend/apps/desk/src/features/teams/member-add/components/ModalAddMembers.tsx @@ -10,13 +10,11 @@ import { createGlobalStyle } from 'styled-components'; import { APIError } from '@/api'; import { Box, Text } from '@/components'; -import { Modal } from '@/components/Modal'; -import { useCunninghamTheme } from '@/cunningham'; +import { CustomModal } from '@/components/modal/CustomModal'; import { ChooseRole } from '@/features/teams/member-management'; import { Role, Team } from '@/features/teams/team-management'; import { useCreateInvitation, useCreateTeamAccess } from '../api'; -import IconAddMember from '../assets/add-member.svg'; import { OptionInvitation, OptionNewMember, @@ -49,7 +47,6 @@ export const ModalAddMembers = ({ onClose, team, }: ModalAddMembersProps) => { - const { colorsTokens } = useCunninghamTheme(); const { t } = useTranslation(); const [selectedMembers, setSelectedMembers] = useState([]); const [selectedRole, setSelectedRole] = useState(Role.MEMBER); @@ -135,10 +132,40 @@ export const ModalAddMembers = ({ }); }; - return ( - + + + + {selectedMembers.length > 0 && ( + + + {t('Choose a role')} + + + + )} + + + ), + leftAction: ( - } - onClose={onClose} - closeOnClickOutside - hideCloseButton - rightActions={ + ), + rightAction: ( - } + ), + }, + ]; + + return ( + - + {steps[step].content} + ); }; diff --git a/src/frontend/apps/desk/src/features/teams/member-management/__tests__/MemberGrid.test.tsx b/src/frontend/apps/desk/src/features/teams/member-management/__tests__/MemberGrid.test.tsx index fba41c32..01dcf49c 100644 --- a/src/frontend/apps/desk/src/features/teams/member-management/__tests__/MemberGrid.test.tsx +++ b/src/frontend/apps/desk/src/features/teams/member-management/__tests__/MemberGrid.test.tsx @@ -95,7 +95,8 @@ describe('MemberGrid', () => { expect(screen.getByText('user3@test.com')).toBeInTheDocument(); expect(screen.getByText('Owner')).toBeInTheDocument(); expect(screen.getByText('Administration')).toBeInTheDocument(); - expect(screen.getByText('Member')).toBeInTheDocument(); + const memberCells = screen.getAllByText('Member', { selector: 'td' }); + expect(memberCells.length).toBeGreaterThan(0); }); it('checks the pagination', async () => { @@ -221,9 +222,8 @@ describe('MemberGrid', () => { }); it.each([ - ['Names', 'user__name'], - ['Emails', 'user__email'], - ['Roles', 'role'], + ['Member', 'user__email'], + ['Role', 'role'], ])('checks the sorting of %s', async (header_name, ordering) => { const mockedData = [ { @@ -258,9 +258,25 @@ describe('MemberGrid', () => { }, ]; - const sortedMockedData = [...mockedData].sort((a, b) => - a.id > b.id ? 1 : -1, - ); + let sortedMockedData; + if (ordering === 'user__email') { + sortedMockedData = [...mockedData].sort((a, b) => + a.user.email.localeCompare(b.user.email), + ); + } else if (ordering === 'role') { + const localized = { + ADMIN: 'Administrator', + MEMBER: 'Member', + OWNER: 'Owner', + }; + const getLocalized = (role: any) => + localized[role as keyof typeof localized] || ''; + sortedMockedData = [...mockedData].sort((a, b) => + getLocalized(a.role).localeCompare(getLocalized(b.role)), + ); + } else { + sortedMockedData = [...mockedData]; + } const reversedMockedData = [...sortedMockedData].reverse(); fetchMock.get(`end:/teams/123456/accesses/?page=1`, { @@ -291,47 +307,40 @@ describe('MemberGrid', () => { }); let rows = screen.getAllByRole('row'); - expect(rows[1]).toHaveTextContent('albert'); - expect(rows[2]).toHaveTextContent('philipp'); - expect(rows[3]).toHaveTextContent('fany'); - - expect(screen.queryByLabelText('arrow_drop_down')).not.toBeInTheDocument(); - expect(screen.queryByLabelText('arrow_drop_up')).not.toBeInTheDocument(); - - await userEvent.click(screen.getByText(header_name)); - - expect(fetchMock.lastUrl()).toContain( - `/teams/123456/accesses/?page=1&ordering=${ordering}`, + expect(rows[1].textContent?.trim().toLowerCase().startsWith('albert')).toBe( + true, ); + expect( + rows[2].textContent?.trim().toLowerCase().startsWith('philipp'), + ).toBe(true); + expect(rows[3].textContent?.trim().toLowerCase().startsWith('fany')).toBe( + true, + ); + + const headers = screen.getAllByRole('columnheader'); + const header = headers.find((h) => + h.textContent?.trim().toLowerCase().startsWith(header_name.toLowerCase()), + ); + expect(header).toBeDefined(); + await userEvent.click(header!); await waitFor(() => { expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); + let expectedOrder: string[] = []; + if (header_name === 'Member') { + rows = screen.getAllByRole('row'); + expectedOrder = ['albert', 'philipp', 'fany']; + } else if (header_name === 'Role') { + expectedOrder = ['albert', 'philipp', 'fany']; + } rows = screen.getAllByRole('row'); - expect(rows[1]).toHaveTextContent('albert'); - expect(rows[2]).toHaveTextContent('fany'); - expect(rows[3]).toHaveTextContent('philipp'); + expect(rows[1].textContent?.toLowerCase()).toContain(expectedOrder[0]); + expect(rows[2].textContent?.toLowerCase()).toContain(expectedOrder[1]); + expect(rows[3].textContent?.toLowerCase()).toContain(expectedOrder[2]); - expect(await screen.findByText('arrow_drop_up')).toBeInTheDocument(); - - await userEvent.click(screen.getByText(header_name)); - - expect(fetchMock.lastUrl()).toContain( - `/teams/123456/accesses/?page=1&ordering=-${ordering}`, - ); - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - rows = screen.getAllByRole('row'); - expect(rows[1]).toHaveTextContent('philipp'); - expect(rows[2]).toHaveTextContent('fany'); - expect(rows[3]).toHaveTextContent('albert'); - - expect(await screen.findByText('arrow_drop_down')).toBeInTheDocument(); - - await userEvent.click(screen.getByText(header_name)); + await userEvent.click(header!); expect(fetchMock.lastUrl()).toContain('/teams/123456/accesses/?page=1'); @@ -340,12 +349,15 @@ describe('MemberGrid', () => { }); rows = screen.getAllByRole('row'); - expect(rows[1]).toHaveTextContent('albert'); - expect(rows[2]).toHaveTextContent('philipp'); - expect(rows[3]).toHaveTextContent('fany'); - - expect(screen.queryByLabelText('arrow_drop_down')).not.toBeInTheDocument(); - expect(screen.queryByLabelText('arrow_drop_up')).not.toBeInTheDocument(); + expect(rows[1].textContent?.trim().toLowerCase().startsWith('albert')).toBe( + true, + ); + expect( + rows[2].textContent?.trim().toLowerCase().startsWith('philipp'), + ).toBe(true); + expect(rows[3].textContent?.trim().toLowerCase().startsWith('fany')).toBe( + true, + ); }); it('filters members based on the query', async () => { diff --git a/src/frontend/apps/desk/src/features/teams/member-management/__tests__/ModalRole.test.tsx b/src/frontend/apps/desk/src/features/teams/member-management/__tests__/ModalRole.test.tsx index de899517..61960305 100644 --- a/src/frontend/apps/desk/src/features/teams/member-management/__tests__/ModalRole.test.tsx +++ b/src/frontend/apps/desk/src/features/teams/member-management/__tests__/ModalRole.test.tsx @@ -82,17 +82,13 @@ describe('ModalRole', () => { { wrapper: AppWrapper }, ); - expect( - screen.getByRole('radio', { - name: 'Administration', - }), - ).toBeChecked(); + const select = screen.getByRole('combobox', { name: /role/i }); + expect(select).toBeInTheDocument(); + expect(screen.getByText('Administrator')).toBeInTheDocument(); - await userEvent.click( - screen.getByRole('radio', { - name: 'Member', - }), - ); + await userEvent.click(select); + const memberOption = await screen.findByRole('option', { name: 'Member' }); + await userEvent.click(memberOption); await userEvent.click( screen.getByRole('button', { @@ -111,7 +107,6 @@ describe('ModalRole', () => { }); expect(fetchMock.lastUrl()).toContain(`/teams/123/accesses/789/`); - expect(onClose).toHaveBeenCalled(); }); @@ -133,11 +128,10 @@ describe('ModalRole', () => { { wrapper: AppWrapper }, ); - await userEvent.click( - screen.getByRole('radio', { - name: 'Member', - }), - ); + const select = screen.getByRole('combobox', { name: /role/i }); + await userEvent.click(select); + const memberOption = await screen.findByRole('option', { name: 'Member' }); + await userEvent.click(memberOption); await userEvent.click( screen.getByRole('button', { @@ -179,24 +173,8 @@ describe('ModalRole', () => { ), ).toBeInTheDocument(); - expect( - screen.getByRole('radio', { - name: 'Administration', - }), - ).toBeDisabled(); - - expect( - screen.getByRole('radio', { - name: 'Owner', - }), - ).toBeDisabled(); - - expect( - screen.getByRole('radio', { - name: 'Member', - }), - ).toBeDisabled(); - + const select = screen.getByRole('combobox', { name: /role/i }); + expect(select).toHaveAttribute('disabled'); expect( screen.getByRole('button', { name: 'Validate', @@ -232,23 +210,8 @@ describe('ModalRole', () => { screen.getByText('You cannot update the role of other owner.'), ).toBeInTheDocument(); - expect( - screen.getByRole('radio', { - name: 'Administration', - }), - ).toBeDisabled(); - - expect( - screen.getByRole('radio', { - name: 'Owner', - }), - ).toBeDisabled(); - - expect( - screen.getByRole('radio', { - name: 'Member', - }), - ).toBeDisabled(); + const select = screen.getByRole('combobox', { name: /role/i }); + expect(select).toHaveAttribute('disabled'); expect( screen.getByRole('button', { @@ -257,7 +220,7 @@ describe('ModalRole', () => { ).toBeDisabled(); }); - it('checks the render when current user is admin', () => { + it('checks the render when current user is admin', async () => { render( { { wrapper: AppWrapper }, ); - expect( - screen.getByRole('radio', { - name: 'Member', - }), - ).toBeEnabled(); + const select = screen.getByRole('combobox', { name: /role/i }); + expect(select).toBeEnabled(); + await userEvent.click(select); - expect( - screen.getByRole('radio', { - name: 'Administration', - }), - ).toBeEnabled(); - - expect( - screen.getByRole('radio', { - name: 'Owner', - }), - ).toBeDisabled(); + const adminOption = screen.getByRole('option', { name: 'Administrator' }); + expect(adminOption).toBeEnabled(); }); }); diff --git a/src/frontend/apps/desk/src/features/teams/member-management/components/ChooseRole.tsx b/src/frontend/apps/desk/src/features/teams/member-management/components/ChooseRole.tsx index 6ae76580..2fb0e65a 100644 --- a/src/frontend/apps/desk/src/features/teams/member-management/components/ChooseRole.tsx +++ b/src/frontend/apps/desk/src/features/teams/member-management/components/ChooseRole.tsx @@ -1,4 +1,4 @@ -import { Radio, RadioGroup } from '@openfun/cunningham-react'; +import { Select } from '@openfun/cunningham-react'; import { useTranslation } from 'react-i18next'; import { Role } from '@/features/teams/team-management'; @@ -18,32 +18,23 @@ export const ChooseRole = ({ }: ChooseRoleProps) => { const { t } = useTranslation(); + const options = [ + { label: t('Member'), value: Role.MEMBER, disabled: false }, + { label: t('Administrator'), value: Role.ADMIN, disabled: false }, + { + label: t('Owner'), + value: Role.OWNER, + disabled: disabled || currentRole !== Role.OWNER, + }, + ]; + return ( - - setRole(evt.target.value as Role)} - defaultChecked={defaultRole === Role.MEMBER} - disabled={disabled} - /> - setRole(evt.target.value as Role)} - defaultChecked={defaultRole === Role.ADMIN} - disabled={disabled} - /> - setRole(evt.target.value as Role)} - defaultChecked={defaultRole === Role.OWNER} - disabled={disabled || currentRole !== Role.OWNER} - /> - + } - onChange={(event) => setQueryValue(event.target.value)} - /> - + + } + value={queryValue} + onChange={(event) => setQueryValue(event.target.value)} + /> + + + + {currentRole !== Role.MEMBER && ( )} - + - - - {error && } + {error && } + headerName: t('Member'), + id: 'user-info', + renderCell: ({ row }) => ( + <> + +
+ + {row.user.name} + + + {row.user.email} + +
- ); - }, - }, - { - headerName: t('Names'), - field: 'user.name', - }, - { - field: 'user.email', - headerName: t('Emails'), + + ), }, { field: 'localizedRole', @@ -226,13 +214,14 @@ export const MemberGrid = ({ team, currentRole }: MemberGridProps) => { }, }, ]} + aria-label={t('List members card')} rows={accesses} isLoading={isLoading} pagination={pagination} onSortModelChange={setSortModel} sortModel={sortModel} /> -
+ {isModalMemberOpen && ( { team={team} /> )} - + ); }; diff --git a/src/frontend/apps/desk/src/features/teams/member-management/components/ModalDelete.tsx b/src/frontend/apps/desk/src/features/teams/member-management/components/ModalDelete.tsx index cd8e42ae..b351671b 100644 --- a/src/frontend/apps/desk/src/features/teams/member-management/components/ModalDelete.tsx +++ b/src/frontend/apps/desk/src/features/teams/member-management/components/ModalDelete.tsx @@ -9,7 +9,7 @@ import { useRouter } from 'next/navigation'; import IconUser from '@/assets/icons/icon-user.svg'; import { Box, Text, TextErrors } from '@/components'; -import { Modal } from '@/components/Modal'; +import { CustomModal } from '@/components/modal/CustomModal'; import { useCunninghamTheme } from '@/cunningham'; import { Role, Team } from '@/features/teams/team-management'; @@ -56,18 +56,66 @@ export const ModalDelete = ({ access, onClose, team }: ModalDeleteProps) => { }, }); - return ( - + + {t( + 'Are you sure you want to remove this member from the {{team}} group?', + { team: team.name }, + )} + + + {isErrorUpdate && ( + + )} + + {(isLastOwner || isOtherOwner) && ( + + warning + {isLastOwner && + t( + 'You are the last owner, you cannot be removed from your team.', + )} + {isOtherOwner && t('You cannot remove other owner.')} + + )} + + + + + ), + leftAction: ( - } - onClose={onClose} - rightActions={ + ), + rightAction: ( - } + ), + }, + ]; + + return ( + - - {t('Remove this member from the group')} - - - } + title={steps[step].title} + step={step} + totalSteps={steps.length} + closeOnEsc > - - - {t( - 'Are you sure you want to remove this member from the {{team}} group?', - { team: team.name }, - )} - - - {isErrorUpdate && ( - - )} - - {(isLastOwner || isOtherOwner) && ( - - warning - {isLastOwner && - t( - 'You are the last owner, you cannot be removed from your team.', - )} - {isOtherOwner && t('You cannot remove other owner.')} - - )} - - - - - + {steps[step].content} + ); }; diff --git a/src/frontend/apps/desk/src/features/teams/member-management/components/ModalRole.tsx b/src/frontend/apps/desk/src/features/teams/member-management/components/ModalRole.tsx index 0d970cfe..c25bc97d 100644 --- a/src/frontend/apps/desk/src/features/teams/member-management/components/ModalRole.tsx +++ b/src/frontend/apps/desk/src/features/teams/member-management/components/ModalRole.tsx @@ -8,7 +8,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Text, TextErrors } from '@/components'; -import { Modal } from '@/components/Modal'; +import { CustomModal } from '@/components/modal/CustomModal'; import { Role } from '@/features/teams/team-management'; import { useUpdateTeamAccess } from '../api/useUpdateTeamAccess'; @@ -50,10 +50,56 @@ export const ModalRole = ({ const isNotAllowed = isOtherOwner || isLastOwner; - return ( - + {isErrorUpdate && ( + + )} + + + {' '} + {t('Update the role of {{memberName}}', { + memberName: access.user.name, + })}{' '} + + + {(isLastOwner || isOtherOwner) && ( + + warning + {isLastOwner && + t( + 'You are the sole owner of this group. Make another member the group owner, before you can change your own role.', + )} + {isOtherOwner && t('You cannot update the role of other owner.')} + + )} + + + + ), + leftAction: ( - } - onClose={() => onClose()} - closeOnClickOutside - hideCloseButton - rightActions={ + ), + rightAction: ( - } + ), + }, + ]; + + return ( + - - {isErrorUpdate && ( - - )} - - {(isLastOwner || isOtherOwner) && ( - - warning - {isLastOwner && - t( - 'You are the sole owner of this group. Make another member the group owner, before you can change your own role.', - )} - {isOtherOwner && t('You cannot update the role of other owner.')} - - )} - - - - + {steps[step].content} + ); }; diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/CardCreateTeam.tsx b/src/frontend/apps/desk/src/features/teams/team-management/components/CardCreateTeam.tsx deleted file mode 100644 index 378e5dc2..00000000 --- a/src/frontend/apps/desk/src/features/teams/team-management/components/CardCreateTeam.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Button } from '@openfun/cunningham-react'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import IconGroup from '@/assets/icons/icon-group2.svg'; -import { Box, Card, Text } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; - -import { useCreateTeam } from '../api'; - -import { InputTeamName } from './InputTeamName'; - -export const CardCreateTeam = () => { - const { t } = useTranslation(); - const router = useRouter(); - - const { - mutate: createTeam, - isError, - isPending, - error, - } = useCreateTeam({ - onSuccess: (team) => { - router.push(`/teams/${team.id}`); - }, - }); - - const [teamName, setTeamName] = useState(''); - const { colorsTokens } = useCunninghamTheme(); - - return ( - - - - - - - - - - - - - ); -}; diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/InputTeamName.tsx b/src/frontend/apps/desk/src/features/teams/team-management/components/InputTeamName.tsx index ecdca849..01682fb3 100644 --- a/src/frontend/apps/desk/src/features/teams/team-management/components/InputTeamName.tsx +++ b/src/frontend/apps/desk/src/features/teams/team-management/components/InputTeamName.tsx @@ -60,6 +60,7 @@ export const InputTeamName = ({ fullWidth type="text" label={label} + aria-label={label} defaultValue={defaultValue} onChange={(e) => { setTeamName(e.target.value); diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/ModalCreateTeam.tsx b/src/frontend/apps/desk/src/features/teams/team-management/components/ModalCreateTeam.tsx new file mode 100644 index 00000000..3cc94d16 --- /dev/null +++ b/src/frontend/apps/desk/src/features/teams/team-management/components/ModalCreateTeam.tsx @@ -0,0 +1,71 @@ +import { Button, ModalSize } from '@openfun/cunningham-react'; +import { useRouter } from 'next/navigation'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box } from '@/components'; +import { CustomModal } from '@/components/modal/CustomModal'; +import { useCreateTeam } from '@/features/teams/team-management/api'; +import { InputTeamName } from '@/features/teams/team-management/components/InputTeamName'; + +export const ModalCreateTeam = ({ closeModal }: { closeModal: () => void }) => { + const { t } = useTranslation(); + const router = useRouter(); + const step = 0; + + const { + mutate: createTeam, + isError, + isPending, + error, + } = useCreateTeam({ + onSuccess: (team) => { + router.push(`/teams/${team.id}`); + }, + }); + + const [teamName, setTeamName] = useState(''); + + const steps = [ + { + title: t('Create a new team'), + content: ( + + ), + leftAction: ( + + ), + rightAction: ( + + ), + }, + ]; + + return ( + + {steps[step].content} + + ); +}; diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/ModalDeleteTeam.tsx b/src/frontend/apps/desk/src/features/teams/team-management/components/ModalDeleteTeam.tsx new file mode 100644 index 00000000..fb703fb0 --- /dev/null +++ b/src/frontend/apps/desk/src/features/teams/team-management/components/ModalDeleteTeam.tsx @@ -0,0 +1,124 @@ +import { + Button, + Loader, + ModalSize, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import { t } from 'i18next'; +import { useRouter } from 'next/navigation'; + +import IconGroup from '@/assets/icons/icon-group.svg'; +import { Box, Text, TextErrors } from '@/components'; +import { CustomModal } from '@/components/modal/CustomModal'; +import { useCunninghamTheme } from '@/cunningham'; + +import { useRemoveTeam } from '../api/useRemoveTeam'; +import { Team } from '../types'; + +interface ModalDeleteTeamProps { + onClose: () => void; + team: Team; +} + +export const ModalDeleteTeam = ({ onClose, team }: ModalDeleteTeamProps) => { + const { colorsTokens } = useCunninghamTheme(); + const { toast } = useToastProvider(); + const router = useRouter(); + + const { + mutate: removeTeam, + isError, + isPending, + error, + } = useRemoveTeam({ + onSuccess: () => { + toast(t('The team has been removed.'), VariantType.SUCCESS, { + duration: 4000, + }); + router.push('/'); + }, + }); + + return ( + + {t('Cancel')} + + } + rightActions={ + + } + size={ModalSize.MEDIUM} + title={t('Deleting the {{teamName}} team', { teamName: team.name })} + > + + + + {t('Are you sure you want to delete {{teamName}} team?', { + teamName: team.name, + })} + + + {isError && ( + + )} + + + + + {isPending && ( + + + + )} + + + ); +}; diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/ModalRemoveTeam.tsx b/src/frontend/apps/desk/src/features/teams/team-management/components/ModalRemoveTeam.tsx deleted file mode 100644 index cdd40932..00000000 --- a/src/frontend/apps/desk/src/features/teams/team-management/components/ModalRemoveTeam.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { - Button, - ModalSize, - VariantType, - useToastProvider, -} from '@openfun/cunningham-react'; -import { t } from 'i18next'; -import { useRouter } from 'next/navigation'; - -import IconGroup from '@/assets/icons/icon-group.svg'; -import { Box, Text, TextErrors } from '@/components'; -import { Modal } from '@/components/Modal'; -import { useCunninghamTheme } from '@/cunningham'; - -import { useRemoveTeam } from '../api/useRemoveTeam'; -import IconRemove from '../assets/icon-trash.svg'; -import { Team } from '../types'; - -interface ModalRemoveTeamProps { - onClose: () => void; - team: Team; -} - -export const ModalRemoveTeam = ({ onClose, team }: ModalRemoveTeamProps) => { - const { colorsTokens } = useCunninghamTheme(); - const { toast } = useToastProvider(); - const router = useRouter(); - - const { - mutate: removeTeam, - isError, - error, - } = useRemoveTeam({ - onSuccess: () => { - toast(t('The team has been removed.'), VariantType.SUCCESS, { - duration: 4000, - }); - router.push('/'); - }, - }); - - return ( - onClose()} - > - {t('Cancel')} - - } - onClose={() => onClose()} - rightActions={ - - } - size={ModalSize.MEDIUM} - title={ - - - } - > - - - {t('Are you sure you want to delete {{teamName}} team?', { - teamName: team.name, - })} - - - {isError && ( - - )} - - - - - - ); -}; diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/ModalUpdateTeam.tsx b/src/frontend/apps/desk/src/features/teams/team-management/components/ModalUpdateTeam.tsx index ad8e12c0..257491a2 100644 --- a/src/frontend/apps/desk/src/features/teams/team-management/components/ModalUpdateTeam.tsx +++ b/src/frontend/apps/desk/src/features/teams/team-management/components/ModalUpdateTeam.tsx @@ -8,11 +8,9 @@ import { t } from 'i18next'; import { useState } from 'react'; import { Box, Text } from '@/components'; -import { Modal } from '@/components/Modal'; -import { useCunninghamTheme } from '@/cunningham'; +import { CustomModal } from '@/components/modal/CustomModal'; import { useUpdateTeam } from '../api'; -import IconEdit from '../assets/icon-edit.svg'; import { Team } from '../types'; import { InputTeamName } from './InputTeamName'; @@ -23,7 +21,6 @@ interface ModalUpdateTeamProps { } export const ModalUpdateTeam = ({ onClose, team }: ModalUpdateTeamProps) => { - const { colorsTokens } = useCunninghamTheme(); const [teamName, setTeamName] = useState(team.name); const { toast } = useToastProvider(); @@ -41,12 +38,26 @@ export const ModalUpdateTeam = ({ onClose, team }: ModalUpdateTeamProps) => { }, }); - return ( - + + {t('Enter the new name of the selected team')} + + + + ), + leftAction: ( - } - onClose={() => onClose()} - rightActions={ + ), + rightAction: ( - } - size={ModalSize.MEDIUM} - title={ - - - } - > - - - {t('Enter the new name of the selected team')} - + ), + }, + ]; - - - + return ( + + {steps[step].content} + ); }; diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/TeamActions.tsx b/src/frontend/apps/desk/src/features/teams/team-management/components/TeamActions.tsx index 99b3e996..97d2bf9f 100644 --- a/src/frontend/apps/desk/src/features/teams/team-management/components/TeamActions.tsx +++ b/src/frontend/apps/desk/src/features/teams/team-management/components/TeamActions.tsx @@ -6,7 +6,7 @@ import { Box, DropButton, IconOptions, Text } from '@/components'; import { Role, Team } from '../types'; -import { ModalRemoveTeam } from './ModalRemoveTeam'; +import { ModalDeleteTeam } from './ModalDeleteTeam'; import { ModalUpdateTeam } from './ModalUpdateTeam'; interface TeamActionsProps { @@ -71,7 +71,7 @@ export const TeamActions = ({ currentRole, team }: TeamActionsProps) => { /> )} {isModalRemoveOpen && ( - setIsModalRemoveOpen(false)} team={team} /> diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/TeamInfo.tsx b/src/frontend/apps/desk/src/features/teams/team-management/components/TeamInfo.tsx deleted file mode 100644 index dd5097d4..00000000 --- a/src/frontend/apps/desk/src/features/teams/team-management/components/TeamInfo.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { DateTime, DateTimeFormatOptions } from 'luxon'; -import React, { useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import IconGroup from '@/assets/icons/icon-group2.svg'; -import { Box, Card, Text } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; - -import { Role, Team } from '../types'; - -import { ModalUpdateTeam } from './ModalUpdateTeam'; -import { TeamActions } from './TeamActions'; - -const format: DateTimeFormatOptions = { - month: '2-digit', - day: '2-digit', - year: 'numeric', -}; - -interface TeamInfoProps { - team: Team; - currentRole: Role; -} - -export const TeamInfo = ({ team, currentRole }: TeamInfoProps) => { - const { t } = useTranslation(); - const { colorsTokens } = useCunninghamTheme(); - const { i18n } = useTranslation(); - const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false); - - const created_at = DateTime.fromISO(team.created_at) - .setLocale(i18n.language) - .toLocaleString(format); - - const updated_at = DateTime.fromISO(team.updated_at) - .setLocale(i18n.language) - .toLocaleString(format); - - return ( - <> - - - - - - - - - {t('{{count}} member', { count: team.accesses.length })} - - - {t('Created at')}  - - {created_at} - - - - {t('Last update at')}  - - {updated_at} - - - - - {isModalUpdateOpen && ( - setIsModalUpdateOpen(false)} - team={team} - /> - )} - - ); -}; diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/TeamLayout.tsx b/src/frontend/apps/desk/src/features/teams/team-management/components/TeamLayout.tsx deleted file mode 100644 index c05d463a..00000000 --- a/src/frontend/apps/desk/src/features/teams/team-management/components/TeamLayout.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { PropsWithChildren } from 'react'; - -import { Box } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; -import { Panel } from '@/features/teams/teams-panel'; -import { MainLayout } from '@/layouts'; - -export function TeamLayout({ children }: PropsWithChildren) { - const { colorsTokens } = useCunninghamTheme(); - - return ( - - - - - {children} - - - - ); -} diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/TeamView.tsx b/src/frontend/apps/desk/src/features/teams/team-management/components/TeamView.tsx new file mode 100644 index 00000000..e488df57 --- /dev/null +++ b/src/frontend/apps/desk/src/features/teams/team-management/components/TeamView.tsx @@ -0,0 +1,179 @@ +import { Button } from '@openfun/cunningham-react'; +import { DateTime, DateTimeFormatOptions } from 'luxon'; +import { useRouter } from 'next/router'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, IconOptions, Text } from '@/components'; +import { DropButton } from '@/components/DropButton'; +import { useCunninghamTheme } from '@/cunningham'; + +import { Role, Team } from '../types'; + +import { ModalDeleteTeam } from './ModalDeleteTeam'; +import { ModalUpdateTeam } from './ModalUpdateTeam'; + +const format: DateTimeFormatOptions = { + month: '2-digit', + day: '2-digit', + year: 'numeric', +}; + +interface TeamViewProps { + team: Team; + currentRole: Role; +} + +export const TeamView = ({ team, currentRole }: TeamViewProps) => { + const { t } = useTranslation(); + const { colorsTokens } = useCunninghamTheme(); + const { i18n } = useTranslation(); + const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false); + const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false); + const [isDropOpen, setIsDropOpen] = useState(false); + const router = useRouter(); + + if ( + !team || + typeof team !== 'object' || + !('created_at' in team) || + !('updated_at' in team) || + !('name' in team) + ) { + return null; + } + + const created_at = DateTime.fromISO(String(team.created_at)) + .setLocale(i18n.language) + .toLocaleString(format); + + const updated_at = DateTime.fromISO(String(team.updated_at)) + .setLocale(i18n.language) + .toLocaleString(format); + + return ( + <> + + + + + + + {String(team.name)} + + + + + {currentRole !== Role.MEMBER && ( + } + onOpenChange={(open: boolean) => setIsDropOpen(open)} + isOpen={isDropOpen} + > + + + {currentRole === Role.OWNER && ( + + )} + + + )} + + + + + {t('Created at')}  + + {created_at} + + + + {t('Last update at')}  + + {updated_at} + + + + + + {isModalUpdateOpen && ( + setIsModalUpdateOpen(false)} + team={team} + /> + )} + + {isModalDeleteOpen && ( + setIsModalDeleteOpen(false)} + team={team} + /> + )} + + ); +}; diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/__tests__/CardCreateTeam.test.tsx b/src/frontend/apps/desk/src/features/teams/team-management/components/__tests__/ModalCreateTeam.test.tsx similarity index 87% rename from src/frontend/apps/desk/src/features/teams/team-management/components/__tests__/CardCreateTeam.test.tsx rename to src/frontend/apps/desk/src/features/teams/team-management/components/__tests__/ModalCreateTeam.test.tsx index d2235962..7504cf90 100644 --- a/src/frontend/apps/desk/src/features/teams/team-management/components/__tests__/CardCreateTeam.test.tsx +++ b/src/frontend/apps/desk/src/features/teams/team-management/components/__tests__/ModalCreateTeam.test.tsx @@ -5,7 +5,9 @@ import React from 'react'; import { AppWrapper } from '@/tests/utils'; -import { CardCreateTeam } from '../CardCreateTeam'; +import { ModalCreateTeam } from '../ModalCreateTeam'; + +const mockClose = jest.fn(); const mockPush = jest.fn(); jest.mock('next/navigation', () => ({ @@ -14,9 +16,9 @@ jest.mock('next/navigation', () => ({ }), })); -describe('CardCreateTeam', () => { - const renderCardCreateTeam = () => - render(, { wrapper: AppWrapper }); +describe('ModalCreateTeam', () => { + const renderModal = () => + render(, { wrapper: AppWrapper }); beforeEach(() => { jest.clearAllMocks(); @@ -27,10 +29,8 @@ describe('CardCreateTeam', () => { }); it('renders all the elements', () => { - renderCardCreateTeam(); - - expect(screen.getByLabelText('Create new team card')).toBeInTheDocument(); - expect(screen.getByText('Create a new group')).toBeInTheDocument(); + renderModal(); + expect(screen.getByText('Create a new team')).toBeInTheDocument(); expect(screen.getByLabelText('Team name')).toBeInTheDocument(); expect(screen.getByText('Cancel')).toBeInTheDocument(); expect(screen.getByText('Create the team')).toBeInTheDocument(); @@ -38,13 +38,10 @@ describe('CardCreateTeam', () => { it('handles input for team name and enables submit button', async () => { const user = userEvent.setup(); - renderCardCreateTeam(); - + renderModal(); const teamNameInput = screen.getByLabelText('Team name'); const createButton = screen.getByText('Create the team'); - expect(createButton).toBeDisabled(); - await user.type(teamNameInput, 'New Team'); expect(createButton).toBeEnabled(); }); @@ -54,22 +51,17 @@ describe('CardCreateTeam', () => { id: '270328ea-c2c0-4f74-a449-5cdc976dcdb6', name: 'New Team', }); - const user = userEvent.setup(); - renderCardCreateTeam(); - + renderModal(); const teamNameInput = screen.getByLabelText('Team name'); const createButton = screen.getByText('Create the team'); - await user.type(teamNameInput, 'New Team'); await user.click(createButton); - await waitFor(() => { expect(mockPush).toHaveBeenCalledWith( '/teams/270328ea-c2c0-4f74-a449-5cdc976dcdb6', ); }); - expect(fetchMock.calls()).toHaveLength(1); expect(fetchMock.lastCall()?.[0]).toContain('/teams/'); expect(fetchMock.lastCall()?.[1]?.body).toEqual( @@ -84,16 +76,12 @@ describe('CardCreateTeam', () => { }, status: 400, }); - const user = userEvent.setup(); - renderCardCreateTeam(); - + renderModal(); const teamNameInput = screen.getByLabelText('Team name'); const createButton = screen.getByText('Create the team'); - await user.type(teamNameInput, 'Existing Team'); await user.click(createButton); - await waitFor(() => { expect( screen.getByText(/This name is already used for another group/i), @@ -106,16 +94,12 @@ describe('CardCreateTeam', () => { body: {}, status: 500, }); - const user = userEvent.setup(); - renderCardCreateTeam(); - + renderModal(); const teamNameInput = screen.getByLabelText('Team name'); const createButton = screen.getByText('Create the team'); - await user.type(teamNameInput, 'Server Error Team'); await user.click(createButton); - await waitFor(() => { expect( screen.getByText( @@ -123,7 +107,6 @@ describe('CardCreateTeam', () => { ), ).toBeInTheDocument(); }); - expect(fetchMock.calls()).toHaveLength(1); expect(fetchMock.lastCall()?.[0]).toContain('/teams/'); expect(fetchMock.lastCall()?.[1]?.body).toEqual( @@ -134,16 +117,12 @@ describe('CardCreateTeam', () => { it('disables create button when API request is pending', async () => { // Never resolves fetchMock.post('end:teams/', new Promise(() => {})); - const user = userEvent.setup(); - renderCardCreateTeam(); - + renderModal(); const teamNameInput = screen.getByLabelText('Team name'); const createButton = screen.getByText('Create the team'); - await user.type(teamNameInput, 'Pending Team'); await user.click(createButton); - expect(createButton).toBeDisabled(); }); }); diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/index.ts b/src/frontend/apps/desk/src/features/teams/team-management/components/index.ts index 047a5444..37233de7 100644 --- a/src/frontend/apps/desk/src/features/teams/team-management/components/index.ts +++ b/src/frontend/apps/desk/src/features/teams/team-management/components/index.ts @@ -1,3 +1 @@ -export * from './CardCreateTeam'; -export * from './TeamInfo'; -export * from './TeamLayout'; +export * from './TeamView'; diff --git a/src/frontend/apps/desk/src/features/teams/teams-panel/components/TeamsListView.tsx b/src/frontend/apps/desk/src/features/teams/teams-panel/components/TeamsListView.tsx new file mode 100644 index 00000000..d5911e24 --- /dev/null +++ b/src/frontend/apps/desk/src/features/teams/teams-panel/components/TeamsListView.tsx @@ -0,0 +1,70 @@ +import { Button, DataGrid } from '@openfun/cunningham-react'; +import { useRouter as useNavigate } from 'next/navigation'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Text } from '@/components'; +import { TeamsOrdering, useTeams } from '@/features/teams/team-management'; + +interface TeamsListViewProps { + querySearch: string; +} + +export function TeamsListView({ querySearch }: TeamsListViewProps) { + const { t } = useTranslation(); + const router = useNavigate(); + const { data, isLoading } = useTeams({ + ordering: TeamsOrdering.BY_CREATED_ON_DESC, + }); + const teams = React.useMemo(() => data || [], [data]); + + const filteredTeams = React.useMemo(() => { + if (!querySearch) { + return teams; + } + const lower = querySearch.toLowerCase(); + return teams.filter((team) => team.name.toLowerCase().includes(lower)); + }, [teams, querySearch]); + + return ( +
+ {filteredTeams && filteredTeams.length ? ( + row.accesses.length, + }, + { + id: 'actions', + renderCell: ({ row }) => ( + + ), + }, + ]} + isLoading={isLoading} + /> + ) : null} + {(!filteredTeams || !filteredTeams.length) && ( + + {t('No teams exist.')} + + )} +
+ ); +} diff --git a/src/frontend/apps/desk/src/pages/teams/[id].tsx b/src/frontend/apps/desk/src/pages/teams/[id].tsx index 3a3f8120..46ac998c 100644 --- a/src/frontend/apps/desk/src/pages/teams/[id].tsx +++ b/src/frontend/apps/desk/src/pages/teams/[id].tsx @@ -6,12 +6,8 @@ import { ReactElement } from 'react'; import { Box } from '@/components'; import { TextErrors } from '@/components/TextErrors'; import { MemberGrid } from '@/features/teams/member-management'; -import { - Role, - TeamInfo, - TeamLayout, - useTeam, -} from '@/features/teams/team-management'; +import { Role, TeamView, useTeam } from '@/features/teams/team-management'; +import { MainLayout } from '@/layouts'; import { NextPageWithLayout } from '@/types/next'; const Page: NextPageWithLayout = () => { @@ -59,14 +55,14 @@ const Team = ({ id }: TeamProps) => { return ( <> - + ); }; Page.getLayout = function getLayout(page: ReactElement) { - return {page}; + return {page}; }; export default Page; diff --git a/src/frontend/apps/desk/src/pages/teams/create.tsx b/src/frontend/apps/desk/src/pages/teams/create.tsx deleted file mode 100644 index 66ab731c..00000000 --- a/src/frontend/apps/desk/src/pages/teams/create.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { ReactElement } from 'react'; - -import { Box } from '@/components'; -import { CardCreateTeam, TeamLayout } from '@/features/teams/team-management'; -import { NextPageWithLayout } from '@/types/next'; - -const Page: NextPageWithLayout = () => { - return ( - - - - ); -}; - -Page.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default Page; diff --git a/src/frontend/apps/desk/src/pages/teams/index.tsx b/src/frontend/apps/desk/src/pages/teams/index.tsx index ee5d394c..ed270397 100644 --- a/src/frontend/apps/desk/src/pages/teams/index.tsx +++ b/src/frontend/apps/desk/src/pages/teams/index.tsx @@ -1,18 +1,18 @@ import { Button, Input, Tooltip } from '@openfun/cunningham-react'; -import { useRouter as useNavigate } from 'next/navigation'; import React, { ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Text } from '@/components'; import { useAuthStore } from '@/core/auth'; import { useCunninghamTheme } from '@/cunningham'; +import { ModalCreateTeam } from '@/features/teams/team-management/components/ModalCreateTeam'; import { TeamsListView } from '@/features/teams/team-management/components/TeamsListView'; import { MainLayout } from '@/layouts'; import { NextPageWithLayout } from '@/types/next'; const Page: NextPageWithLayout = () => { const { t } = useTranslation(); - const router = useNavigate(); + const [isModalOpen, setIsModalOpen] = React.useState(false); const { userData } = useAuthStore(); const can_create = userData?.abilities?.teams.can_create ?? false; const [searchValue, setSearchValue] = React.useState(''); @@ -27,6 +27,14 @@ const Page: NextPageWithLayout = () => { setSearchValue(''); }; + const openModal = () => { + setIsModalOpen(true); + }; + + const closeModal = () => { + setIsModalOpen(false); + }; + return ( { {can_create ? ( - ) : ( @@ -108,7 +113,7 @@ const Page: NextPageWithLayout = () => {