@shopgate/pwa-common
Version:
Common library for the Shopgate Connect PWA.
448 lines (424 loc) • 14.2 kB
JavaScript
import _inheritsLoose from "@babel/runtime/helpers/inheritsLoose";
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import throttle from 'lodash/throttle';
import isEqual from 'lodash/isEqual';
import { router } from '@virtuous/conductor';
import { RouteContext } from "../../context";
import { ITEMS_PER_LOAD } from "../../constants/DisplayOptions";
/**
* This component receives a data source and will then load
* more items from it when the user reaches the end of the
* (parent) scroll container.
*/
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
let InfiniteContainer = /*#__PURE__*/function (_Component) {
/**
* The component constructor.
* @param {Object} props The component props.
* @param {Object} context The component context.
*/
function InfiniteContainer(props, context) {
var _this;
_this = _Component.call(this, props, context) || this;
_this.domScrollContainer = null;
/**
* 10ms was chosen because, on the one hand, it prevents the scroll event from flooding but,
* on the other hand, it does not hinder users that scroll quickly from reloading next chunk.
*/
_this.handleLoadingProxy = throttle(() => {
if (props.enablePromiseBasedLoading) {
_this.handleLoadingPromise();
} else {
_this.handleLoading();
}
}, 10);
// A flag to prevent concurrent loading requests.
_this.isLoading = false;
// Determine the initial offset of items.
const {
items,
limit,
initialLimit
} = props;
const currentOffset = items.length ? initialLimit : limit;
const {
state: {
offset = 0
} = {}
} = context || {};
_this.state = {
itemCount: items.length,
offset: [offset, currentOffset],
// A state flag that will be true as long as we await more items.
// The loading indicator will be shown accordingly.
awaitingItems: true
};
return _this;
}
/**
* When the component is mounted, it tries to find a proper
* parent scroll container if available.
* After that it calls for the initial data to load.
*/
_inheritsLoose(InfiniteContainer, _Component);
var _proto = InfiniteContainer.prototype;
_proto.componentDidMount = function componentDidMount() {
const {
current
} = this.props.containerRef;
if (current) {
this.domScrollContainer = current;
this.bindEvents();
}
// Initially request items if none received.
if (!this.props.items.length) {
const [start] = this.state.offset;
this.props.loader(start);
}
this.verifyAllDone();
}
/**
* Checks if the component received new items or already received all items.
* @param {Object} nextProps The next props.
*/;
_proto.UNSAFE_componentWillReceiveProps = function UNSAFE_componentWillReceiveProps(nextProps) {
/**
* Downstream logic to process the props. It's wrapped into a separate function, since it might
* be executed after the state was updated to avoid race conditions.
*/
const finalize = () => {
const {
current
} = nextProps.containerRef;
if (!this.domScrollContainer && current) {
this.domScrollContainer = current;
this.bindEvents();
}
if (this.receivedTotalItems(nextProps)) {
// Trigger loading if totalItems are available
if (nextProps.enablePromiseBasedLoading) {
this.handleLoadingPromise(true, nextProps);
} else {
this.handleLoading(true, nextProps);
}
}
this.verifyAllDone(nextProps);
};
if (nextProps.requestHash !== this.props.requestHash) {
this.resetComponent(() => {
finalize();
});
return;
}
if (nextProps.items.length >= this.state.itemCount) {
this.setState({
itemCount: nextProps.items.length
}, finalize());
} else {
this.resetComponent(() => {
finalize();
});
}
}
/**
* Let the component only update when props.items or state changes.
* @param {Object} nextProps The next component props.
* @param {Object} nextState The next component state.
* @returns {boolean}
*/;
_proto.shouldComponentUpdate = function shouldComponentUpdate(nextProps, nextState) {
return !isEqual(this.props.containerRef, nextProps.containerRef) || !isEqual(this.props.columns, nextProps.columns) || !isEqual(this.props.items, nextProps.items) || !isEqual(this.state, nextState);
}
/**
* Reset the loading flag.
*/;
_proto.componentDidUpdate = function componentDidUpdate() {
// When promise based implementation is active, `isLoading` is reset when response comes in.
// In the legacy implementation this happens after the fetched items reached the component and
// is not necessary here anymore.
if (!this.props.enablePromiseBasedLoading) {
this.isLoading = false;
}
}
/**
* When the component will unmount it unbinds all previously bound event listeners.
*/;
_proto.componentWillUnmount = function componentWillUnmount() {
router.update(this.context.id, {
offset: this.state.offset[0]
}, false);
this.unbindEvents();
}
/**
* Adds scroll event listeners to the scroll container if available.
*/;
_proto.bindEvents = function bindEvents() {
if (this.domScrollContainer) {
this.domScrollContainer.addEventListener('scroll', this.handleLoadingProxy);
}
}
/**
* Removes scroll event listeners from the scroll container.
* @param {Node} container A reference to an old scroll container.
*/;
_proto.unbindEvents = function unbindEvents(container) {
if (container) {
container.removeEventListener('scroll', this.handleLoadingProxy);
} else if (this.domScrollContainer) {
this.domScrollContainer.removeEventListener('scroll', this.handleLoadingProxy);
}
}
/**
* Tests if there are more items to be received via items prop.
* @param {Object} [props] The current or next component props.
* @returns {boolean}
*/;
_proto.needsToReceiveItems = function needsToReceiveItems(props = this.props) {
return props.totalItems === null || props.items.length < props.totalItems;
}
/**
* Tests if the total amount of items has been received via totalItems prop.
* @param {Object} nextProps The next component props.
* @returns {boolean}
*/;
_proto.receivedTotalItems = function receivedTotalItems(nextProps) {
return nextProps.totalItems !== null && nextProps.totalItems !== this.props.totalItems;
}
/**
* Tests if all items have been received and are visible based on current offset.
* @param {Object} [props] The current or next component props.
* @returns {boolean}
*/;
_proto.allItemsAreRendered = function allItemsAreRendered(props = this.props) {
const {
totalItems,
items
} = props;
const [offset, limit] = this.state.offset;
if (props.enablePromiseBasedLoading) {
// At promise based loading the offset is increased after the response came in.
// This method is invoked to evaluate if a new request needs to be dispatched, so we check
// against the current offset state.
return totalItems !== null && (offset >= totalItems || offset === 0 && Array.isArray(items) && items.length === totalItems);
}
return !this.needsToReceiveItems(props) && offset + limit >= totalItems;
}
/**
* Increases the current offset by limit (from props).
* @returns {Object}
*/;
_proto.increaseOffset = function increaseOffset() {
const [start, length] = this.state.offset;
let newOffset = start + length;
/**
* When items are cached, the initial limit can be "6".
* Then, new offset should be limited to the "normal" limit (30).
* Otherwise, with cached items, this component would skip the initial number of items
* when the cache is out.
*/
if (start % this.props.limit) {
// Example: when 6, bump to 30, not 36.
newOffset = this.props.limit;
}
this.setState({
offset: [newOffset, this.props.limit]
});
return {
offset: newOffset,
limit: this.props.limit
};
}
/**
* Resets the state.
* @param {Function} callback A callback which is invoked after the state was updated.
* This is necessary to avoid race conditions with downstream code.
*/;
_proto.resetComponent = function resetComponent(callback) {
this.setState({
offset: [0, this.props.limit],
awaitingItems: true,
itemCount: 0
}, () => {
this.unbindEvents();
this.bindEvents();
callback();
});
}
/**
* Stops the lazy loading processes
*/;
_proto.stopLazyLoading = function stopLazyLoading() {
this.setState({
awaitingItems: false
});
this.unbindEvents();
}
/**
* Verifies if all items are loaded and shown, then set final state and unbind events.
* @param {Object} [props] The current or next component props.
* @returns {boolean} Returns true if the component has reached the final state.
*/;
_proto.verifyAllDone = function verifyAllDone(props = this.props) {
if (this.allItemsAreRendered(props)) {
this.stopLazyLoading();
return true;
}
return false;
}
/**
* Tests if the current scroll position is near the bottom
* of the scroll container.
* @returns {boolean}
*/;
_proto.validateScrollPosition = function validateScrollPosition() {
if (!this.domScrollContainer) {
return true;
}
let scrollTop;
let scrollHeight;
let clientHeight;
if (this.domScrollContainer === window) {
const body = document.querySelector('body');
scrollTop = window.scrollY;
({
scrollHeight,
clientHeight
} = body);
} else {
({
scrollTop,
scrollHeight,
clientHeight
} = this.domScrollContainer);
}
const {
preloadMultiplier
} = this.props;
const scrollPosition = scrollTop + clientHeight;
const scrollThreshold = scrollHeight - clientHeight * preloadMultiplier;
return scrollPosition > scrollThreshold;
}
/**
* Handles incrementing of render offset and the request of new items if necessary.
* @param {boolean} [force] If set to true, proceed independently of scroll validation.
* @param {Object} [props] The current or next component props.
*/;
_proto.handleLoading = function handleLoading(force = false, props = this.props) {
// Do not load if there is an update in progress.
if (this.isLoading) {
return;
}
if (this.verifyAllDone()) {
return;
}
const [start, length] = this.state.offset;
const {
items,
totalItems,
loader
} = props;
const renderLength = start + length;
if (force || this.validateScrollPosition()) {
// Check if we need to render items that we already received.
if (renderLength <= items.length) {
// Render already received items by increasing the offset.
this.isLoading = true;
this.increaseOffset();
} else if (items.length < totalItems) {
// We already rendered all received items but there are more available.
// Therefore request new items.
this.isLoading = true;
loader(start);
// If necessary increase render offset for upcoming items.
if (renderLength < items.length + length) {
this.increaseOffset();
}
}
}
}
/**
* Handles incrementing of render offset and the request of new items if necessary.
*
* Other than the regular handleLoading method this one requires that the loader returns a promise
* that can be used to check if we received a response for a request. That check is needed
* for offset handling.
* @param {boolean} [force] If set to true, proceed independently of scroll validation.
* @param {Object} [props] The current or next component props.
*/;
_proto.handleLoadingPromise = async function handleLoadingPromise(force = false, props = this.props) {
if (this.isLoading) {
return;
}
if (this.verifyAllDone()) {
return;
}
if (force || this.validateScrollPosition()) {
// Add isLoading state to prevent requests while the current one is running
this.isLoading = true;
const {
loader
} = props;
try {
const [offset] = this.state.offset;
// Dispatch the request
await loader(offset);
// Increase the offset for the next request
this.increaseOffset();
} catch (e) {
// Stop lazy loading processes on request error
this.stopLazyLoading();
}
// Remove the loading state to enable next request
this.isLoading = false;
}
}
/**
* Renders the component.
* @returns {JSX}
*/;
_proto.render = function render() {
const {
wrapper,
items,
iterator,
loadingIndicator,
columns
} = this.props;
const {
awaitingItems
} = this.state;
const [start, length] = this.state.offset;
// Only show items in offset range. uses iterator component as item factory
const children = items.slice(0, start + length).map(item => iterator({
...item,
columns
}));
const content = typeof wrapper === 'function' ? wrapper({
children
}) : (/*#__PURE__*/React.createElement(wrapper, {}, children));
return /*#__PURE__*/_jsxs("div", {
className: "common__infinite-container",
children: [/*#__PURE__*/_jsx("div", {
children: content
}), awaitingItems && loadingIndicator]
});
};
return InfiniteContainer;
}(Component);
InfiniteContainer.contextType = RouteContext;
InfiniteContainer.defaultProps = {
columns: 2,
containerRef: {
current: null
},
initialLimit: 10,
limit: ITEMS_PER_LOAD,
loadingIndicator: null,
preloadMultiplier: 2,
requestHash: null,
totalItems: null,
wrapper: 'div',
enablePromiseBasedLoading: false
};
export default InfiniteContainer;