💄(teams) update team list page to match new UI

This is an attempt to quick fix the team page to match the new UI.
This commit is contained in:
Quentin BEY
2025-05-12 15:25:10 +02:00
parent 8c67d4a004
commit bd43e4620d
9 changed files with 328 additions and 93 deletions

View File

@@ -40,7 +40,9 @@ def test_commands_create_demo(settings):
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
assert mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"] + 1 + 1
assert (
mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"] + 1 + 1
)
# 3 domain access for each user with domain rights
# 3 x 3 domain access for each user with both rights

View File

@@ -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 (
<div role="listbox">
{filteredTeams && filteredTeams.length ? (
<DataGrid
aria-label="listboxTeams"
rows={filteredTeams}
columns={[
{
field: 'name',
headerName: t('Team'),
enableSorting: true,
},
{
id: 'members',
headerName: t('Member count'),
enableSorting: false,
renderCell: ({ row }) => row.accesses.length,
},
{
id: 'actions',
renderCell: ({ row }) => (
<Button
color="tertiary"
onClick={() => router.push(`/teams/${row.id}`)}
aria-label={t('View team details')}
>
<span className="material-icons">chevron_right</span>
</Button>
),
},
]}
isLoading={isLoading}
/>
) : null}
{(!filteredTeams || !filteredTeams.length) && (
<Text $align="center" $size="small">
{t('No teams exist.')}
</Text>
)}
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { CunninghamProvider } from '@openfun/cunningham-react';
import { render, screen } from '@testing-library/react';
import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { useTeams } from '@/features/teams/team-management';
import { TeamsListView } from '../TeamsListView';
// Mock the hooks
jest.mock('@/features/teams/team-management', () => ({
useTeams: jest.fn(),
TeamsOrdering: {
BY_CREATED_ON_DESC: '-created_at',
},
}));
jest.mock('next/navigation');
jest.mock('react-i18next');
describe('TeamsListView', () => {
const mockTeams = [
{
id: '1',
name: 'Team A',
accesses: [{ id: '1' }, { id: '2' }],
},
{
id: '2',
name: 'Team B',
accesses: [{ id: '3' }],
},
];
const mockRouter = {
push: jest.fn(),
};
const renderWithProvider = (ui: React.ReactElement) => {
return render(<CunninghamProvider>{ui}</CunninghamProvider>);
};
beforeEach(() => {
// Setup mock implementations
(useTeams as jest.Mock).mockReturnValue({
data: mockTeams,
isLoading: false,
});
(useRouter as jest.Mock).mockReturnValue(mockRouter);
(useTranslation as jest.Mock).mockReturnValue({
t: (key: string) => key,
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('renders teams list when teams exist', () => {
renderWithProvider(<TeamsListView querySearch="" />);
// Check if team names are rendered
expect(screen.getByText('Team A')).toBeInTheDocument();
expect(screen.getByText('Team B')).toBeInTheDocument();
// Check if member counts are rendered
expect(screen.getByText('2')).toBeInTheDocument(); // Team A has 2 members
expect(screen.getByText('1')).toBeInTheDocument(); // Team B has 1 member
});
it('shows "No teams exist" message when no teams are available', () => {
(useTeams as jest.Mock).mockReturnValue({
data: [],
isLoading: false,
});
renderWithProvider(<TeamsListView querySearch="" />);
expect(screen.getByText('No teams exist.')).toBeInTheDocument();
});
it('filters teams based on search query', () => {
renderWithProvider(<TeamsListView querySearch="Team A" />);
// Only Team A should be visible
expect(screen.getByText('Team A')).toBeInTheDocument();
expect(screen.queryByText('Team B')).not.toBeInTheDocument();
});
it('navigates to team details when clicking the view button', () => {
renderWithProvider(<TeamsListView querySearch="" />);
// Find and click the view button for Team A
const viewButtons = screen.getAllByRole('button', {
name: 'View team details',
});
viewButtons[0].click();
// Check if router.push was called with the correct path
expect(mockRouter.push).toHaveBeenCalledWith('/teams/1');
});
it('handles case-insensitive search', () => {
renderWithProvider(<TeamsListView querySearch="team a" />);
// Team A should still be visible despite different case
expect(screen.getByText('Team A')).toBeInTheDocument();
expect(screen.queryByText('Team B')).not.toBeInTheDocument();
});
});

View File

@@ -1,38 +1,146 @@
import { Button } from '@openfun/cunningham-react';
import { Button, Input, Tooltip } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { ReactElement } from 'react';
import React, { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box, Text } from '@/components';
import { useAuthStore } from '@/core/auth';
import { TeamLayout } from '@/features/teams/team-management';
import { useCunninghamTheme } from '@/cunningham';
import { TeamsListView } from '@/features/teams/team-management/components/TeamsListView';
import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const StyledButton = styled(Button)`
width: fit-content;
`;
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
const router = useNavigate();
const { userData } = useAuthStore();
const can_create = userData?.abilities?.teams.can_create ?? false;
const [searchValue, setSearchValue] = React.useState('');
const { colorsTokens } = useCunninghamTheme();
const colors = colorsTokens();
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(event.target.value);
};
const clearInput = () => {
setSearchValue('');
};
return (
<Box $align="center" $justify="center" $height="inherit">
{can_create && (
<StyledButton onClick={() => void router.push('/teams/create')}>
{t('Create a new team')}
</StyledButton>
)}
{!can_create && <Text>{t('Click on team to view details')}</Text>}
</Box>
<div aria-label="Teams panel" className="container">
<div
data-testid="regie-grid"
style={{
height: '100%',
justifyContent: 'center',
width: '100%',
padding: '16px',
overflowX: 'hidden',
overflowY: 'auto',
background: 'white',
borderRadius: '4px',
border: `1px solid ${colorsTokens()['greyscale-200']}`,
}}
>
<h2
style={{ fontWeight: 700, fontSize: '1.5rem', marginBottom: '20px' }}
>
{t('Teams')}
</h2>
<div
className="sm:block md:flex"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
gap: '1em',
}}
>
<div
style={{ width: 'calc(100% - 245px)' }}
className="c__input__wrapper__mobile"
>
<Input
style={{ width: '100%' }}
label={t('Search a team')}
icon={<span className="material-icons">search</span>}
rightIcon={
searchValue && (
<span
className="material-icons"
onClick={clearInput}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
clearInput();
}
}}
role="button"
tabIndex={0}
style={{ cursor: 'pointer' }}
>
close
</span>
)
}
value={searchValue}
onChange={handleInputChange}
/>
</div>
<div
className="hidden md:flex"
style={{
background: colors['greyscale-200'],
height: '32px',
width: '1px',
}}
></div>
<div
className="block md:hidden"
style={{ marginBottom: '10px' }}
></div>
<div>
{can_create ? (
<Button
data-testid="button-new-team"
onClick={() => void router.push('/teams/create')}
>
{t('Create a new team')}
</Button>
) : (
<Tooltip content="You don't have the correct access right">
<div>
<Button
data-testid="button-new-team"
onClick={() => void router.push('/teams/create')}
disabled={!can_create}
>
{t('Create a new team')}
</Button>
</div>
</Tooltip>
)}
</div>
</div>
{!can_create && (
<p style={{ textAlign: 'center' }}>
{t('Click on team to view details')}
</p>
)}
<TeamsListView querySearch={searchValue} />
</div>
</div>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
return <TeamLayout>{page}</TeamLayout>;
return <MainLayout backgroundColor="grey">{page}</MainLayout>;
};
export default Page;

