⚡️(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 />
|
<Header />
|
||||||
<Box $css="flex: 1;" $direction="row">
|
<Box $css="flex: 1;" $direction="row">
|
||||||
<Menu />
|
<Menu />
|
||||||
<Box
|
<Box $height={`calc(100vh - ${HEADER_HEIGHT})`} $width="100%">
|
||||||
$height={`calc(100vh - ${HEADER_HEIGHT})`}
|
|
||||||
$width="100%"
|
|
||||||
$css="overflow: auto;"
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactHTML } from 'react';
|
import { ComponentPropsWithRef, ReactHTML } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { CSSProperties } from 'styled-components/dist/types';
|
import { CSSProperties } from 'styled-components/dist/types';
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@ export interface BoxProps {
|
|||||||
$width?: CSSProperties['width'];
|
$width?: CSSProperties['width'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BoxType = ComponentPropsWithRef<typeof Box>;
|
||||||
|
|
||||||
export const Box = styled('div')<BoxProps>`
|
export const Box = styled('div')<BoxProps>`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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';
|
import { PanelTeams } from '../components/PanelTeams';
|
||||||
|
|
||||||
|
window.HTMLElement.prototype.scroll = function () {};
|
||||||
|
|
||||||
describe('PanelTeams', () => {
|
describe('PanelTeams', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fetchMock.restore();
|
fetchMock.restore();
|
||||||
@@ -44,7 +46,9 @@ describe('PanelTeams', () => {
|
|||||||
|
|
||||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
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 () => {
|
it('renders with not team to display', async () => {
|
||||||
@@ -68,7 +72,7 @@ describe('PanelTeams', () => {
|
|||||||
|
|
||||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||||
|
|
||||||
expect(await screen.findByLabelText('Team icon')).toBeInTheDocument();
|
expect(await screen.findByLabelText('Teams icon')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the error', async () => {
|
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';
|
import { APIList, fetchAPI } from '@/api';
|
||||||
|
|
||||||
@@ -14,7 +19,7 @@ interface Access {
|
|||||||
user: string;
|
user: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TeamResponse {
|
export interface TeamResponse {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
accesses: Access[];
|
accesses: Access[];
|
||||||
@@ -26,7 +31,10 @@ export enum TeamsOrdering {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TeamsParams = {
|
export type TeamsParams = {
|
||||||
ordering?: TeamsOrdering;
|
ordering: TeamsOrdering;
|
||||||
|
};
|
||||||
|
type TeamsAPIParams = TeamsParams & {
|
||||||
|
page: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TeamsResponse = APIList<TeamResponse>;
|
type TeamsResponse = APIList<TeamResponse>;
|
||||||
@@ -34,10 +42,9 @@ export interface TeamsResponseError {
|
|||||||
detail: string;
|
detail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTeams = async (props?: TeamsParams) => {
|
export const getTeams = async ({ ordering, page }: TeamsAPIParams) => {
|
||||||
const response = await fetchAPI(
|
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
|
||||||
`teams/${props?.ordering ? `?ordering=${props.ordering}` : ''}`,
|
const response = await fetchAPI(`teams/?page=${page}${orderingQuery}`);
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Couldn't fetch teams: ${response.statusText}`);
|
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 const KEY_LIST_TEAM = 'teams';
|
||||||
|
|
||||||
export function useTeams(
|
export function useTeams(
|
||||||
param?: TeamsParams,
|
param: TeamsParams,
|
||||||
queryConfig?: UseQueryOptions<
|
queryConfig?: DefinedInitialDataInfiniteOptions<
|
||||||
TeamsResponse,
|
TeamsResponse,
|
||||||
TeamsResponseError,
|
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],
|
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,
|
...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 { Loader } from '@openfun/cunningham-react';
|
||||||
import React from 'react';
|
import React, { useMemo, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import IconGroup from '@/assets/icons/icon-group.svg';
|
|
||||||
import { Box, Text } from '@/components';
|
import { Box, Text } from '@/components';
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
import { InfiniteScroll } from '@/components/InfiniteScroll';
|
||||||
|
|
||||||
import { useTeams } from '../api/useTeams';
|
import { TeamResponse, useTeams } from '../api/useTeams';
|
||||||
import IconNone from '../assets/icon-none.svg';
|
|
||||||
import { useTeamStore } from '../store/useTeamsStore';
|
import { useTeamStore } from '../store/useTeamsStore';
|
||||||
|
|
||||||
export const PanelTeams = () => {
|
import { PanelTeam } from './PanelTeam';
|
||||||
const ordering = useTeamStore((state) => state.ordering);
|
|
||||||
const { data, isPending, isError } = useTeams({
|
|
||||||
ordering,
|
|
||||||
});
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { colorsTokens } = useCunninghamTheme();
|
|
||||||
|
|
||||||
if (isPending) {
|
interface PanelTeamsStateProps {
|
||||||
return (
|
isLoading: boolean;
|
||||||
<Box $align="center" className="m-l">
|
isError: boolean;
|
||||||
<Loader />
|
teams?: TeamResponse[];
|
||||||
</Box>
|
}
|
||||||
);
|
|
||||||
}
|
const PanelTeamsState = ({
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
teams,
|
||||||
|
}: PanelTeamsStateProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<Box $justify="center" className="m-b">
|
<Box $justify="center" className="mb-b">
|
||||||
<Text $theme="danger" $align="center" $textAlign="center">
|
<Text $theme="danger" $align="center" $textAlign="center">
|
||||||
{t('Something bad happens, please refresh the page.')}
|
{t('Something bad happens, please refresh the page.')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -36,9 +33,17 @@ export const PanelTeams = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.count) {
|
if (isLoading) {
|
||||||
return (
|
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">
|
<Text as="p" className="mb-0 mt-0" $theme="greyscale" $variation="500">
|
||||||
{t('0 group to display.')}
|
{t('0 group to display.')}
|
||||||
</Text>
|
</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 (
|
return (
|
||||||
<Box as="ul" $gap="1rem" className="p-s mt-t" $css="overflow:auto;">
|
<Box $css="overflow: auto;" ref={containerRef}>
|
||||||
{data?.results.map((team) => (
|
<InfiniteScroll
|
||||||
<Box
|
hasMore={hasNextPage}
|
||||||
as="li"
|
isLoading={isFetchingNextPage}
|
||||||
key={team.id}
|
next={() => {
|
||||||
$direction="row"
|
void fetchNextPage();
|
||||||
$align="center"
|
}}
|
||||||
$gap="0.5rem"
|
scrollContainer={containerRef.current}
|
||||||
>
|
$gap="1rem"
|
||||||
{team.accesses.length ? (
|
as="ul"
|
||||||
<IconGroup
|
className="p-s mt-t"
|
||||||
className="p-t"
|
role="listbox"
|
||||||
width={36}
|
>
|
||||||
aria-label={t(`Team icon`)}
|
<PanelTeamsState
|
||||||
color={colorsTokens()['primary-500']}
|
isLoading={isLoading}
|
||||||
style={{
|
isError={isError}
|
||||||
borderRadius: '10px',
|
teams={teams}
|
||||||
border: `1px solid ${colorsTokens()['primary-300']}`,
|
/>
|
||||||
}}
|
</InfiniteScroll>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<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>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { waitForElementCount } from '../helpers';
|
||||||
|
|
||||||
import { keyCloakSignIn } from './common';
|
import { keyCloakSignIn } from './common';
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
@@ -7,8 +9,9 @@ test.beforeEach(async ({ page }) => {
|
|||||||
await keyCloakSignIn(page);
|
await keyCloakSignIn(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
test.describe('Teams', () => {
|
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();
|
const panel = page.getByLabel('Teams panel').first();
|
||||||
|
|
||||||
await expect(panel.getByText('Recents')).toBeVisible();
|
await expect(panel.getByText('Recents')).toBeVisible();
|
||||||
@@ -32,14 +35,20 @@ test.describe('Teams', () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('check sort button', async ({ page }) => {
|
test('002 - check sort button', async ({ page, browserName }) => {
|
||||||
const panel = page.getByLabel('Teams panel').first();
|
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++) {
|
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 page.getByRole('button', { name: 'Create a team' }).click();
|
||||||
await expect(
|
await expect(
|
||||||
panel.locator('li').getByText(`team-sort${i}`),
|
panel.locator('li').nth(0).getByText(`${randomTeams[i]}-${i}`),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,11 +60,32 @@ test.describe('Teams', () => {
|
|||||||
|
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
await expect(
|
await expect(
|
||||||
panel
|
panel.locator('li').nth(i).getByText(`${randomTeams[i]}-${i}`),
|
||||||
.locator('li')
|
|
||||||
.nth(i)
|
|
||||||
.getByText(`team-sort${i + 1}`),
|
|
||||||
).toBeVisible();
|
).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