diff --git a/src/hocs/withData/index.js b/src/hocs/withData/index.js index 8b7c9c5..8078d5b 100644 --- a/src/hocs/withData/index.js +++ b/src/hocs/withData/index.js @@ -1,324 +1,10 @@ import { createElement, Component, PureComponent } from 'react'; - -const PAGINATION = { - TYPE: { - OFFSET_AND_LIMIT: 'offsetAndLimit', - CURSOR: 'cursor' - }, - MODE: { - INFINITE: 'infinite' - }, - PRESET: { - infiniteOffsetAndLimit: (initialSize, nextSize = initialSize) => ({ - type: PAGINATION.TYPE.OFFSET_AND_LIMIT, - mode: PAGINATION.MODE.INFINITE, - getNextPage: ({ limit, offset }) => { - if (offset === null) { - return { offset: 0, limit: initialSize }; - } - return { offset: offset + limit, limit: nextSize }; - } - }), - infiniteCursor: (startingCursor) => ({ - type: PAGINATION.TYPE.CURSOR, - mode: PAGINATION.MODE.INFINITE, - startingCursor, - getCursor: (r) => r.cursor, - reduceResults: (mem, l) => [...mem, ...l] - }) - } -}; - -const every = (predicate, collection) => { - for (let i = 0; i < collection.length; i++) { - if (!predicate(collection[i])) { - return false; - } - } - return true; -}; - -const any = (predicate, collection) => { - for (let i = 0; i < collection.length; i++) { - if (predicate(collection[i])) { - return true; - } - } - return false; -}; - -class Retriever { - constructor({ type, name, getter, getProps, publishData, publishError }) { - this.type = type; - this.name = name; - this.getter = getter; - this.getProps = getProps; - this.publishData = publishData; - this.publishError = publishError; - } - - get() { - const promise = this.getter(this.getProps()); - if (!promise || !promise.then) { - throw new Error(`${this.type} for ${this.name} did not return a promise!`); - } - return promise.then(this.publishData, this.publishError); - } - - // eslint-disable-next-line class-methods-use-this - mergeProps(props) { - return props; - } - - // eslint-disable-next-line class-methods-use-this - onDestroy() {} -} - -class ResolveRetriever extends Retriever { - constructor(args) { - super({ type: 'resolve', ...args }); - } -} - -class PollRetriever extends Retriever { - constructor(args) { - super({ type: 'poll', ...args }); - this.interval = args.interval ? setInterval(() => this.get(), args.interval) : null; - } - - onDestroy() { - if (this.interval) { - clearInterval(this.interval); - this.interval = null; - } - } -} - -class ObserveRetriever extends Retriever { - constructor(args) { - super({ type: 'observe', ...args }); - - this.subscription = null; - } - - get() { - const observable = this.getter(this.getProps()); - if (!observable || !observable) { - throw new Error(`${this.type} for ${this.name} did not expose a subscribe function`); - } - this.subscription = observable.subscribe(this.publishData, this.publishError); - return Promise.resolve(); - } - - onDestroy() { - if (this.subscription && this.subscription.unsubscribe) { - this.subscription.unsubscribe(); - } - } -} - -const mergePaginateProps = (props, name, obj) => ({ - ...props, - paginate: { - ...props.paginate, - [name]: obj - } -}); - -class PaginatedInfiniteCursorRetriever extends Retriever { - constructor({ pagerConfig, ...args}) { - super({ type: 'resolve with cursor', ...args }); - - this.pagerConfig = pagerConfig; - - this.pastCursors = []; - this.nextCursors = pagerConfig.startingCursor || null; - - this.hasNext = true; - - this.pending = null; - } - - get() { - if (this.pending) { - return this.pending; - } - - const props = this.getProps(); - - this.pending = Promise.all([ - ...this.pastCursors.map((cursor) => this.getter(props, cursor)), - this.getter(props, this.nextCursor).then(response => { - const nextCursor = this.pagerConfig.getCursor(response); - - this.pastCursors.push(this.nextCursor); - this.nextCursor = nextCursor; - this.hasNext = !!nextCursor; - this.pending = null; - - return response; - }) - ]).then((results) => this.publishData(results.reduce(this.pagerConfig.reduceResults))); - - this.pending.catch((err) => { - this.pending = null; - return Promise.reject(err); - }); - - return this.pending; - } - - mergeProps(props) { - return mergePaginateProps(props, this.name, { - getNext: () => this.get(), - hasNext: this.hasNext - }); - } -} - -class PaginatedInfiniteOffsetAndLimitResolveRetriever extends Retriever { - constructor({ pagerConfig, ...args }) { - super({ type: 'resolve paginated', ...args }); - this.pagerConfig = pagerConfig; - - this.pagers = []; - - this.queueNext(); - } - - get() { - const props = this.getProps(); - return Promise.all(this.pagers.map((pager) => this.getter(props, pager))).then( - (lists) => this.publishData(lists.reduce((mem, list) => [...mem, ...list], [])), - (err) => Promise.reject(err) - ); - } - - queueNext() { - const prevPager = this.pagers[this.pagers.length - 1] || { limit: null, offset: null }; - const nextPager = this.pagerConfig.getNextPage(prevPager); - this.pagers.push(nextPager); - } - - mergeProps(props) { - return mergePaginateProps(props, this.name, { - getNext: () => { - this.queueNext(); - return this.get(); - } - }); - } -} - -class PaginatedInfiniteOffsetAndLimitObserveRetriever extends Retriever { - constructor({ pagerConfig, ...args }) { - super({ type: 'observe paginated', ...args }); - this.pagerConfig = pagerConfig; - - this.pagerSubscriptions = []; - - this.queueNext(); - } - - get() { - const props = this.getProps(); - return new Promise((resolve) => { - const tryResolve = (d) => { - if (every(p_ => p_.data, this.pagerSubscriptions)) { - resolve(d); - } - }; - this.pagerSubscriptions = this.pagerSubscriptions.map((p) => { - if (!p.subscription) { - p.firstLoad = true; - p.subscription = this.getter(props, p.pager).subscribe( - (data) => { - p.data = data; - p.firstLoad = false; - this.tryToPublish(); - tryResolve(data); - }, - this.publishError - ); - } - return p; - }); - }); - } - - queueNext() { - const pagers = this.pagerSubscriptions.map((p) => p.pager); - const prevPager = pagers[pagers.length - 1] || { limit: null, offset: null }; - const nextPager = this.pagerConfig.getNextPage(prevPager); - const p = { - pager: nextPager, - subscription: null, - data: null, - error: null - }; - this.pagerSubscriptions.push(p); - } - - tryToPublish() { - const result = []; - for (let i = 0; i < this.pagerSubscriptions.length; i++) { - const p = this.pagerSubscriptions[i]; - if (!p.data) { - return; - } - result.push(...p.data); - } - this.publishData(result); - } - - mergeProps(props) { - return mergePaginateProps(props, this.name, { - getNext: () => { - this.queueNext(); - return this.get(); - }, - isLoading: any(p => !p.data, this.pagerSubscriptions) - }); - } - - onDestroy() { - this.pagerSubscriptions.forEach((p) => { - if (p.subscription && p.subscription.unsubscribe) { - p.subscription.unsubscribe(); - } - }); - } -} - -const isInfiniteOffsetAndLimitPager = ({ mode, type }) => { - return mode === PAGINATION.MODE.INFINITE && type === PAGINATION.TYPE.OFFSET_AND_LIMIT; -}; - -const isInfiniteCursor = ({ mode, type }) => { - return mode === PAGINATION.MODE.INFINITE && type === PAGINATION.TYPE.CURSOR; -}; - -const getResolveRetriever = (pagerConfig) => { - if (pagerConfig) { - if (isInfiniteOffsetAndLimitPager(pagerConfig)) { - return PaginatedInfiniteOffsetAndLimitResolveRetriever; - } - - if (isInfiniteCursor(pagerConfig)) { - return PaginatedInfiniteCursorRetriever; - } - } - return ResolveRetriever; -}; - -const getObserveRetriever = (pagerConfig) => { - if (pagerConfig) { - if (isInfiniteOffsetAndLimitPager(pagerConfig)) { - return PaginatedInfiniteOffsetAndLimitObserveRetriever; - } - } - return ObserveRetriever; -}; +import { + getResolveRetriever, + getObserveRetriever, + getPollRetriever, + PAGINATION +} from './retrievers'; class Container extends Component { constructor(props) { @@ -327,6 +13,10 @@ class Container extends Component { this.resolvedData = {}; this.resolvedDataTargetSize = 0; + this.timeouts = { + pendingScheduled: null + }; + this.retrievers = {}; this.subscriptions = []; @@ -350,7 +40,7 @@ class Container extends Component { componentWillMount() { this.setupRetrievers(this.props); - this.trigger(); + this.trigger({}); } componentWillReceiveProps(newProps) { @@ -360,7 +50,7 @@ class Container extends Component { } this.destroy(); this.setupRetrievers(newProps); - this.trigger(); + this.trigger(newProps.delays); } componentWillUnmount() { @@ -368,6 +58,10 @@ class Container extends Component { this.destroy(); } + shouldComponentUpdate() { + return this.rerender; + } + safeSetState(...args) { if (!this.isUnmounting) { this.setState(...args); @@ -377,8 +71,11 @@ class Container extends Component { addResolvedData(field, data) { this.resolvedData[field] = data; if (this.resolvedDataTargetSize === Object.keys(this.resolvedData).length) { + this.clearPendingTimeout(); + this.rerender = true; this.safeSetState({ pending: false, + pendingScheduled: false, resolvedProps: { ...this.resolvedData }, error: null }); @@ -386,7 +83,20 @@ class Container extends Component { } setError(field, error) { - this.safeSetState({ pending: false, error }); + this.clearPendingTimeout(); + this.safeSetState({ + pending: false, + pendingScheduled: false, + error + }); + } + + clearPendingTimeout() { + const { timeouts } = this; + if (timeouts.pendingScheduled) { + clearTimeout(timeouts.pendingScheduled); + timeouts.pendingScheduled = null; + } } setupRetrievers(props) { @@ -426,23 +136,40 @@ class Container extends Component { }); pollKeys.forEach((key) => { - const getter = poll[key].resolve; - const interval = (poll[key].interval || (() => null))(originalProps); - this.retrievers[key] = new PollRetriever({ + const pagerConfig = paginate[key]; + const Constructor = getPollRetriever(pagerConfig); + this.retrievers[key] = new Constructor({ name: key, publishData: publishData(key), publishError: publishError(key), getProps, - getter, - interval + getter: poll[key].resolve, + interval: (poll[key].interval || (() => null))(originalProps) }); }); this.resolvedDataTargetSize = resolveKeys.length + observeKeys.length + pollKeys.length; } - trigger() { - this.safeSetState({ pending: true, error: null }); + trigger(delays) { + this.rerender = false; + const update = () => { + this.rerender = true; + this.resolvedData = {}; + this.safeSetState({ pending: true, pendingScheduled: false, error: null }); + }; + if (delays.refetch) { + const { timeouts } = this; + timeouts.pendingScheduled = setTimeout(() => { + if (timeouts.pendingScheduled) { + update(); + this.clearPendingTimeout(); + } + }, delays.refetch); + this.safeSetState({ pendingScheduled: true }); + } else { + update(); + } Object.keys(this.retrievers).forEach((key) => { this.retrievers[key].get(); @@ -469,11 +196,20 @@ class Container extends Component { } } +const DEFAULT_DELAYS = { + refetch: 0 +}; + export function withData(conf) { return component => { class WithDataWrapper extends PureComponent { render() { - const props = { ...conf, originalProps: this.props, component }; + const props = { + ...conf, + delays: conf.delays || DEFAULT_DELAYS, + originalProps: this.props, + component + }; return createElement(Container, props); } } @@ -481,5 +217,10 @@ export function withData(conf) { }; } +// wait with refetch spinner +// wait with initial spinner +// +// minimum time for spinner + withData.PAGINATION = PAGINATION; diff --git a/src/hocs/withData/index.spec.js b/src/hocs/withData/index.spec.js index 4cba2ef..d6e2c5a 100644 --- a/src/hocs/withData/index.spec.js +++ b/src/hocs/withData/index.spec.js @@ -7,7 +7,7 @@ import sinon from 'sinon'; import { withData } from '.'; -const delay = (t = 1) => new Promise(res => setTimeout(() => res(), t)); +const wait = (t = 1) => new Promise(res => setTimeout(() => res(), t)); const peter = { id: 'peter', name: 'peter' }; const gernot = { id: 'gernot', name: 'gernot' }; @@ -69,8 +69,76 @@ const createConfig = () => { }; }; +class Logger { + constructor() { + this.logs = []; + } + + log(l) { + this.logs.push({ ...l, t: Date.now() }); + } + + getByType(t) { + return this.logs.filter(l => l.type === t); + } + + getRenders() { + return this.getByType('render'); + } + + getCallSequence() { + return this.logs.map(l => l.type); + } + + getRenderProps(count) { + return this.getRenders()[count].props; + } + + expectRenderCount(count) { + this.expectCountByType('render', count); + } + + expectCountByType(type, count) { + expect(this.getByType(type).length).to.equal(count); + } + + expectCallSequence(sequence) { + expect(this.getCallSequence()).to.deep.equal(sequence); + } +} + const createSpyComponent = () => { - return sinon.stub().returns(null); + const logger = new Logger(); + const log = (type, props, generation) => logger.log({ type, props, generation }); + + let generation = 0; + + class SpyComponent extends Component { + constructor() { + super(); + this.generation = generation++; + } + + componentWillMount() { + log('componentWillMount', this.props, this.generation); + } + + componentWillUnmount() { + log('componentWillUnmount', this.props, this.generation); + } + + // eslint-disable-next-line class-methods-use-this + componentWillReceiveProps(nextProps) { + log('componentWillReceiveProps', nextProps, this.generation); + } + + render() { + log('render', this.props, this.generation); + return null; + } + } + + return { spy: SpyComponent, logger }; }; class StateContainer extends Component { @@ -91,7 +159,7 @@ const render = (component, componentProps, ref, mapState = (t => t)) => { describe('withData', () => { it('passes the original properties down', () => { const api = build(createConfig()); - const spy = createSpyComponent(); + const { spy, logger } = createSpyComponent(); const comp = withData({ resolve: { users: () => api.user.getUsers(), @@ -101,16 +169,16 @@ describe('withData', () => { render(comp, { userId: 'peter' }); - return delay().then(() => { - expect(spy).to.have.been.called; - const props = spy.args[0][0]; + return wait().then(() => { + logger.expectRenderCount(1); + const props = logger.getRenderProps(0); expect(props.userId).to.equal('peter'); }); }); it('does not re-render when no props to it have changed', () => { const api = build(createConfig()); - const spy = createSpyComponent(); + const { spy, logger } = createSpyComponent(); const comp = withData({ resolve: { users: () => api.user.getUsers(), @@ -122,14 +190,14 @@ describe('withData', () => { render(comp, { userId: 'peter' }, c => { stateContainer = c; }, ({ userId }) => ({ userId })); - return delay().then(() => { - expect(spy).to.have.been.calledOnce; + return wait().then(() => { + logger.expectRenderCount(1); stateContainer.setState({ x: 'x' }); - return delay().then(() => { - expect(spy).to.have.been.calledOnce; + return wait().then(() => { + logger.expectRenderCount(1); stateContainer.setState({ userId: 'gernot' }); - return delay().then(() => { - expect(spy).to.have.been.calledThrice; + return wait().then(() => { + logger.expectRenderCount(2); }); }); }); @@ -137,7 +205,7 @@ describe('withData', () => { it('allows to observe changes', () => { const api = build(createConfig(), [observable()]); - const spy = createSpyComponent(); + const { spy, logger } = createSpyComponent(); const comp = withData({ observe: { user: ({ userId }) => api.user.getUser.createObservable(userId) @@ -146,25 +214,125 @@ describe('withData', () => { render(comp, { userId: 'peter' }); - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - const firstProps = spy.args[0][0]; + return wait().then(() => { + logger.expectRenderCount(1); + const firstProps = logger.getRenderProps(0); expect(firstProps.user).to.deep.equal(peter); return api.user.updateUser({ id: 'peter', name: 'crona' }).then((nextUser) => { - return delay().then(() => { - expect(spy).to.have.been.calledTwice; - const secondProps = spy.args[1][0]; + return wait().then(() => { + logger.expectRenderCount(2); + const secondProps = logger.getRenderProps(1); expect(secondProps.user).to.deep.equal(nextUser); }); }); }); }); + describe('delay', () => { + it('unmounts and remounts view component when no delay is specified on refecth', () => { + const api = build(createConfig()); + const { spy, logger } = createSpyComponent(); + const { spy: pendingSpy, logger: pendingLogger } = createSpyComponent(); + const comp = withData({ + resolve: { + user: ({ userId }) => api.user.getUser(userId) + }, + pendingComponent: pendingSpy, + delays: { + refetch: 0 + } + })(spy); + + let stateContainer = null; + + render(comp, { userId: 'peter' }, c => { stateContainer = c; }, ({ userId }) => ({ userId })); + + return wait().then(() => { + pendingLogger.expectRenderCount(1); + logger.expectRenderCount(1); + stateContainer.setState({ userId: 'gernot' }); + logger.expectRenderCount(1); + logger.expectCountByType('componentWillUnmount', 1); + return wait().then(() => { + logger.expectCountByType('componentWillMount', 2); + pendingLogger.expectRenderCount(2); + logger.expectRenderCount(2); + logger.expectCallSequence([ + 'componentWillMount', + 'render', + 'componentWillUnmount', + 'componentWillMount', + 'render' + ]); + }); + }); + }); + + it('does not show pending state immediately when delay is requested', () => { + const api = build(createConfig()); + const { spy, logger } = createSpyComponent(); + const { spy: pendingSpy, logger: pendingLogger } = createSpyComponent(); + const comp = withData({ + resolve: { + user: ({ userId }) => api.user.getUser(userId) + }, + pendingComponent: pendingSpy, + delays: { + refetch: 100 + } + })(spy); + + let stateContainer = null; + + render(comp, { userId: 'peter' }, c => { stateContainer = c; }, ({ userId }) => ({ userId })); + + return wait().then(() => { + pendingLogger.expectRenderCount(1); + logger.expectRenderCount(1); + stateContainer.setState({ userId: 'gernot' }); + logger.expectRenderCount(1); + return wait().then(() => { + pendingLogger.expectRenderCount(1); + logger.expectRenderCount(2); + }); + }); + }); + + it('does not needlessly unmount immediately when delay is requested', () => { + const api = build(createConfig()); + const { spy, logger } = createSpyComponent(); + const comp = withData({ + resolve: { + user: ({ userId }) => api.user.getUser(userId) + }, + delays: { + refetch: 100 + } + })(spy); + + let stateContainer = null; + + render(comp, { userId: 'peter' }, c => { stateContainer = c; }, ({ userId }) => ({ userId })); + + return wait().then(() => { + stateContainer.setState({ userId: 'gernot' }); + return wait().then(() => { + logger.expectCallSequence([ + 'componentWillMount', + 'render', + 'componentWillReceiveProps', + 'render' + ]); + }); + }); + }); + }); + describe('pagination', () => { it('allows to paginate with limit and offset (resolve)', () => { const api = build(createConfig(), [observable()]); - const spy = createSpyComponent(); + const { spy, logger } = createSpyComponent(); const comp = withData({ resolve: { users: (props, { limit, offset }) => api.user.getUsersPaginated({ limit, offset }) @@ -176,15 +344,15 @@ describe('withData', () => { render(comp, {}); - return delay().then(() => { - expect(spy).to.have.been.called; - const firstProps = spy.args[0][0]; + return wait().then(() => { + logger.expectRenderCount(1); + const firstProps = logger.getRenderProps(0); expect(firstProps.users).to.deep.equal([peter, gernot]); return firstProps.paginate.users.getNext().then(() => { - return delay().then(() => { - expect(spy).to.have.been.calledTwice; - const secondProps = spy.args[1][0]; + return wait().then(() => { + logger.expectRenderCount(2); + const secondProps = logger.getRenderProps(1); expect(secondProps.users).to.deep.equal([peter, gernot, robin]); }); }); @@ -193,7 +361,7 @@ describe('withData', () => { it('allows to paginate with limit and offset (observe)', () => { const api = build(createConfig(), [observable()]); - const spy = createSpyComponent(); + const { spy, logger } = createSpyComponent(); const comp = withData({ observe: { users: (props, { limit, offset }) => api.user.getUsersPaginated.createObservable({ @@ -208,19 +376,21 @@ describe('withData', () => { render(comp, {}); - return delay().then(() => { - expect(spy).to.have.been.called; - const firstProps = spy.args[0][0]; + return wait().then(() => { + logger.expectRenderCount(1); + const firstProps = logger.getRenderProps(0); expect(firstProps.users).to.deep.equal([peter, gernot]); return firstProps.paginate.users.getNext().then(() => { - expect(spy).to.have.been.calledTwice; - const secondProps = spy.args[1][0]; + logger.expectRenderCount(2); + const secondProps = logger.getRenderProps(1); expect(secondProps.users).to.deep.equal([peter, gernot, robin]); return api.user.updateUser({ id: 'peter', name: 'crona' }).then((nextUser) => { - return delay().then(() => { - const thirdProps = spy.args[2][0]; + return wait().then(() => { + // TODO: Why is there another render call here? + logger.expectRenderCount(4); + const thirdProps = logger.getRenderProps(2); expect(thirdProps.users[0]).to.deep.equal(nextUser); }); }); @@ -230,7 +400,7 @@ describe('withData', () => { it('allows to paginate with a cursor', () => { const api = build(createConfig(), [observable()]); - const spy = createSpyComponent(); + const { spy, logger } = createSpyComponent(); const comp = withData({ resolve: { users: (props, cursor) => api.user.getUsersWithCursor({ cursor, limit: 2 }) @@ -245,23 +415,23 @@ describe('withData', () => { render(comp, {}); - return delay().then(() => { - expect(spy).to.have.been.called; - const firstProps = spy.args[0][0]; + return wait().then(() => { + logger.expectRenderCount(1); + const firstProps = logger.getRenderProps(0); expect(firstProps.users.length).to.equal(2); expect(firstProps.users).to.contain(peter); expect(firstProps.users).to.contain(gernot); return firstProps.paginate.users.getNext().then(() => { - return delay().then(() => { - expect(spy).to.have.been.calledTwice; - const secondProps = spy.args[1][0]; + return wait().then(() => { + logger.expectRenderCount(2); + const secondProps = logger.getRenderProps(1); expect(secondProps.users).to.deep.equal([peter, gernot, robin, paulo]); expect(secondProps.paginate.users.hasNext).to.be.true; return secondProps.paginate.users.getNext().then(() => { - expect(spy).to.have.been.calledThrice; - const thirdProps = spy.args[2][0]; + logger.expectRenderCount(3); + const thirdProps = logger.getRenderProps(2); expect(thirdProps.users).to.deep.equal([peter, gernot, robin, paulo, timur]); expect(thirdProps.paginate.users.hasNext).to.be.false; }); @@ -274,7 +444,7 @@ describe('withData', () => { describe('poll', () => { it('does not poll when interval is set to a falsy value', () => { const api = build(createConfig()); - const spy = createSpyComponent(); + const { spy, logger } = createSpyComponent(); const comp = withData({ poll: { users: { @@ -286,17 +456,17 @@ describe('withData', () => { render(comp, {}); - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - return delay(10).then(() => { - expect(spy).to.have.been.calledOnce; + return wait().then(() => { + logger.expectRenderCount(1); + return wait(10).then(() => { + logger.expectRenderCount(1); }); }); }); it('does not poll when interval is not defined', () => { const api = build(createConfig()); - const spy = createSpyComponent(); + const { spy, logger } = createSpyComponent(); const comp = withData({ poll: { users: { @@ -307,10 +477,10 @@ describe('withData', () => { render(comp, {}); - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - return delay(10).then(() => { - expect(spy).to.have.been.calledOnce; + return wait().then(() => { + logger.expectRenderCount(1); + return wait(10).then(() => { + logger.expectRenderCount(1); }); }); }); @@ -320,7 +490,7 @@ describe('withData', () => { // to make this really robust! const api = build(createConfig()); - const spy = createSpyComponent(); + const { spy, logger } = createSpyComponent(); const comp = withData({ poll: { users: { @@ -332,12 +502,12 @@ describe('withData', () => { render(comp, {}); - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - return delay(10).then(() => { - expect(spy).to.have.been.calledTwice; - return delay(10).then(() => { - expect(spy).to.have.been.calledThrice; + return wait().then(() => { + logger.expectRenderCount(1); + return wait(10).then(() => { + logger.expectRenderCount(2); + return wait(10).then(() => { + logger.expectRenderCount(3); }); }); }); @@ -346,7 +516,7 @@ describe('withData', () => { describe('shouldRefetch', () => { it('does not trigger callbacks when returning false for new props', () => { - const spy = createSpyComponent(); + const { spy, logger } = createSpyComponent(); const spyResolve = sinon.stub().returns(Promise.resolve({})); const comp = withData({ @@ -362,19 +532,19 @@ describe('withData', () => { render(comp, { userId: 'peter' }, c => { stateContainer = c; }); - return delay().then(() => { - expect(spy).to.have.been.calledOnce; + return wait().then(() => { + logger.expectRenderCount(1); expect(spyResolve).to.have.been.calledOnce; stateContainer.setState({ userId: 'robin' }); - return delay().then(() => { + return wait().then(() => { expect(spyResolve).to.have.been.calledOnce; - expect(spy).to.have.been.calledTwice; + logger.expectRenderCount(2); stateContainer.setState({ userId: 'gernot' }); - return delay().then(() => { + return wait().then(() => { expect(spyResolve).to.have.been.calledTwice; - expect(spy).to.have.been.calledThrice; + logger.expectRenderCount(3); }); }); }); diff --git a/src/hocs/withData/retrievers.js b/src/hocs/withData/retrievers.js new file mode 100644 index 0000000..07c08b8 --- /dev/null +++ b/src/hocs/withData/retrievers.js @@ -0,0 +1,324 @@ +export const PAGINATION = { + TYPE: { + OFFSET_AND_LIMIT: 'offsetAndLimit', + CURSOR: 'cursor' + }, + MODE: { + INFINITE: 'infinite' + }, + PRESET: { + infiniteOffsetAndLimit: (initialSize, nextSize = initialSize) => ({ + type: PAGINATION.TYPE.OFFSET_AND_LIMIT, + mode: PAGINATION.MODE.INFINITE, + getNextPage: ({ limit, offset }) => { + if (offset === null) { + return { offset: 0, limit: initialSize }; + } + return { offset: offset + limit, limit: nextSize }; + } + }), + infiniteCursor: (startingCursor) => ({ + type: PAGINATION.TYPE.CURSOR, + mode: PAGINATION.MODE.INFINITE, + startingCursor, + getCursor: (r) => r.cursor, + reduceResults: (mem, l) => [...mem, ...l] + }) + } +}; + +const every = (predicate, collection) => { + for (let i = 0; i < collection.length; i++) { + if (!predicate(collection[i])) { + return false; + } + } + return true; +}; + +const any = (predicate, collection) => { + for (let i = 0; i < collection.length; i++) { + if (predicate(collection[i])) { + return true; + } + } + return false; +}; + +class Retriever { + constructor({ type, name, getter, getProps, publishData, publishError }) { + this.type = type; + this.name = name; + this.getter = getter; + this.getProps = getProps; + this.publishData = publishData; + this.publishError = publishError; + } + + get() { + const promise = this.getter(this.getProps()); + if (!promise || !promise.then) { + throw new Error(`${this.type} for ${this.name} did not return a promise!`); + } + return promise.then(this.publishData, this.publishError); + } + + // eslint-disable-next-line class-methods-use-this + mergeProps(props) { + return props; + } + + // eslint-disable-next-line class-methods-use-this + onDestroy() {} +} + +class ResolveRetriever extends Retriever { + constructor(args) { + super({ type: 'resolve', ...args }); + } +} + +class PollRetriever extends Retriever { + constructor(args) { + super({ type: 'poll', ...args }); + this.interval = args.interval ? setInterval(() => this.get(), args.interval) : null; + } + + onDestroy() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } +} + +class ObserveRetriever extends Retriever { + constructor(args) { + super({ type: 'observe', ...args }); + + this.subscription = null; + } + + get() { + const observable = this.getter(this.getProps()); + if (!observable || !observable) { + throw new Error(`${this.type} for ${this.name} did not expose a subscribe function`); + } + this.subscription = observable.subscribe(this.publishData, this.publishError); + return Promise.resolve(); + } + + onDestroy() { + if (this.subscription && this.subscription.unsubscribe) { + this.subscription.unsubscribe(); + } + } +} + +const mergePaginateProps = (props, name, obj) => ({ + ...props, + paginate: { + ...props.paginate, + [name]: obj + } +}); + +class PaginatedInfiniteCursorRetriever extends Retriever { + constructor({ pagerConfig, ...args}) { + super({ type: 'resolve with cursor', ...args }); + + this.pagerConfig = pagerConfig; + + this.pastCursors = []; + this.nextCursors = pagerConfig.startingCursor || null; + + this.hasNext = true; + + this.pending = null; + } + + get() { + if (this.pending) { + return this.pending; + } + + const props = this.getProps(); + + this.pending = Promise.all([ + ...this.pastCursors.map((cursor) => this.getter(props, cursor)), + this.getter(props, this.nextCursor).then(response => { + const nextCursor = this.pagerConfig.getCursor(response); + + this.pastCursors.push(this.nextCursor); + this.nextCursor = nextCursor; + this.hasNext = !!nextCursor; + this.pending = null; + + return response; + }) + ]).then((results) => this.publishData(results.reduce(this.pagerConfig.reduceResults))); + + this.pending.catch((err) => { + this.pending = null; + return Promise.reject(err); + }); + + return this.pending; + } + + mergeProps(props) { + return mergePaginateProps(props, this.name, { + getNext: () => this.get(), + hasNext: this.hasNext + }); + } +} + +class PaginatedInfiniteOffsetAndLimitResolveRetriever extends Retriever { + constructor({ pagerConfig, ...args }) { + super({ type: 'resolve paginated', ...args }); + this.pagerConfig = pagerConfig; + + this.pagers = []; + + this.queueNext(); + } + + get() { + const props = this.getProps(); + return Promise.all(this.pagers.map((pager) => this.getter(props, pager))).then( + (lists) => this.publishData(lists.reduce((mem, list) => [...mem, ...list], [])), + (err) => Promise.reject(err) + ); + } + + queueNext() { + const prevPager = this.pagers[this.pagers.length - 1] || { limit: null, offset: null }; + const nextPager = this.pagerConfig.getNextPage(prevPager); + this.pagers.push(nextPager); + } + + mergeProps(props) { + return mergePaginateProps(props, this.name, { + getNext: () => { + this.queueNext(); + return this.get(); + } + }); + } +} + +class PaginatedInfiniteOffsetAndLimitObserveRetriever extends Retriever { + constructor({ pagerConfig, ...args }) { + super({ type: 'observe paginated', ...args }); + this.pagerConfig = pagerConfig; + + this.pagerSubscriptions = []; + + this.queueNext(); + } + + get() { + const props = this.getProps(); + return new Promise((resolve) => { + const tryResolve = (d) => { + if (every(p_ => p_.data, this.pagerSubscriptions)) { + resolve(d); + } + }; + this.pagerSubscriptions = this.pagerSubscriptions.map((p) => { + if (!p.subscription) { + p.firstLoad = true; + p.subscription = this.getter(props, p.pager).subscribe( + (data) => { + p.data = data; + p.firstLoad = false; + this.tryToPublish(); + tryResolve(data); + }, + this.publishError + ); + } + return p; + }); + }); + } + + queueNext() { + const pagers = this.pagerSubscriptions.map((p) => p.pager); + const prevPager = pagers[pagers.length - 1] || { limit: null, offset: null }; + const nextPager = this.pagerConfig.getNextPage(prevPager); + const p = { + pager: nextPager, + subscription: null, + data: null, + error: null + }; + this.pagerSubscriptions.push(p); + } + + tryToPublish() { + const result = []; + for (let i = 0; i < this.pagerSubscriptions.length; i++) { + const p = this.pagerSubscriptions[i]; + if (!p.data) { + return; + } + result.push(...p.data); + } + this.publishData(result); + } + + mergeProps(props) { + return mergePaginateProps(props, this.name, { + getNext: () => { + this.queueNext(); + return this.get(); + }, + isLoading: any(p => !p.data, this.pagerSubscriptions) + }); + } + + onDestroy() { + this.pagerSubscriptions.forEach((p) => { + if (p.subscription && p.subscription.unsubscribe) { + p.subscription.unsubscribe(); + } + }); + } +} + +const isInfiniteOffsetAndLimitPager = ({ mode, type }) => { + return mode === PAGINATION.MODE.INFINITE && type === PAGINATION.TYPE.OFFSET_AND_LIMIT; +}; + +const isInfiniteCursor = ({ mode, type }) => { + return mode === PAGINATION.MODE.INFINITE && type === PAGINATION.TYPE.CURSOR; +}; + +export const getResolveRetriever = (pagerConfig) => { + if (pagerConfig) { + if (isInfiniteOffsetAndLimitPager(pagerConfig)) { + return PaginatedInfiniteOffsetAndLimitResolveRetriever; + } + + if (isInfiniteCursor(pagerConfig)) { + return PaginatedInfiniteCursorRetriever; + } + } + return ResolveRetriever; +}; + +export const getObserveRetriever = (pagerConfig) => { + if (pagerConfig) { + if (isInfiniteOffsetAndLimitPager(pagerConfig)) { + return PaginatedInfiniteOffsetAndLimitObserveRetriever; + } + } + return ObserveRetriever; +}; + +export const getPollRetriever = () => { + return PollRetriever; +}; +