diff --git a/src/components/Resolve/Resolve.test.tsx b/src/components/Resolve/Resolve.test.tsx new file mode 100644 index 00000000..4a331f30 --- /dev/null +++ b/src/components/Resolve/Resolve.test.tsx @@ -0,0 +1,59 @@ +import { mount } from 'enzyme'; +import * as React from 'react'; +import Resolve from './Resolve'; + +describe('resolve', () => { + const timeout = 10; + + const resolvable = resolved => + new Promise(resolve => { + setTimeout(() => { + resolve(resolved); + }, timeout); + }); + + const rejectable = rejected => + new Promise((_, reject) => { + setTimeout(() => { + reject(rejected); + }, timeout); + }); + + test('no pending, resolve, reject', () => { + const wrapper = mount(); + expect(wrapper.html()).toEqual(null); + }); + + test('pending', () => { + const pending =

horse

; + const result = '

horse

'; + const wrapper = mount( + , + ); + expect(wrapper.html()).toEqual(result); + }); + + test('resolve', done => { + const resolved = value =>

{value}

; + const result = '

resolved

'; + const wrapper = mount( + , + ); + setTimeout(() => { + expect(wrapper.html()).toEqual(result); + done(); + }, timeout + 5); + }); + + test('resolve', done => { + const rejected = value =>

{value}

; + const result = '

rejected

'; + const wrapper = mount( + , + ); + setTimeout(() => { + expect(wrapper.html()).toEqual(result); + done(); + }, timeout + 5); + }); +}); diff --git a/src/components/Resolve/Resolve.tsx b/src/components/Resolve/Resolve.tsx new file mode 100644 index 00000000..f3248c31 --- /dev/null +++ b/src/components/Resolve/Resolve.tsx @@ -0,0 +1,134 @@ +import * as PropTypes from 'prop-types'; +import * as React from 'react'; +import { isPromise } from '../../utils'; +import { statusTypes } from './config'; + +export interface IResolveProps { + /** The promise to be resolved */ + promise?: Promise; + + /** Will be displayed after promise is resolved */ + resolved?: (value) => React.ReactNode; + + /** Will be displayed while promise is handled */ + pending?: React.ReactNode; + + /** Will be displayed if promise is rejected */ + rejected?: (error) => React.ReactNode; +} + +const initialState = { + status: statusTypes.none, + value: '', +}; + +type IResolveState = Readonly; + +/** + * A component to render based on a promise + * + * @example + *

{`Resolved value is ${value}`}

+ * } /> + */ +class Resolve extends React.Component { + // PropTypes + public static propTypes = { + pending: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), + promise: isPromise, + rejected: PropTypes.func, + resolved: PropTypes.func, + }; + + // Initialize state + public readonly state: IResolveState = initialState; + + // Create escape hatch to stop handling of promise if unmounted + public unmounted = false; + + public componentDidMount() { + // Start handling the promise, must happen after mount as setState is called when promise is handled + this._handlePromise(this.props.promise); + } + + public componentDidUpdate(prevProps) { + if (this.props.promise !== prevProps.promise) { + this.setState({ + status: statusTypes.none, + }); + this._handlePromise(this.props.promise); + } + } + + public componentWillUnmount() { + this.unmounted = true; + } + + // Promise resolver function + public _handlePromise(promise) { + // Store the current promise to fast exit if promise is change during handling + const currentPromise = promise; + this.setState({ + status: statusTypes.pending, + }); + promise + .then(success => { + // Escape early as promise is changed + if (currentPromise !== promise) { + return; + } + if (!this.unmounted) { + this.setState({ + status: statusTypes.resolved, + value: success, + }); + } + }) + .catch(reason => { + // Escape early as promise is changed + if (currentPromise !== promise) { + return; + } + if (!this.unmounted) { + this.setState({ + status: statusTypes.rejected, + value: reason, + }); + } + }); + } + + public render() { + const { pending, resolved, rejected } = this.props; + const { status, value } = this.state; + + switch (status) { + case statusTypes.none: + break; + case statusTypes.pending: + if (pending) { + return pending; + } + break; + case statusTypes.resolved: + if (resolved) { + return resolved(value); + } + break; + case statusTypes.rejected: + if (rejected) { + return rejected(value); + } + break; + default: + break; + } + + return null; + } +} + +export default Resolve; diff --git a/src/components/Resolve/config.ts b/src/components/Resolve/config.ts new file mode 100644 index 00000000..3e2dc1db --- /dev/null +++ b/src/components/Resolve/config.ts @@ -0,0 +1,6 @@ +export const statusTypes = { + none: 'none', + pending: 'pending', + rejected: 'rejected', + resolved: 'resolved', +}; diff --git a/src/components/Resolve/index.ts b/src/components/Resolve/index.ts new file mode 100644 index 00000000..00a3eebd --- /dev/null +++ b/src/components/Resolve/index.ts @@ -0,0 +1 @@ +export { default } from './Resolve'; diff --git a/src/utils/index.ts b/src/utils/index.ts index 2756655c..43dbb546 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,2 @@ export * from './isEmptyChildren'; +export * from './isPromise/isPromise'; diff --git a/src/utils/isPromise/isPromise.test.ts b/src/utils/isPromise/isPromise.test.ts new file mode 100644 index 00000000..15c80f75 --- /dev/null +++ b/src/utils/isPromise/isPromise.test.ts @@ -0,0 +1,31 @@ +import { isPromise } from './isPromise'; + +describe('isPromise type check', () => { + test('Should return positive if supplied a promise', () => { + const prom = new Promise(resolve => { + setTimeout(() => { + resolve(1); + }, 10); + }); + const result = isPromise({ prom }, 'prom', 'component'); + expect(result).toEqual(null); + }); + test('Should return positive if supplied a nothing', () => { + const result = isPromise({}, 'prom', 'component'); + expect(result).toEqual(null); + }); + [ + 'not a promise', + a => a, + true, + 1, + ['contains', 'not', 'a', 'promise'], + null, + undefined, + ].forEach(notPromise => + test(`Should return negative if supplied a ${typeof notPromise}`, () => { + const result = isPromise({ prom: 'not a promise' }, 'prom', 'component'); + expect(result).toMatchObject(new Error('prom in component is not a promise')); + }), + ); +}); diff --git a/src/utils/isPromise/isPromise.ts b/src/utils/isPromise/isPromise.ts new file mode 100644 index 00000000..3e17a1dd --- /dev/null +++ b/src/utils/isPromise/isPromise.ts @@ -0,0 +1,8 @@ +export const isPromise = (props: object, propName: string, componentName: string) => { + if (props[propName]) { + return props[propName] instanceof Promise + ? null + : new Error(`${propName} in ${componentName} is not a promise`); + } + return null; +};