import React from 'react'; import {gettext} from 'core/utils'; import {OrderedMap} from 'immutable'; interface IProps { pageSize: number; itemCount: number; padding?: string; getItemsByIds(ids: Array): Promise>; loadMoreItems(from: number, to: number): Promise>; children: (items: OrderedMap) => JSX.Element; 'data-test-id'?: string; } interface IState { items: OrderedMap; // id, entity loading: boolean; } const messageStyles: React.CSSProperties = { padding: 20, textAlign: 'center', backgroundColor: 'white', borderTop: '1px solid #ebebeb', }; function hasScrollbar(element: Element) { return element.clientHeight < element.scrollHeight; } export class LazyLoader extends React.Component, IState> { private containerRef: any; private _mounted: boolean; constructor(props: IProps) { super(props); this.state = { items: OrderedMap(), loading: true, }; this.loadMore = this.loadMore.bind(this); this.allItemsLoaded = this.allItemsLoaded.bind(this); this.getLoadedItemsCount = this.getLoadedItemsCount.bind(this); this.reset = this.reset.bind(this); this.reloadAllItems = this.reloadAllItems.bind(this); this.updateItems = this.updateItems.bind(this); } public updateItems(ids: Set): void { const {items} = this.state; const onlyLoadedIds = Array.from(ids).filter((id) => items.has(id)); this.props.getItemsByIds(onlyLoadedIds).then((updates) => { this.setState({ items: items.merge(updates), }); }); } private reloadAllItems() { const MAX_PAGE_SIZE = 200; // back-end limit const loadedItemsCount = this.state.items.size; const pages = loadedItemsCount > 0 ? new Array(Math.ceil(loadedItemsCount / MAX_PAGE_SIZE)).fill(null).map((_, i) => { const to = (i + 1) * MAX_PAGE_SIZE; const to_limited = Math.min(to, loadedItemsCount); const from = to - MAX_PAGE_SIZE; return {from: from, to: to_limited}; }) : ( [{from: this.state.items.size, to: this.state.items.size + this.props.pageSize}] ); if (this._mounted) { this.setState({ loading: true, }, () => { Promise.all( pages.map(({from, to}) => this.props.loadMoreItems(from, to)), ).then((res) => { this.setState({ items: res.reduce((acc, item) => acc.merge(item)), loading: false, }); }); }); } } public reset(): void { this.reloadAllItems(); } private loadMore() { this.setState({loading: true}); const {items} = this.state; const from = items.size; const to = from + this.props.pageSize; this.props.loadMoreItems(from, to).then((moreItems) => { this.setState({ items: items.merge(moreItems), loading: false, }); }); } private allItemsLoaded() { const {items} = this.state; const from = items.size; const loadedCount = items.size; return Math.max(from, loadedCount) >= this.props.itemCount; } private getLoadedItemsCount() { return this.state.items.size; } componentDidMount() { this._mounted = true; this.loadMore(); } componentWillUnmount() { this._mounted = false; } componentDidUpdate(prevProps: IProps, prevState: IState) { if (!this.state.loading && this.state.items !== prevState.items) { // Ensure there are enough items for the scrollbar to appear. // Lazy loading wouldn't work otherwise because it depends on "scroll" event firing. if (hasScrollbar(this.containerRef) !== true && this.allItemsLoaded() !== true) { this.loadMore(); } } } render() { const {loading, items} = this.state; return (
{ if (loading || this.allItemsLoaded()) { return; } const {scrollHeight, offsetHeight, scrollTop} = (event.target as any); const reachedBottom = scrollHeight === Math.round(offsetHeight + scrollTop); if (reachedBottom) { this.loadMore(); } }} ref={(el) => { this.containerRef = el; }} > {this.getLoadedItemsCount() === 0 ? null : this.props.children(items)} {(() => { const loaderPosition: React.CSSProperties = this.getLoadedItemsCount() > 0 ? { position: 'absolute', bottom: 0, left: 0, width: '100%', } : {}; if (loading === true) { return (
{gettext('Loading...')}
); } else if (this.allItemsLoaded()) { if (this.getLoadedItemsCount() === 0) { return (
{gettext('There are currently no items.')}
); } else { return (
{gettext('All items have been loaded.')}
); } } else { return null; } })()}
); } }