⚡️(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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
55
src/frontend/apps/desk/src/components/InfiniteScroll.tsx
Normal file
55
src/frontend/apps/desk/src/components/InfiniteScroll.tsx
Normal 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>;
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
21
src/frontend/apps/e2e/__tests__/helpers.ts
Normal file
21
src/frontend/apps/e2e/__tests__/helpers.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user