️(app-desk) add infinite scrool to teams list

If we have a big list of teams, we need to add infinite
scroll to avoid loading all the teams at once.
This commit is contained in:
Anthony LC
2024-02-02 13:12:03 +01:00
committed by Anthony LC
parent fafffd2391
commit 47ffa60a94
9 changed files with 280 additions and 85 deletions

View File

@@ -11,11 +11,7 @@ export default function InnerLayout({
<Header />
<Box $css="flex: 1;" $direction="row">
<Menu />
<Box
$height={`calc(100vh - ${HEADER_HEIGHT})`}
$width="100%"
$css="overflow: auto;"
>
<Box $height={`calc(100vh - ${HEADER_HEIGHT})`} $width="100%">
{children}
</Box>
</Box>

View File

@@ -1,4 +1,4 @@
import { ReactHTML } from 'react';
import { ComponentPropsWithRef, ReactHTML } from 'react';
import styled from 'styled-components';
import { CSSProperties } from 'styled-components/dist/types';
@@ -19,6 +19,8 @@ export interface BoxProps {
$width?: CSSProperties['width'];
}
export type BoxType = ComponentPropsWithRef<typeof Box>;
export const Box = styled('div')<BoxProps>`
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,55 @@
import { PropsWithChildren, useEffect, useRef } from 'react';
import { Box, BoxType } from '@/components';
interface InfiniteScrollProps extends BoxType {
hasMore: boolean;
isLoading: boolean;
next: () => void;
scrollContainer: HTMLElement | null;
}
export const InfiniteScroll = ({
children,
hasMore,
isLoading,
next,
scrollContainer,
...boxProps
}: PropsWithChildren<InfiniteScrollProps>) => {
const timeout = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!scrollContainer) {
return;
}
const nextHandle = () => {
if (!hasMore || isLoading) {
return;
}
// To not wait until the end of the scroll to load more data
const heightFromBottom = 150;
const { scrollTop, clientHeight, scrollHeight } = scrollContainer;
if (scrollTop + clientHeight >= scrollHeight - heightFromBottom) {
next();
}
};
const handleScroll = () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(nextHandle, 50);
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, [hasMore, isLoading, next, scrollContainer]);
return <Box {...boxProps}>{children}</Box>;
};

View File

@@ -6,6 +6,8 @@ import { AppWrapper } from '@/tests/utils';
import { PanelTeams } from '../components/PanelTeams';
window.HTMLElement.prototype.scroll = function () {};
describe('PanelTeams', () => {
afterEach(() => {
fetchMock.restore();
@@ -44,7 +46,9 @@ describe('PanelTeams', () => {
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByLabelText('Empty team icon')).toBeInTheDocument();
expect(
await screen.findByLabelText('Empty teams icon'),
).toBeInTheDocument();
});
it('renders with not team to display', async () => {
@@ -68,7 +72,7 @@ describe('PanelTeams', () => {
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByLabelText('Team icon')).toBeInTheDocument();
expect(await screen.findByLabelText('Teams icon')).toBeInTheDocument();
});
it('renders the error', async () => {

View File

@@ -1,4 +1,9 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
useInfiniteQuery,
} from '@tanstack/react-query';
import { APIList, fetchAPI } from '@/api';
@@ -14,7 +19,7 @@ interface Access {
user: string;
}
interface TeamResponse {
export interface TeamResponse {
id: string;
name: string;
accesses: Access[];
@@ -26,7 +31,10 @@ export enum TeamsOrdering {
}
export type TeamsParams = {
ordering?: TeamsOrdering;
ordering: TeamsOrdering;
};
type TeamsAPIParams = TeamsParams & {
page: number;
};
type TeamsResponse = APIList<TeamResponse>;
@@ -34,10 +42,9 @@ export interface TeamsResponseError {
detail: string;
}
export const getTeams = async (props?: TeamsParams) => {
const response = await fetchAPI(
`teams/${props?.ordering ? `?ordering=${props.ordering}` : ''}`,
);
export const getTeams = async ({ ordering, page }: TeamsAPIParams) => {
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
const response = await fetchAPI(`teams/?page=${page}${orderingQuery}`);
if (!response.ok) {
throw new Error(`Couldn't fetch teams: ${response.statusText}`);
@@ -48,16 +55,32 @@ export const getTeams = async (props?: TeamsParams) => {
export const KEY_LIST_TEAM = 'teams';
export function useTeams(
param?: TeamsParams,
queryConfig?: UseQueryOptions<
param: TeamsParams,
queryConfig?: DefinedInitialDataInfiniteOptions<
TeamsResponse,
TeamsResponseError,
TeamsResponse
InfiniteData<TeamsResponse>,
QueryKey,
number
>,
) {
return useQuery<TeamsResponse, TeamsResponseError, TeamsResponse>({
return useInfiniteQuery<
TeamsResponse,
TeamsResponseError,
InfiniteData<TeamsResponse>,
QueryKey,
number
>({
initialPageParam: 1,
queryKey: [KEY_LIST_TEAM, param],
queryFn: () => getTeams(param),
queryFn: ({ pageParam }) =>
getTeams({
...param,
page: pageParam,
}),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
},
...queryConfig,
});
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group.svg';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { TeamResponse } from '../api/useTeams';
import IconNone from '../assets/icon-none.svg';
interface TeamProps {
team: TeamResponse;
}
export const PanelTeam = ({ team }: TeamProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const commonProps = {
className: 'p-t',
width: 36,
style: {
borderRadius: '10px',
},
};
return (
<Box as="li" $direction="row" $align="center" $gap="0.5rem">
{team.accesses.length ? (
<IconGroup
aria-label={t(`Teams icon`)}
color={colorsTokens()['primary-500']}
{...commonProps}
style={{
...commonProps.style,
border: `1px solid ${colorsTokens()['primary-300']}`,
}}
/>
) : (
<IconNone
aria-label={t(`Empty teams icon`)}
color={colorsTokens()['greyscale-500']}
{...commonProps}
style={{
...commonProps.style,
border: `1px solid ${colorsTokens()['greyscale-300']}`,
}}
/>
)}
<Text $weight="bold">{team.name}</Text>
</Box>
);
};

View File

@@ -1,34 +1,31 @@
import { Loader } from '@openfun/cunningham-react';
import React from 'react';
import React, { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group.svg';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { InfiniteScroll } from '@/components/InfiniteScroll';
import { useTeams } from '../api/useTeams';
import IconNone from '../assets/icon-none.svg';
import { TeamResponse, useTeams } from '../api/useTeams';
import { useTeamStore } from '../store/useTeamsStore';
export const PanelTeams = () => {
const ordering = useTeamStore((state) => state.ordering);
const { data, isPending, isError } = useTeams({
ordering,
});
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
import { PanelTeam } from './PanelTeam';
if (isPending) {
return (
<Box $align="center" className="m-l">
<Loader />
</Box>
);
}
interface PanelTeamsStateProps {
isLoading: boolean;
isError: boolean;
teams?: TeamResponse[];
}
const PanelTeamsState = ({
isLoading,
isError,
teams,
}: PanelTeamsStateProps) => {
const { t } = useTranslation();
if (isError) {
return (
<Box $justify="center" className="m-b">
<Box $justify="center" className="mb-b">
<Text $theme="danger" $align="center" $textAlign="center">
{t('Something bad happens, please refresh the page.')}
</Text>
@@ -36,9 +33,17 @@ export const PanelTeams = () => {
);
}
if (!data.count) {
if (isLoading) {
return (
<Box $justify="center" className="m-b">
<Box $align="center" className="m-l">
<Loader />
</Box>
);
}
if (!teams?.length) {
return (
<Box $justify="center">
<Text as="p" className="mb-0 mt-0" $theme="greyscale" $variation="500">
{t('0 group to display.')}
</Text>
@@ -51,42 +56,48 @@ export const PanelTeams = () => {
);
}
return teams.map((team) => <PanelTeam team={team} key={team.id} />);
};
export const PanelTeams = () => {
const ordering = useTeamStore((state) => state.ordering);
const {
data,
isError,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useTeams({
ordering,
});
const containerRef = useRef<HTMLDivElement>(null);
const teams = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return acc.concat(page.results);
}, [] as TeamResponse[]);
}, [data?.pages]);
return (
<Box as="ul" $gap="1rem" className="p-s mt-t" $css="overflow:auto;">
{data?.results.map((team) => (
<Box
as="li"
key={team.id}
$direction="row"
$align="center"
$gap="0.5rem"
>
{team.accesses.length ? (
<IconGroup
className="p-t"
width={36}
aria-label={t(`Team icon`)}
color={colorsTokens()['primary-500']}
style={{
borderRadius: '10px',
border: `1px solid ${colorsTokens()['primary-300']}`,
}}
/>
) : (
<IconNone
className="p-t"
width={36}
aria-label={t(`Empty team icon`)}
color={colorsTokens()['greyscale-500']}
style={{
borderRadius: '10px',
border: `1px solid ${colorsTokens()['greyscale-300']}`,
}}
/>
)}
<Text $weight="bold">{team.name}</Text>
</Box>
))}
<Box $css="overflow: auto;" ref={containerRef}>
<InfiniteScroll
hasMore={hasNextPage}
isLoading={isFetchingNextPage}
next={() => {
void fetchNextPage();
}}
scrollContainer={containerRef.current}
$gap="1rem"
as="ul"
className="p-s mt-t"
role="listbox"
>
<PanelTeamsState
isLoading={isLoading}
isError={isError}
teams={teams}
/>
</InfiniteScroll>
</Box>
);
};

View File

@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { waitForElementCount } from '../helpers';
import { keyCloakSignIn } from './common';
test.beforeEach(async ({ page }) => {
@@ -7,8 +9,9 @@ test.beforeEach(async ({ page }) => {
await keyCloakSignIn(page);
});
test.describe.configure({ mode: 'serial' });
test.describe('Teams', () => {
test('checks all the elements are visible', async ({ page }) => {
test('001 - checks all the elements are visible', async ({ page }) => {
const panel = page.getByLabel('Teams panel').first();
await expect(panel.getByText('Recents')).toBeVisible();
@@ -32,14 +35,20 @@ test.describe('Teams', () => {
).toBeVisible();
});
test('check sort button', async ({ page }) => {
test('002 - check sort button', async ({ page, browserName }) => {
const panel = page.getByLabel('Teams panel').first();
await page.getByRole('button', { name: 'Add a team' }).click();
const randomTeams = Array.from({ length: 3 }, () => {
return `team-sort-${browserName}-${Math.floor(Math.random() * 1000)}`;
});
for (let i = 0; i < 3; i++) {
await page.getByText('Team name').fill(`team-sort${i}`);
await page.getByText('Team name').fill(`${randomTeams[i]}-${i}`);
await page.getByRole('button', { name: 'Create a team' }).click();
await expect(
panel.locator('li').getByText(`team-sort${i}`),
panel.locator('li').nth(0).getByText(`${randomTeams[i]}-${i}`),
).toBeVisible();
}
@@ -51,11 +60,32 @@ test.describe('Teams', () => {
for (let i = 0; i < 3; i++) {
await expect(
panel
.locator('li')
.nth(i)
.getByText(`team-sort${i + 1}`),
panel.locator('li').nth(i).getByText(`${randomTeams[i]}-${i}`),
).toBeVisible();
}
});
test('003 - check the infinite scrool', async ({ page, browserName }) => {
test.setTimeout(90000);
const panel = page.getByLabel('Teams panel').first();
await page.getByRole('button', { name: 'Add a team' }).click();
const randomTeams = Array.from({ length: 40 }, () => {
return `team-infinite-${browserName}-${Math.floor(Math.random() * 10000)}`;
});
for (let i = 0; i < 40; i++) {
await page.getByText('Team name').fill(`${randomTeams[i]}-${i}`);
await page.getByRole('button', { name: 'Create Team' }).click();
await expect(
panel.locator('li').getByText(`${randomTeams[i]}-${i}`),
).toBeVisible();
}
await expect(panel.locator('li')).toHaveCount(20);
await panel.getByText(`${randomTeams[24]}-${24}`).click();
await waitForElementCount(panel.locator('li'), 21, 10000);
expect(await panel.locator('li').count()).toBeGreaterThan(20);
});
});

View File

@@ -0,0 +1,21 @@
import { Locator } from '@playwright/test';
export async function waitForElementCount(
locator: Locator,
count: number,
timeout: number,
) {
let elapsedTime = 0;
const interval = 200; // Check every 200 ms
while (elapsedTime < timeout) {
const currentCount = await locator.count();
if (currentCount >= count) {
return true;
}
await locator.page().waitForTimeout(interval); // Wait for the interval before checking again
elapsedTime += interval;
}
throw new Error(
`Timeout after ${timeout}ms waiting for element count to be at least ${count}`,
);
}