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}`,
+ );
+}