superdesk/superdesk-client-core

View on GitHub
scripts/core/itemList/LazyLoader.tsx

Summary

Maintainability
A
2 hrs
Test Coverage
import React from 'react';
import {gettext} from 'core/utils';
import {OrderedMap} from 'immutable';
import {EmptyState, ListItemLoader} from 'superdesk-ui-framework/react';

interface IProps<T> {
    pageSize: number;
    itemCount: number;
    padding?: string;
    getItemsByIds(ids: Array<string>): Promise<OrderedMap<string, T>>;
    loadMoreItems(from: number, to: number): Promise<OrderedMap<string, T>>;
    children: (items: OrderedMap<string, T>) => JSX.Element;
    'data-test-id'?: string;
}

interface IState<T> {
    items: OrderedMap<string, T>; // id, entity
    loading: boolean;
}

const messageStyles: React.CSSProperties = {
    padding: 20,
    textAlign: 'center',
    backgroundColor: 'transparent',
    borderBlockStart: '1px solid transparent',
};

function hasScrollbar(element: Element) {
    return element.clientHeight < element.scrollHeight;
}

export class LazyLoader<T> extends React.Component<IProps<T>, IState<T>> {
    private containerRef: any;
    private _mounted: boolean;

    constructor(props: IProps<T>) {
        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);
    }

    /**
     * Items that are about to be updated need to be removed and updated items added at the same index
     * because items use ITrackById as their ID, and ITrackById changes on every update
     */
    public updateItems(ids: Set<string>): void {
        const onlyLoadedIds = Array.from(ids).filter((id) => this.state.items.has(id));

        let promise: Promise<IState<T>['items']> = Promise.resolve(this.state.items);

        /**
         * Chaining required in order to be able to apply updates one by one.
         * Doing it one by one is required in order to be able to insert updated item at the same index.
         * Since ITrackById changes on every update, it would otherwise be hard to determine
         * which old item the new item is supposed to replace.
         */
        for (const id of Array.from(onlyLoadedIds)) {
            promise = promise.then((items) => {
                return this.props.getItemsByIds([id]).then((updates) => {
                    const item = items.get(id);
                    const index = items.toIndexedSeq().indexOf(item);

                    const start = items.slice(0, index);
                    const end = items.slice(index + 1, items.size);
                    //                            ^ removing item at index

                    const next = start.concat(updates).concat(end).toOrderedMap();

                    return next;
                });
            });
        }

        promise.then((next) => {
            this.setState({
                items: next,
            });
        });
    }

    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<T>, prevState: IState<T>) {
        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 (
            <div
                style={{display: 'flex', flexDirection: 'column', maxHeight: '100%'}}
                data-test-id={this.props['data-test-id']}
            >
                <div
                    style={{
                        maxHeight: '100%',
                        overflow: 'auto',
                        flexGrow: 1,
                        padding: this.props.padding ?? '0',
                    }}
                    onScroll={(event) => {
                        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)}
                    {(() => {
                        if (loading === true) {
                            return (
                                <ListItemLoader />
                            );
                        } else if (this.allItemsLoaded()) {
                            if (this.getLoadedItemsCount() === 0) {
                                return (
                                    <EmptyState
                                        title={gettext('There are currently no items')}
                                        size="large"
                                        absolutePositioned
                                        illustration="3"
                                    />
                                );
                            } else {
                                return (
                                    <div style={messageStyles}>
                                        <div className="label label--large label--translucent label--no-transform">
                                            {gettext('All items have been loaded')}
                                        </div>
                                    </div>
                                );
                            }
                        } else {
                            return null;
                        }
                    })()}
                </div>
            </div>
        );
    }
}