Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/infinite scroll #174

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26,254 changes: 9 additions & 26,245 deletions cookbook-react/package-lock.json

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions cookbook-react/src/recipes/lists/infinite-scroll/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Infinite Scroll

A component that allows you to integrate an infinite scroll without much effort, it also does not negatively impact the performance of your application.

The component uses **Intersection Observer API** ([visit the official site](https://developer.mozilla.org/es/docs/Web/API/Intersection_Observer_API)) so as not to affect the performance of your application.

# Props

|Prop Name|Description|type
|-------------|--------------|-------|
|children|List of elements. pass them as if it were a wrapper. On this list, the moment when the user reaches the end to obtain the following pages will be calculated|`ReactNode`|
|onLoadMore|The function that will be called when the user reaches the end of the list. It is supposed that in this function you must add the logic that obtains the elements of the next page, and concatenates them with the list|`() => void`|
|isLoading|helps the component decide when to show loading|`boolean`|true/false
|hasMore|It is extremely important to notify the component if it should continue requesting the following pages or if it should no longer do so, this avoids unnecessary requests|`boolean`|true/false
|loader?|it's optional. 1). If you do not send anything, the component will show a default loading with a predefined text. 2) If you send a sting, the component will show a default loading with the sent text 3) If you send a `JSX.Element` the component will show that `JSX. Element`|`JSX.Element|string`
|endMessage?|it's optional. 1) If you don't send anything, the component will show a predefined text. 2) If you send a `sting`, the component will show the text sent. 3) If you send a `JSX.Element` the component will show that `JSX. Element`|`JSX.Element|string`
|className?|it's optional. Add any custom class you want in the listing container|`string`