View File

@@ -43,12 +43,21 @@ export const createTeam = async (
length: number,
) => {
const panel = page.getByLabel('Teams panel').first();
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new team',
});
const buttonCreate = page.getByRole('button', { name: 'Create the team' });
const randomTeams = randomName(teamName, browserName, length);
for (let i = 0; i < randomTeams.length; i++) {
await panel.getByRole('button', { name: 'Add a team' }).click();
if (i == 0) {
// for the first team, we need to click on the button in the homepage
await buttonCreateHomepage.click();
} else {
// for the other teams, we need to click on the button in the panel of the detail view
await panel.getByRole('button', { name: 'Add a team' }).click();
}
await page.getByText('Team name').fill(randomTeams[i]);
await expect(buttonCreate).toBeEnabled();
await buttonCreate.click();

View File

@@ -10,18 +10,14 @@ test.describe('Language', () => {
});
test('checks the language picker', async ({ page }) => {
await expect(
page.getByLabel('Teams panel', { exact: true }).getByText('Groups'),
).toBeVisible();
await expect(page.getByRole('heading', { name: 'Teams' })).toBeVisible();
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'FR' }).click();
await expect(header.getByRole('combobox').getByText('FR')).toBeVisible();
await expect(
page.getByLabel('Teams panel', { exact: true }).getByText('Groupes'),
).toBeVisible();
await expect(page.getByRole('heading', { name: 'Équipes' })).toBeVisible();
});
test('checks lang attribute of html tag updates when user changes language', async ({

View File

@@ -22,7 +22,7 @@ test.describe('Login to people as Identity Provider', () => {
await expect(page).toHaveURL('http://localhost:3000/', { timeout: 10000 });
// check the user is logged in
await expect(page.getByText('Groups')).toBeVisible();
await expect(page.getByText('0 group to display.')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Teams' })).toBeVisible();
await expect(page.getByText('No teams exist.')).toBeVisible();
});
});

View File

@@ -61,9 +61,10 @@ test.describe('Teams Create', () => {
page,
browserName,
}) => {
const panel = page.getByLabel('Teams panel').first();
await panel.getByRole('button', { name: 'Add a team' }).click();
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new team',
});
await buttonCreateHomepage.click();
const teamName = `My routing team ${browserName}-${Math.floor(Math.random() * 1000)}`;
await page.getByText('Team name').fill(teamName);
@@ -72,6 +73,7 @@ test.describe('Teams Create', () => {
const elTeam = page.getByRole('heading', { name: teamName });
await expect(elTeam).toBeVisible();
const panel = page.getByLabel('Teams panel').first();
await panel.getByRole('button', { name: 'Add a team' }).click();
await expect(elTeam).toBeHidden();
@@ -79,20 +81,6 @@ test.describe('Teams Create', () => {
await expect(elTeam).toBeVisible();
});
test('checks alias teams url with homepage', async ({ page }) => {
await expect(page).toHaveURL('/');
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new team',
});
await expect(buttonCreateHomepage).toBeVisible();
await page.goto('/teams/');
await expect(buttonCreateHomepage).toBeVisible();
await expect(page).toHaveURL(/\/teams\//);
});
test('checks 404 on teams/[id] page', async ({ page }) => {
await page.goto('/teams/some-unknown-team');
await expect(

View File

@@ -9,55 +9,9 @@ test.beforeEach(async ({ page, browserName }) => {
test.describe('Teams Panel', () => {
test('checks all the elements are visible', async ({ page }) => {
const panel = page.getByLabel('Teams panel').first();
await expect(page.getByRole('heading', { name: 'Teams' })).toBeVisible();
await expect(panel.getByText('Groups')).toBeVisible();
await expect(
panel.getByRole('button', {
name: 'Sort the teams',
}),
).toBeVisible();
await expect(
panel.getByRole('button', {
name: 'Add a team',
}),
).toBeVisible();
});
test('checks the sort button', async ({ page }) => {
const responsePromiseSortDesc = page.waitForResponse(
(response) =>
response.url().includes('/teams/?ordering=-created_at') &&
response.status() === 200,
);
const responsePromiseSortAsc = page.waitForResponse(
(response) =>
response.url().includes('/teams/?ordering=created_at') &&
response.status() === 200,
);
const panel = page.getByLabel('Teams panel').first();
await panel
.getByRole('button', {
name: 'Sort the teams by creation date ascendent',
})
.click();
const responseSortAsc = await responsePromiseSortAsc;
expect(responseSortAsc.ok()).toBeTruthy();
await panel
.getByRole('button', {
name: 'Sort the teams by creation date descendent',
})
.click();
const responseSortDesc = await responsePromiseSortDesc;
expect(responseSortDesc.ok()).toBeTruthy();
await expect(page.getByTestId('button-new-team')).toBeVisible();
});
test('checks the hover and selected state', async ({ page, browserName }) => {