Skip to content

Commit

Permalink
Breaking Changes: createPubSubConnector API now support configuration…
Browse files Browse the repository at this point in the history
… parameters and return a function
  • Loading branch information
cef62 committed Dec 8, 2015
1 parent e97f603 commit 3fbe845
Show file tree
Hide file tree
Showing 3 changed files with 199 additions and 9 deletions.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ export default class App extends Component {
```
## Connector Component

Any children of **PubSubProvider** who require access to the PubSub API should
be wrapped by a **PubSub Connector**. The connector will pass `pubSub` as **prop** to its child component.
Any children of **PubSubProvider** who require access to the PubSub API should be wrapped by a **PubSub Connector**.
The connector will pass `pubSub` as **prop** to its child component.

Create the Connected Component:

Expand All @@ -96,8 +96,9 @@ ConnectedComponent.propTypes = {
pubSub: subscriptionShape.isRequired,
};

export default createPubSubConnector(ConnectedComponent);
export default createPubSubConnector()(ConnectedComponent);
```
**Attention** `createPubSubConnector` must be invoked twice, first with configuration parameters and the returned function with the component to be wrapped.

### Remove subscribed events

Expand Down
126 changes: 121 additions & 5 deletions src/components/createPubSubConnector.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,108 @@
import { Component, createElement } from 'react';
import hoistStatics from 'hoist-non-react-statics';
import invariant from 'invariant';
import isPlainObject from '../utils/isPlainObject';
import pubSubShape from '../utils/pubSubShape';

const defaultRetriveProps = () => ({});

function getDisplayName(WrappedComponent) {
return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}

export default function createPubSubConnector(options = {}) {
export default function createPubSubConnector(mapSubscriptionsToProps, options = {}) {
const shouldMapSubscriptions = Boolean(mapSubscriptionsToProps);
const { withRef = false } = options;

let mappedSubscriptions = {};
function registerMappedSubscriptions(pubSub, subscriptionsMap = {}, setState, getProps) {
const { add } = pubSub;

// TODO: may be necessary to support hot reload
const mappedSubscriptionsKeys = Object.keys(mappedSubscriptions);
if (mappedSubscriptionsKeys.length) {
mappedSubscriptionsKeys.forEach(key => mappedSubscriptions[key].unsubscribe());
}

if (!Object.keys(subscriptionsMap).length) {
return;
}

invariant(
Object.keys(subscriptionsMap)
.every(key => (
typeof subscriptionsMap[key] === 'function' || typeof subscriptionsMap[key] === 'string')),
`Every mapped Subscription of "createPubSubConnector" must be a function`
+ `returning the value to be passed as prop to the decorated component.`
);

const updateMappedSubscriptions = (key, transformerOrAlias) => {
let callback;
if (typeof transformerOrAlias === 'function') {
callback = (retrieveProps = defaultRetriveProps) =>
(...args) => {
// store received values
mappedSubscriptions[key].lastResult = args;

// transform values
const newValues = transformerOrAlias(args, retrieveProps());

invariant(
isPlainObject(newValues),
`Transformer functions for mapped subscriptions must return a plain object, `
+ `instead received %s.`
);

invariant(
Object.keys(newValues).length,
`Transformer functions for mapped subscriptions must return an object`
+ `with at least one property.`
);

// TODO: add controls to avoid triggering setState if value isn't changed and
setState(newValues);
};
} else {
callback = result => setState({ [transformerOrAlias]: result });
}

return callback;
};

mappedSubscriptions = Object.keys(subscriptionsMap)
.reduce((acc, key) => {
const refresh = updateMappedSubscriptions(key, subscriptionsMap[key]);
let callback = refresh;
if (typeof subscriptionsMap[key] === 'function') {
callback = refresh(getProps);
}
acc[key] = {
key,
refresh,
unsubscribe: add(key, callback),
};
return acc;
}, {});
}

function refreshMappedSubscriptions(subscriptionsMap = {}, getProps) {
const mappedSubscriptionsKeys = Object.keys(mappedSubscriptions);
if (!mappedSubscriptionsKeys.length) {
return;
}

Object.keys(subscriptionsMap)
.forEach(key => {
if (
typeof subscriptionsMap[key] === 'function'
&& typeof mappedSubscriptions[key] !== 'undefined'
) {
const { refresh, lastResult } = mappedSubscriptions[key];
refresh(getProps)(...lastResult);
}
});
}

return function wrapComponent(Composed) {
class PubSubConnector extends Component {
constructor(props, context) {
Expand All @@ -25,18 +118,41 @@ export default function createPubSubConnector(options = {}) {
);

this.pubSub = this.pubSubCore.register(this);
if (shouldMapSubscriptions) {
registerMappedSubscriptions(
this.pubSub,
mapSubscriptionsToProps,
(...args) => this.setState(...args),
() => this.props
);
}
}

componentWillUpdate(nextProps) {
if (this.props === nextProps) {
return;
}

if (shouldMapSubscriptions) {
refreshMappedSubscriptions(
mapSubscriptionsToProps,
() => nextProps
);
}
}

componentWillUnmount() {
mappedSubscriptions = {};
this.pubSub.unsubscribe();
delete this.pubSub;
delete this.pubSubCore;
}

getWrappedInstance() {
invariant(withRef,
`To access the wrapped instance, you need to specify explicitly` +
`{ withRef: true } in the options passed to the createPubSubConnector() call.`
invariant(
withRef,
`To access the wrapped instance, you need to specify explicitly`
+ `{ withRef: true } in the options passed to the createPubSubConnector() call.`
);

return this.refs.wrappedInstance;
Expand All @@ -45,7 +161,7 @@ export default function createPubSubConnector(options = {}) {
render() {
const { pubSub } = this;
const ref = withRef ? 'wrappedInstance' : null;
return createElement(Composed, Object.assign({ pubSub, ref }, this.props));
return createElement(Composed, Object.assign({ pubSub, ref }, this.props, this.state));
}
}

Expand Down
75 changes: 74 additions & 1 deletion test/components/createPubSubConnector.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ test('should return the instance of the wrapped component for use in calling chi
return (<Passthrough />);
}
}
const WrappedComponent = createPubSubConnector({ withRef: true })(Container);
const WrappedComponent = createPubSubConnector(null, { withRef: true })(Container);

const tree = TestUtils.renderIntoDocument(
<ProviderMock pubSubCore={pubSubCore}>
Expand Down Expand Up @@ -277,3 +277,76 @@ test('should subscribe pure function components to the pubSub core', t => {

t.end();
});

test(
'should map subscriptions defined in `mapSubscriptionsToProps` and '
+ 'pass their payload as props to wrapped component',
t => {
const SPEAK = 'speak';
const UPDATE = 'update';
const pubSubCore = createPubSub();
const register = pubSubCore.register;
let pubSub;
pubSubCore.register = (...args) => {
pubSub = register(...args);
return pubSub;
};

class Container extends Component {
render() {
return (<Passthrough {...this.props} />);
}
}

class ControlledState extends Component {
constructor() {
super();
this.state = { updateField: 'label' };
}

render() {
const { updateField } = this.state;
return (<WrapperContainer updateField={updateField} />);
}
}

const mapSubscriptionsToProps = {
[SPEAK]: 'lastMessage',
[UPDATE]: (args, props) => {
const [payload = {}] = args;
const { updateField: key } = props;
return { updatedField: payload[key] };
},
};
const WrapperContainer = createPubSubConnector(mapSubscriptionsToProps)(Container);

const tree = TestUtils.renderIntoDocument(
<ProviderMock pubSubCore={pubSubCore}>
<ControlledState />
</ProviderMock>
);
const stub = TestUtils.findRenderedComponentWithType(tree, Passthrough);
const wrapper = TestUtils.findRenderedComponentWithType(tree, ControlledState);

t.is(stub.props.lastMessage, undefined);
pubSub.publish(SPEAK, 'Hey there!');
t.is(stub.props.lastMessage, 'Hey there!');

t.is(stub.props.updatedField, undefined);
pubSub.publish(UPDATE, { label: 'myLabel', name: 'myName' });
t.is(stub.props.updatedField, 'myLabel');
wrapper.setState({ updateField: 'name' });

t.is(stub.props.updatedField, 'myName');
pubSub.publish(UPDATE, { label: 'myLabel', name: 'myNewName' });
t.is(stub.props.updatedField, 'myNewName');

pubSub.publish(UPDATE, { label: 'myLabel' });
t.is(stub.props.updatedField, undefined);

pubSub.publish(SPEAK, 'New Message');
t.is(stub.props.lastMessage, 'New Message');

t.end();
}
);

0 comments on commit 3fbe845

Please sign in to comment.