## Examples
<InfiniteScroll
onLoadMore={nextPage}
hasMore={hasMore}
isLoading={isLoading}
loader={<div className={styles.loading}>Loading...</div>}
>
{list.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</InfiniteScroll>

## Demo

cd cookbook-react
npm install
npm run start

To see the demo use the url [http://localhost:3000/?Category=lists&component=infinite-scroll&compact=true](http:%20//%20localhost:%203000%20/?%20Category%20=%20lists%20&%20component%20=%20infinite-scroll%20&%20compact%20=%20true)
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import i18next from 'i18next';

i18next.addResources('en', 'InfiniteScroll', {
loading: '',
end: 'You came to the end',
error: 'An error has occurred'
});

i18next.addResources('es', 'InfiniteScroll', {
loading: '',
end: 'Llegaste al final',
error: 'Ha ocurrido un error'
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import React from 'react';
import { render, screen } from '@testing-library/react';

import InfiniteScroll from '.';

interface Entries {
isIntersecting: boolean;
}

beforeEach(() => {
class IntersectionObserver {
constructor(callback: (entries: Entries[]) => void) {
callback([
{
isIntersecting: true
}
]);
}

observe = jest.fn();

disconnect = jest.fn();

unobserve = jest.fn();
}

Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
configurable: true,
value: IntersectionObserver
});

Object.defineProperty(global, 'IntersectionObserver', {
writable: true,
configurable: true,
value: IntersectionObserver
});
});

const LIST_ITEMS = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5'];
const listComponentMock = () => (
<>
{LIST_ITEMS.map((item) => (
<li key={item} role="listitem">
{item}
</li>
))}
</>
);

describe('Component Infinite Scroll', () => {
test('Displays the list of items', () => {
render(
<InfiniteScroll hasMore isLoading={false} onLoadMore={jest.fn()}>
{listComponentMock()}
</InfiniteScroll>
);
const list = screen.getAllByRole('listitem');
expect(list.length).toBe(LIST_ITEMS.length);
});
test('The function onLoadMore is called', () => {
const nextPage = jest.fn();
render(
<InfiniteScroll hasMore isLoading={false} onLoadMore={nextPage}>
{listComponentMock()}
</InfiniteScroll>
);
expect(nextPage).toBeCalled();
});
test('When a class name is passed - add the class name to the list container', () => {
const nextPage = jest.fn();
const { container } = render(
<InfiniteScroll hasMore isLoading onLoadMore={nextPage} className="MyClassName">
{listComponentMock()}
</InfiniteScroll>
);
expect(container.firstChild).toHaveClass('MyClassName');
});
test('when a component loading is passed - Displays the loading component when the list is loading', () => {
const myComponentLoader = <div>My Loading Component...</div>;
const nextPage = jest.fn();
render(
<InfiniteScroll hasMore isLoading onLoadMore={nextPage} loader={myComponentLoader}>
{listComponentMock()}
</InfiniteScroll>
);

const componentWanted = screen.queryByText('My Loading Component...');
const defaultLoading = screen.queryByTestId('default-message');

expect(defaultLoading).not.toBeInTheDocument();
expect(componentWanted).toBeInTheDocument();
});
test('when a text loading is passed - Displays the text with a spinner when the list is loading', () => {
const nextPage = jest.fn();
render(
<InfiniteScroll hasMore isLoading onLoadMore={nextPage} loader="String for Loading">
{listComponentMock()}
</InfiniteScroll>
);

const textWanted = screen.queryByText('String for Loading');
const defaultLoading = screen.queryByTestId('default-message');
const spinnerWanted = screen.queryByTestId('spinner');

expect(defaultLoading).not.toBeInTheDocument();
expect(textWanted).toBeInTheDocument();
expect(spinnerWanted).toBeInTheDocument();
});
test('when a loading is not passed - Displays the default text with a spinner when the list is loading', () => {
const nextPage = jest.fn();
render(
<InfiniteScroll hasMore isLoading onLoadMore={nextPage}>
{listComponentMock()}
</InfiniteScroll>
);

const defaultLoading = screen.queryByTestId('default-message');
const spinnerWanted = screen.queryByTestId('spinner');
expect(defaultLoading).toBeInTheDocument();
expect(spinnerWanted).toBeInTheDocument();
});
test('when a component end message is passed - Displays the end message component when the list is finished', () => {
const myComponentEndMessage = <div>Finish List</div>;
const nextPage = jest.fn();
render(
<InfiniteScroll
hasMore={false}
isLoading={false}
onLoadMore={nextPage}
endMessage={myComponentEndMessage}
>
{listComponentMock()}
</InfiniteScroll>
);

const componentWanted = screen.queryByText('Finish List');
const defaultEndMessage = screen.queryByTestId('default-message');

expect(defaultEndMessage).not.toBeInTheDocument();
expect(componentWanted).toBeInTheDocument();
});
test('when a text end message is passed - Displays the text sent when the list is finished', () => {
const nextPage = jest.fn();
render(
<InfiniteScroll hasMore={false} isLoading={false} onLoadMore={nextPage} endMessage="Finish List">
{listComponentMock()}
</InfiniteScroll>
);

const textWanted = screen.queryByText('Finish List');
const defaultEndMessage = screen.queryByTestId('default-message');
const spinner = screen.queryByTestId('spinner');

expect(spinner).not.toBeInTheDocument();
expect(defaultEndMessage).not.toBeInTheDocument();
expect(textWanted).toBeInTheDocument();
});
test('when a end message is not passed - Displays the default end message when the list is finished', () => {
const nextPage = jest.fn();
render(
<InfiniteScroll hasMore={false} isLoading={false} onLoadMore={nextPage}>
{listComponentMock()}
</InfiniteScroll>
);

const defaultMessage = screen.queryByTestId('default-message');
const spinner = screen.queryByTestId('spinner');
expect(spinner).not.toBeInTheDocument();
expect(defaultMessage).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React, { useCallback, useEffect, useRef } from 'react';
import i18next from 'i18next';

import styles from './styles.module.scss';

type ElementType = JSX.Element | string;

interface Props {
children: React.ReactNode;
onLoadMore: () => void;
isLoading: boolean;
hasMore: boolean;
loader?: ElementType;
endMessage?: ElementType;
className?: string;
}

const getAlertMessage = (defaultText: string, element?: ElementType, loading?: boolean) => {
if (!element) {
return (
<div className={styles.alert} data-testid="default-message">
{loading && <div className={styles.spinner} data-testid="spinner" />} {defaultText}
</div>
);
}

if (typeof element === 'string') {
return (
<div className={styles.alert}>
{loading && <div className={styles.spinner} data-testid="spinner" />} {element}
</div>
);
}

return element;
};

function InfiniteScroll({ children, onLoadMore, isLoading, hasMore, loader, endMessage, className }: Props) {
const contentListRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);

const handleNextPage = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [element] = entries;
if (element.isIntersecting && contentListRef.current && hasMore && !isLoading) {
onLoadMore();
}
},
[hasMore, isLoading, onLoadMore]
);

useEffect(() => {
const observer = new IntersectionObserver(handleNextPage, {
threshold: 0.9
});

if (contentListRef.current) {
observer.observe(contentListRef.current);
}

return () => observer.disconnect();
}, [handleNextPage]);

return (
<>
<div className={className} ref={containerRef}>
{children}
</div>
{isLoading && getAlertMessage(i18next.t('InfiniteScroll:loading'), loader, true)}
{!hasMore && getAlertMessage(i18next.t('InfiniteScroll:end'), endMessage)}
<div ref={contentListRef} />
</>
);
}

export default InfiniteScroll;
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.spinner {
animation: spin 1s ease infinite;
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #09F;
border-radius: 50%;
height: 36px;
margin: 0 auto 10px;
width: 36px;
}

@keyframes spin {
0% {
transform: rotate(0deg);
}

100% {
transform: rotate(360deg);
}
}

.alert {
font-style: italic;
margin: 15px auto;
max-width: 500px;
}
10 changes: 10 additions & 0 deletions cookbook-react/src/recipes/lists/infinite-scroll/cookbook.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"thumbnail": {
"type": "iframe",
"url": "https://cookbook-react.now.sh/?category=lists&component=infinite-scroll&compact=true"
},
"detail": {
"type": "iframe",
"url": "https://cookbook-react.now.sh/?category=lists&component=infinite-scroll&compact=true"
}
}
46 changes: 46 additions & 0 deletions cookbook-react/src/recipes/lists/infinite-scroll/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react';
import { render, screen } from '@testing-library/react';

import PageInfiniteScroll from '.';

interface Entries {
isIntersecting: boolean;
}

beforeEach(() => {
class IntersectionObserver {
constructor(callback: (entries: Entries[]) => void) {
callback([
{
isIntersecting: true
}
]);
}

observe = jest.fn();

disconnect = jest.fn();

unobserve = jest.fn();
}

Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
configurable: true,
value: IntersectionObserver
});

Object.defineProperty(global, 'IntersectionObserver', {
writable: true,
configurable: true,
value: IntersectionObserver
});
});

describe('Page Infinite Scroll', () => {
test('there are two radio type inputs on the screen', () => {
render(<PageInfiniteScroll />);
const radio = screen.getAllByRole('radio');
expect(radio.length).toBe(2);
});
});
Loading