@shopgate/pwa-common
Version:
Common library for the Shopgate Connect PWA.
110 lines • 18.3 kB
JavaScript
import _regeneratorRuntime from"@babel/runtime/regenerator";function _typeof(obj){if(typeof Symbol==="function"&&typeof Symbol.iterator==="symbol"){_typeof=function _typeof(obj){return typeof obj;};}else{_typeof=function _typeof(obj){return obj&&typeof Symbol==="function"&&obj.constructor===Symbol&&obj!==Symbol.prototype?"symbol":typeof obj;};}return _typeof(obj);}function _extends(){_extends=Object.assign||function(target){for(var i=1;i<arguments.length;i++){var source=arguments[i];for(var key in source){if(Object.prototype.hasOwnProperty.call(source,key)){target[key]=source[key];}}}return target;};return _extends.apply(this,arguments);}function asyncGeneratorStep(gen,resolve,reject,_next,_throw,key,arg){try{var info=gen[key](arg);var value=info.value;}catch(error){reject(error);return;}if(info.done){resolve(value);}else{Promise.resolve(value).then(_next,_throw);}}function _asyncToGenerator(fn){return function(){var self=this,args=arguments;return new Promise(function(resolve,reject){var gen=fn.apply(self,args);function _next(value){asyncGeneratorStep(gen,resolve,reject,_next,_throw,"next",value);}function _throw(err){asyncGeneratorStep(gen,resolve,reject,_next,_throw,"throw",err);}_next(undefined);});};}function _slicedToArray(arr,i){return _arrayWithHoles(arr)||_iterableToArrayLimit(arr,i)||_nonIterableRest();}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance");}function _iterableToArrayLimit(arr,i){var _arr=[];var _n=true;var _d=false;var _e=undefined;try{for(var _i=arr[Symbol.iterator](),_s;!(_n=(_s=_i.next()).done);_n=true){_arr.push(_s.value);if(i&&_arr.length===i)break;}}catch(err){_d=true;_e=err;}finally{try{if(!_n&&_i["return"]!=null)_i["return"]();}finally{if(_d)throw _e;}}return _arr;}function _arrayWithHoles(arr){if(Array.isArray(arr))return arr;}function _classCallCheck(instance,Constructor){if(!(instance instanceof Constructor)){throw new TypeError("Cannot call a class as a function");}}function _defineProperties(target,props){for(var i=0;i<props.length;i++){var descriptor=props[i];descriptor.enumerable=descriptor.enumerable||false;descriptor.configurable=true;if("value"in descriptor)descriptor.writable=true;Object.defineProperty(target,descriptor.key,descriptor);}}function _createClass(Constructor,protoProps,staticProps){if(protoProps)_defineProperties(Constructor.prototype,protoProps);if(staticProps)_defineProperties(Constructor,staticProps);return Constructor;}function _callSuper(_this,derived,args){function isNativeReflectConstruct(){if(typeof Reflect==="undefined"||!Reflect.construct)return false;if(Reflect.construct.sham)return false;if(typeof Proxy==="function")return true;try{return!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],function(){}));}catch(e){return false;}}derived=_getPrototypeOf(derived);return _possibleConstructorReturn(_this,isNativeReflectConstruct()?Reflect.construct(derived,args||[],_getPrototypeOf(_this).constructor):derived.apply(_this,args));}function _possibleConstructorReturn(self,call){if(call&&(_typeof(call)==="object"||typeof call==="function")){return call;}return _assertThisInitialized(self);}function _assertThisInitialized(self){if(self===void 0){throw new ReferenceError("this hasn't been initialised - super() hasn't been called");}return self;}function _getPrototypeOf(o){_getPrototypeOf=Object.setPrototypeOf?Object.getPrototypeOf:function _getPrototypeOf(o){return o.__proto__||Object.getPrototypeOf(o);};return _getPrototypeOf(o);}function _inherits(subClass,superClass){if(typeof superClass!=="function"&&superClass!==null){throw new TypeError("Super expression must either be null or a function");}subClass.prototype=Object.create(superClass&&superClass.prototype,{constructor:{value:subClass,writable:true,configurable:true}});if(superClass)_setPrototypeOf(subClass,superClass);}function _setPrototypeOf(o,p){_setPrototypeOf=Object.setPrototypeOf||function _setPrototypeOf(o,p){o.__proto__=p;return o;};return _setPrototypeOf(o,p);}function _defineProperty(obj,key,value){if(key in obj){Object.defineProperty(obj,key,{value:value,enumerable:true,configurable:true,writable:true});}else{obj[key]=value;}return obj;}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.
*/var InfiniteContainer=/*#__PURE__*/function(_Component){/**
* The component constructor.
* @param {Object} props The component props.
* @param {Object} context The component context.
*/function InfiniteContainer(props,context){var _this2;_classCallCheck(this,InfiniteContainer);_this2=_callSuper(this,InfiniteContainer,[props,context]);_this2.domElement=null;_this2.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.
*/_this2.handleLoadingProxy=throttle(function(){if(props.enablePromiseBasedLoading){_this2.handleLoadingPromise();}else{_this2.handleLoading();}},10);// A flag to prevent concurrent loading requests.
_this2.isLoading=false;// Determine the initial offset of items.
var items=props.items,limit=props.limit,initialLimit=props.initialLimit;var currentOffset=items.length?initialLimit:limit;var _ref=context||{},_ref$state=_ref.state,_ref$state2=_ref$state===void 0?{}:_ref$state,_ref$state2$offset=_ref$state2.offset,offset=_ref$state2$offset===void 0?0:_ref$state2$offset;_this2.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 _this2;}/**
* 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.
*/_inherits(InfiniteContainer,_Component);return _createClass(InfiniteContainer,[{key:"componentDidMount",value:function componentDidMount(){var current=this.props.containerRef.current;if(current){this.domScrollContainer=current;this.bindEvents();}// Initially request items if none received.
if(!this.props.items.length){var _this$state$offset2=_slicedToArray(this.state.offset,1),start=_this$state$offset2[0];this.props.loader(start);}this.verifyAllDone();}/**
* Checks if the component received new items or already received all items.
* @param {Object} nextProps The next props.
*/},{key:"UNSAFE_componentWillReceiveProps",value:function UNSAFE_componentWillReceiveProps(nextProps){var _this3=this;/**
* 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.
*/var finalize=function finalize(){var current=nextProps.containerRef.current;if(!_this3.domScrollContainer&¤t){_this3.domScrollContainer=current;_this3.bindEvents();}if(_this3.receivedTotalItems(nextProps)){// Trigger loading if totalItems are available
if(nextProps.enablePromiseBasedLoading){_this3.handleLoadingPromise(true,nextProps);}else{_this3.handleLoading(true,nextProps);}}_this3.verifyAllDone(nextProps);};if(nextProps.requestHash!==this.props.requestHash){this.resetComponent(function(){finalize();});return;}if(nextProps.items.length>=this.state.itemCount){this.setState({itemCount:nextProps.items.length},finalize());}else{this.resetComponent(function(){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}
*/},{key:"shouldComponentUpdate",value:function shouldComponentUpdate(nextProps,nextState){return!isEqual(this.props.containerRef,nextProps.containerRef)||!isEqual(this.props.items,nextProps.items)||!isEqual(this.state,nextState);}/**
* Reset the loading flag.
*/},{key:"componentDidUpdate",value: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.
*/},{key:"componentWillUnmount",value: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.
*/},{key:"bindEvents",value: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.
*/},{key:"unbindEvents",value: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}
*/},{key:"needsToReceiveItems",value:function needsToReceiveItems(){var props=arguments.length>0&&arguments[0]!==undefined?arguments[0]: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}
*/},{key:"receivedTotalItems",value: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}
*/},{key:"allItemsAreRendered",value:function allItemsAreRendered(){var props=arguments.length>0&&arguments[0]!==undefined?arguments[0]:this.props;var totalItems=props.totalItems,items=props.items;var _this$state$offset3=_slicedToArray(this.state.offset,2),offset=_this$state$offset3[0],limit=_this$state$offset3[1];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}
*/},{key:"increaseOffset",value:function increaseOffset(){var _this$state$offset4=_slicedToArray(this.state.offset,2),start=_this$state$offset4[0],length=_this$state$offset4[1];var 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.
*/},{key:"resetComponent",value:function resetComponent(callback){var _this4=this;this.setState({offset:[0,this.props.limit],awaitingItems:true,itemCount:0},function(){_this4.unbindEvents();_this4.bindEvents();callback();});}/**
* Stops the lazy loading processes
*/},{key:"stopLazyLoading",value: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.
*/},{key:"verifyAllDone",value:function verifyAllDone(){var props=arguments.length>0&&arguments[0]!==undefined?arguments[0]: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}
*/},{key:"validateScrollPosition",value:function validateScrollPosition(){if(!this.domScrollContainer){return true;}var scrollTop;var scrollHeight;var clientHeight;if(this.domScrollContainer===window){var body=document.querySelector('body');scrollTop=window.scrollY;scrollHeight=body.scrollHeight;clientHeight=body.clientHeight;}else{var _this$domScrollContai=this.domScrollContainer;scrollTop=_this$domScrollContai.scrollTop;scrollHeight=_this$domScrollContai.scrollHeight;clientHeight=_this$domScrollContai.clientHeight;}var preloadMultiplier=this.props.preloadMultiplier;var scrollPosition=scrollTop+clientHeight;var 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.
*/},{key:"handleLoading",value:function handleLoading(){var force=arguments.length>0&&arguments[0]!==undefined?arguments[0]:false;var props=arguments.length>1&&arguments[1]!==undefined?arguments[1]:this.props;// Do not load if there is an update in progress.
if(this.isLoading){return;}if(this.verifyAllDone()){return;}var _this$state$offset5=_slicedToArray(this.state.offset,2),start=_this$state$offset5[0],length=_this$state$offset5[1];var items=props.items,totalItems=props.totalItems,loader=props.loader;var 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.
*/},{key:"handleLoadingPromise",value:function(){var _handleLoadingPromise=_asyncToGenerator(/*#__PURE__*/_regeneratorRuntime.mark(function _callee(){var force,props,loader,_this$state$offset7,offset,_args=arguments;return _regeneratorRuntime.wrap(function _callee$(_context){while(1)switch(_context.prev=_context.next){case 0:force=_args.length>0&&_args[0]!==undefined?_args[0]:false;props=_args.length>1&&_args[1]!==undefined?_args[1]:this.props;if(!this.isLoading){_context.next=4;break;}return _context.abrupt("return");case 4:if(!this.verifyAllDone()){_context.next=6;break;}return _context.abrupt("return");case 6:if(!(force||this.validateScrollPosition())){_context.next=20;break;}// Add isLoading state to prevent requests while the current one is running
this.isLoading=true;loader=props.loader;_context.prev=9;_this$state$offset7=_slicedToArray(this.state.offset,1),offset=_this$state$offset7[0];// Dispatch the request
_context.next=13;return loader(offset);case 13:// Increase the offset for the next request
this.increaseOffset();_context.next=19;break;case 16:_context.prev=16;_context.t0=_context["catch"](9);// Stop lazy loading processes on request error
this.stopLazyLoading();case 19:// Remove the loading state to enable next request
this.isLoading=false;case 20:case"end":return _context.stop();}},_callee,this,[[9,16]]);}));function handleLoadingPromise(){return _handleLoadingPromise.apply(this,arguments);}return handleLoadingPromise;}()/**
* Renders the component.
* @returns {JSX}
*/},{key:"render",value:function render(){var _this5=this;var _this$props=this.props,wrapper=_this$props.wrapper,items=_this$props.items,iterator=_this$props.iterator,loadingIndicator=_this$props.loadingIndicator,columns=_this$props.columns;var awaitingItems=this.state.awaitingItems;var _this$state$offset8=_slicedToArray(this.state.offset,2),start=_this$state$offset8[0],length=_this$state$offset8[1];// Only show items in offset range. uses iterator component as item factory
var children=items.slice(0,start+length).map(function(item){return iterator(_extends({},item,{columns:columns}));});var content=typeof wrapper==='function'?wrapper({children:children}):React.createElement(wrapper,{},children);return React.createElement("div",{ref:function ref(elementRef){_this5.domElement=elementRef;},className:"common__infinite-container"},React.createElement("div",null,content),awaitingItems&&loadingIndicator);}}]);}(Component);_defineProperty(InfiniteContainer,"contextType",RouteContext);_defineProperty(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;