From 47ffa60a943c5be74ebac46f5434039cc94f9b31 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 2 Feb 2024 13:12:03 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(app-desk)=20add=20infinite?= =?UTF-8?q?=20scrool=20to=20teams=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If we have a big list of teams, we need to add infinite scroll to avoid loading all the teams at once. --- .../apps/desk/src/app/InnerLayout.tsx | 6 +- src/frontend/apps/desk/src/components/Box.tsx | 4 +- .../desk/src/components/InfiniteScroll.tsx | 55 ++++++++ .../teams/__tests__/PanelTeams.test.tsx | 8 +- .../desk/src/features/teams/api/useTeams.tsx | 47 +++++-- .../features/teams/components/PanelTeam.tsx | 53 ++++++++ .../features/teams/components/PanelTeams.tsx | 125 ++++++++++-------- .../apps/e2e/__tests__/app-desk/teams.spec.ts | 46 +++++-- src/frontend/apps/e2e/__tests__/helpers.ts | 21 +++ 9 files changed, 280 insertions(+), 85 deletions(-) create mode 100644 src/frontend/apps/desk/src/components/InfiniteScroll.tsx create mode 100644 src/frontend/apps/desk/src/features/teams/components/PanelTeam.tsx create mode 100644 src/frontend/apps/e2e/__tests__/helpers.ts diff --git a/src/frontend/apps/desk/src/app/InnerLayout.tsx b/src/frontend/apps/desk/src/app/InnerLayout.tsx index c8fb990..dc01026 100644 --- a/src/frontend/apps/desk/src/app/InnerLayout.tsx +++ b/src/frontend/apps/desk/src/app/InnerLayout.tsx @@ -11,11 +11,7 @@ export default function InnerLayout({
- + {children} diff --git a/src/frontend/apps/desk/src/components/Box.tsx b/src/frontend/apps/desk/src/components/Box.tsx index 30733bc..f51ca6f 100644 --- a/src/frontend/apps/desk/src/components/Box.tsx +++ b/src/frontend/apps/desk/src/components/Box.tsx @@ -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; + export const Box = styled('div')` display: flex; flex-direction: column; diff --git a/src/frontend/apps/desk/src/components/InfiniteScroll.tsx b/src/frontend/apps/desk/src/components/InfiniteScroll.tsx new file mode 100644 index 0000000..112a105 --- /dev/null +++ b/src/frontend/apps/desk/src/components/InfiniteScroll.tsx @@ -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) => { + const timeout = useRef>(); + + 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 {children}; +}; diff --git a/src/frontend/apps/desk/src/features/teams/__tests__/PanelTeams.test.tsx b/src/frontend/apps/desk/src/features/teams/__tests__/PanelTeams.test.tsx index 1573ddf..ff715b6 100644 --- a/src/frontend/apps/desk/src/features/teams/__tests__/PanelTeams.test.tsx +++ b/src/frontend/apps/desk/src/features/teams/__tests__/PanelTeams.test.tsx @@ -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 () => { diff --git a/src/frontend/apps/desk/src/features/teams/api/useTeams.tsx b/src/frontend/apps/desk/src/features/teams/api/useTeams.tsx index ee89dd7..bfc0a50 100644 --- a/src/frontend/apps/desk/src/features/teams/api/useTeams.tsx +++ b/src/frontend/apps/desk/src/features/teams/api/useTeams.tsx @@ -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; @@ -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, + QueryKey, + number >, ) { - return useQuery({ + return useInfiniteQuery< + TeamsResponse, + TeamsResponseError, + InfiniteData, + 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, }); } diff --git a/src/frontend/apps/desk/src/features/teams/components/PanelTeam.tsx b/src/frontend/apps/desk/src/features/teams/components/PanelTeam.tsx new file mode 100644 index 0000000..d3e9524 --- /dev/null +++ b/src/frontend/apps/desk/src/features/teams/components/PanelTeam.tsx @@ -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 ( + + {team.accesses.length ? ( + + ) : ( + + )} + {team.name} + + ); +}; diff --git a/src/frontend/apps/desk/src/features/teams/components/PanelTeams.tsx b/src/frontend/apps/desk/src/features/teams/components/PanelTeams.tsx index d598878..8f0fbe7 100644 --- a/src/frontend/apps/desk/src/features/teams/components/PanelTeams.tsx +++ b/src/frontend/apps/desk/src/features/teams/components/PanelTeams.tsx @@ -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 ( - - - - ); - } +interface PanelTeamsStateProps { + isLoading: boolean; + isError: boolean; + teams?: TeamResponse[]; +} + +const PanelTeamsState = ({ + isLoading, + isError, + teams, +}: PanelTeamsStateProps) => { + const { t } = useTranslation(); if (isError) { return ( - + {t('Something bad happens, please refresh the page.')} @@ -36,9 +33,17 @@ export const PanelTeams = () => { ); } - if (!data.count) { + if (isLoading) { return ( - + + + + ); + } + + if (!teams?.length) { + return ( + {t('0 group to display.')} @@ -51,42 +56,48 @@ export const PanelTeams = () => { ); } + return teams.map((team) => ); +}; + +export const PanelTeams = () => { + const ordering = useTeamStore((state) => state.ordering); + const { + data, + isError, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useTeams({ + ordering, + }); + const containerRef = useRef(null); + const teams = useMemo(() => { + return data?.pages.reduce((acc, page) => { + return acc.concat(page.results); + }, [] as TeamResponse[]); + }, [data?.pages]); + return ( - - {data?.results.map((team) => ( - - {team.accesses.length ? ( - - ) : ( - - )} - {team.name} - - ))} + + { + void fetchNextPage(); + }} + scrollContainer={containerRef.current} + $gap="1rem" + as="ul" + className="p-s mt-t" + role="listbox" + > + + ); }; diff --git a/src/frontend/apps/e2e/__tests__/app-desk/teams.spec.ts b/src/frontend/apps/e2e/__tests__/app-desk/teams.spec.ts index ef59066..4535fdd 100644 --- a/src/frontend/apps/e2e/__tests__/app-desk/teams.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-desk/teams.spec.ts @@ -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); + }); }); diff --git a/src/frontend/apps/e2e/__tests__/helpers.ts b/src/frontend/apps/e2e/__tests__/helpers.ts new file mode 100644 index 0000000..678b555 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/helpers.ts @@ -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}`, + ); +}