diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js
index 9020a8457..120fe4512 100644
--- a/client/coral-embed-stream/src/index.js
+++ b/client/coral-embed-stream/src/index.js
@@ -5,15 +5,18 @@ import {ApolloProvider} from 'react-apollo';
import {checkLogin} from 'coral-framework/actions/auth';
import './graphql';
import {addExternalConfig} from 'coral-embed-stream/src/actions/config';
-import {getStore, injectReducers} from 'coral-framework/services/store';
+import {getStore, injectReducers, addListener} from 'coral-framework/services/store';
import {getClient} from 'coral-framework/services/client';
import pym from 'coral-framework/services/pym';
import AppRouter from './AppRouter';
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
import reducers from './reducers';
+import EventEmitter from 'eventemitter2';
+import EventEmitterProvider from 'coral-framework/components/EventEmitterProvider';
const store = getStore();
const client = getClient();
+const eventEmitter = new EventEmitter();
loadPluginsTranslations();
injectPluginsReducers();
@@ -42,11 +45,29 @@ if (!window.opener) {
} else {
init();
}
+
+ // Pass any events through our parent.
+ eventEmitter.onAny((eventName, value) => {
+ pym.sendMessage('eventEmitter', JSON.stringify({eventName, value}));
+ });
+
+ // Add a redux listener to pass through all actions to our event emitter.
+ addListener((action) => {
+
+ // Ignore apollo actions.
+ if (action.type.startsWith('APOLLO_')) {
+ return;
+ }
+
+ eventEmitter.emit(`redux.action.${action.type}`, {action});
+ });
}
render(
-
-
-
+
+
+
+
+
, document.querySelector('#talk-embed-stream-container')
);
diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js
index eb61f3b90..7c7b0658f 100644
--- a/client/coral-embed/src/index.js
+++ b/client/coral-embed/src/index.js
@@ -3,6 +3,9 @@ import URLSearchParams from 'url-search-params';
import {buildUrl} from 'coral-framework/utils';
import queryString from 'query-string';
+import EventEmitter from 'eventemitter2';
+
+const eventEmitter = new EventEmitter();
// TODO: Styles should live in a separate file
const snackbarStyles = {
@@ -57,6 +60,10 @@ function configurePymParent(pymParent, opts) {
pymParent.sendMessage('config', JSON.stringify(config));
}
+ if (opts.events) {
+ opts.events(eventEmitter);
+ }
+
pymParent.onMessage('coral-auth-changed', function(message) {
if (opts.onAuthChanged) {
opts.onAuthChanged(message ? JSON.parse(message) : null);
@@ -167,6 +174,11 @@ function configurePymParent(pymParent, opts) {
window.open(url, '_blank').focus();
});
+ pymParent.onMessage('eventEmitter', (raw) => {
+ const {eventName, value} = JSON.parse(raw);
+ eventEmitter.emit(eventName, value);
+ });
+
// get dimensions of viewport
const viewport = () => {
let e = window, a = 'inner';
diff --git a/client/coral-framework/components/EventEmitterProvider.js b/client/coral-framework/components/EventEmitterProvider.js
new file mode 100644
index 000000000..36c0bccdb
--- /dev/null
+++ b/client/coral-framework/components/EventEmitterProvider.js
@@ -0,0 +1,18 @@
+import React from 'react';
+const PropTypes = require('prop-types');
+
+class EventEmitterProvider extends React.Component {
+ getChildContext() {
+ return {eventEmitter: this.props.eventEmitter};
+ }
+
+ render() {
+ return this.props.children;
+ }
+}
+
+EventEmitterProvider.childContextTypes = {
+ eventEmitter: PropTypes.object,
+};
+
+export default EventEmitterProvider;
diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js
index 782f840bd..67b47482e 100644
--- a/client/coral-framework/hocs/index.js
+++ b/client/coral-framework/hocs/index.js
@@ -2,3 +2,6 @@ export {default as withFragments} from './withFragments';
export {default as withMutation} from './withMutation';
export {default as withQuery} from './withQuery';
export {default as withCopyToClipboard} from './withCopyToClipboard';
+export {default as withEmit} from './withEmit';
+export {default as excludeIf} from './excludeIf';
+export {default as connect} from './connect';
diff --git a/client/coral-framework/hocs/withEmit.js b/client/coral-framework/hocs/withEmit.js
new file mode 100644
index 000000000..9e47d3a07
--- /dev/null
+++ b/client/coral-framework/hocs/withEmit.js
@@ -0,0 +1,23 @@
+import React from 'react';
+const PropTypes = require('prop-types');
+
+export default (WrappedComponent) => {
+ class WithCopyToClipboard extends React.Component {
+ static contextTypes = {
+ eventEmitter: PropTypes.object,
+ };
+
+ emit = (eventName, value, context) => {
+ this.context.eventEmitter.emit(eventName, value, context);
+ };
+
+ render() {
+ return ;
+ }
+ }
+
+ return WithCopyToClipboard;
+};
diff --git a/client/coral-framework/hocs/withMutation.js b/client/coral-framework/hocs/withMutation.js
index 80b1aa29d..4008e3491 100644
--- a/client/coral-framework/hocs/withMutation.js
+++ b/client/coral-framework/hocs/withMutation.js
@@ -6,8 +6,8 @@ import flatten from 'lodash/flatten';
import isEmpty from 'lodash/isEmpty';
import {getMutationOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry';
import {getDefinitionName, getResponseErrors} from '../utils';
+import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
-const PropTypes = require('prop-types');
class ResponseErrors extends Error {
constructor(errors) {
@@ -43,10 +43,6 @@ export default (document, config = {}) => (WrappedComponent) => {
store: PropTypes.object,
};
- emit = (eventName, value, context) => {
- this.context.eventEmitter.emit(eventName, value, context);
- };
-
// Lazily resolve fragments from graphRegistry to support circular dependencies.
memoized = null;
@@ -119,13 +115,21 @@ export default (document, config = {}) => (WrappedComponent) => {
if (isEmpty(wrappedConfig.optimisticResponse)) {
delete wrappedConfig.optimisticResponse;
}
+
+ this.context.eventEmitter.emit(`mutation.${name}.begin`, {variables});
+
return data.mutate(wrappedConfig)
.then((res) => {
const errors = getResponseErrors(res);
if (errors) {
throw new ResponseErrors(errors);
}
+ this.context.eventEmitter.emit(`mutation.${name}.success`, {variables});
return Promise.resolve(res);
+ })
+ .catch((error) => {
+ this.context.eventEmitter.emit(`mutation.${name}.error`, {variables, error});
+ throw new error;
});
};
return config.props({...data, mutate});
diff --git a/client/coral-framework/hocs/withQuery.js b/client/coral-framework/hocs/withQuery.js
index ec190466c..9265a1656 100644
--- a/client/coral-framework/hocs/withQuery.js
+++ b/client/coral-framework/hocs/withQuery.js
@@ -2,6 +2,7 @@ import * as React from 'react';
import {graphql} from 'react-apollo';
import {getQueryOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry';
import {getDefinitionName, separateDataAndRoot, getResponseErrors} from '../utils';
+import PropTypes from 'prop-types';
const withSkipOnErrors = (reducer) => (prev, action, ...rest) => {
if (action.type === 'APOLLO_MUTATION_RESULT' && getResponseErrors(action.result)) {
@@ -10,76 +11,141 @@ const withSkipOnErrors = (reducer) => (prev, action, ...rest) => {
return reducer(prev, action, ...rest);
};
+function networkStatusToString(networkStatus) {
+ switch(networkStatus) {
+ case 1:
+ return 'loading';
+ case 2:
+ return 'setVariables';
+ case 3:
+ return 'fetchMore';
+ case 4:
+ return 'refetch';
+ case 6:
+ return 'poll';
+ case 7:
+ return 'ready';
+ case 8:
+ return 'error';
+ }
+ throw new Error(`Unknown network status ${networkStatus}`);
+}
+
/**
* Exports a HOC with the same signature as `graphql`, that will
* apply query options registered in the graphRegistry.
*/
export default (document, config = {}) => (WrappedComponent) => {
- const wrappedConfig = {
- ...config,
- options: config.options || {},
- props: (args) => {
- const wrappedArgs = {
- ...args,
- data: {
- ...args.data,
- subscribeToMore(stmArgs) {
+ const name = getDefinitionName(document);
- // Resolve document fragments before passing it to `apollo-client`.
- return args.data.subscribeToMore({
- ...stmArgs,
- document: resolveFragments(stmArgs.document),
- });
- },
- fetchMore(lmArgs) {
-
- // Resolve document fragments before passing it to `apollo-client`.
- return args.data.fetchMore({
- ...lmArgs,
- query: resolveFragments(lmArgs.query),
- });
- },
- },
- };
- return config.props
- ? config.props(wrappedArgs)
- : separateDataAndRoot(wrappedArgs.data);
- },
- };
-
- const wrappedOptions = (data) => {
- const base = (typeof wrappedConfig.options === 'function')
- ? wrappedConfig.options(data)
- : wrappedConfig.options;
- const name = getDefinitionName(document);
- const configs = getQueryOptions(name);
- const reducerCallbacks =
- [base.reducer || ((i) => i)]
- .concat(...configs.map((cfg) => cfg.reducer))
- .filter((i) => i);
-
- const reducer = withSkipOnErrors(
- reducerCallbacks.reduce(
- (a, b) => (prev, ...rest) =>
- b(a(prev, ...rest), ...rest),
- ));
-
- return {
- ...base,
- reducer,
+ return class WithQuery extends React.Component {
+ static contextTypes = {
+ eventEmitter: PropTypes.object,
};
- };
- let memoized = null;
- const getWrapped = () => {
- if (!memoized) {
- memoized = graphql(resolveFragments(document), {...wrappedConfig, options: wrappedOptions})(WrappedComponent);
+ // Lazily resolve fragments from graphRegistry to support circular dependencies.
+ memoized = null;
+ lastNetworkStatus = null;
+
+ emitWhenNeeded(data) {
+ const {variables, networkStatus} = data;
+ if (this.lastNetworkStatus === networkStatus) {
+ return;
+ }
+ this.lastNetworkStatus = networkStatus;
+
+ const status = networkStatusToString(networkStatus);
+
+ this.context.eventEmitter.emit(`query.${name}.${status}`, {variables, networkStatus});
}
- return memoized;
- };
- return (props) => {
- const Wrapped = getWrapped();
- return ;
+ wrappedConfig = {
+ ...config,
+ options: config.options || {},
+ props: (args) => {
+ this.emitWhenNeeded(args.data);
+
+ const wrappedArgs = {
+ ...args,
+ data: {
+ ...args.data,
+ subscribeToMore: (stmArgs) => {
+ const subscrName = getDefinitionName(stmArgs.document);
+ this.context.eventEmitter.emit(
+ `query.${name}.subscribeToMore.${subscrName}`,
+ {variables: stmArgs.variables});
+
+ // Resolve document fragments before passing it to `apollo-client`.
+ return args.data.subscribeToMore({
+ ...stmArgs,
+ document: resolveFragments(stmArgs.document),
+ onError: (err) => {
+ this.context.eventEmitter.emit(
+ `query.${name}.subscribeToMore.${subscrName}.error`,
+ {variables: stmArgs.variables});
+
+ if (stmArgs.onErr) {
+ return stmArgs.onErr(err);
+ }
+ throw err;
+ },
+ });
+ },
+ fetchMore: (lmArgs) => {
+ const fetchName = getDefinitionName(lmArgs.query);
+ this.context.eventEmitter.emit(
+ `query.${name}.fetchMore.${fetchName}`,
+ {variables: lmArgs.variables});
+
+ // Resolve document fragments before passing it to `apollo-client`.
+ return args.data.fetchMore({
+ ...lmArgs,
+ query: resolveFragments(lmArgs.query),
+ });
+ },
+ },
+ };
+ return config.props
+ ? config.props(wrappedArgs)
+ : separateDataAndRoot(wrappedArgs.data);
+ },
+ };
+
+ wrappedOptions = (data) => {
+ const base = (typeof this.wrappedConfig.options === 'function')
+ ? this.wrappedConfig.options(data)
+ : this.wrappedConfig.options;
+ const configs = getQueryOptions(name);
+ const reducerCallbacks =
+ [base.reducer || ((i) => i)]
+ .concat(...configs.map((cfg) => cfg.reducer))
+ .filter((i) => i);
+
+ const reducer = withSkipOnErrors(
+ reducerCallbacks.reduce(
+ (a, b) => (prev, ...rest) =>
+ b(a(prev, ...rest), ...rest),
+ ));
+
+ return {
+ ...base,
+ reducer,
+ };
+ };
+
+ getWrapped = () => {
+ if (!this.memoized) {
+ this.memoized = graphql(
+ resolveFragments(document),
+ {...this.wrappedConfig, options: this.wrappedOptions},
+ )(WrappedComponent);
+ }
+ return this.memoized;
+ };
+
+ render() {
+ const Wrapped = this.getWrapped();
+ return ;
+ }
};
};
diff --git a/client/coral-framework/services/store.js b/client/coral-framework/services/store.js
index 4d6dafe20..eaf221e14 100644
--- a/client/coral-framework/services/store.js
+++ b/client/coral-framework/services/store.js
@@ -3,12 +3,18 @@ import thunk from 'redux-thunk';
import mainReducer from '../reducers';
import {getClient} from './client';
+let listeners = [];
+
export function injectReducers(reducers) {
const store = getStore();
store.coralReducers = {...store.coralReducers, ...reducers};
store.replaceReducer(combineReducers(store.coralReducers));
}
+export function addListener(cb) {
+ listeners.push(cb);
+}
+
export function getStore() {
if (window.coralStore) {
return window.coralStore;
@@ -21,11 +27,17 @@ export function getStore() {
return next(action);
};
+ const customListener = () => (next) => (action) => {
+ listeners.forEach((cb) => {cb(action);});
+ return next(action);
+ };
+
const middlewares = [
applyMiddleware(
getClient().middleware(),
thunk,
apolloErrorReporter,
+ customListener,
),
];
diff --git a/package.json b/package.json
index 9cb065320..663efceaf 100644
--- a/package.json
+++ b/package.json
@@ -75,6 +75,7 @@
"dotenv": "^4.0.0",
"ejs": "^2.5.6",
"env-rewrite": "^1.0.2",
+ "eventemitter2": "^4.1.2",
"express": "^4.15.2",
"express-session": "^1.15.1",
"file-loader": "^0.11.2",
diff --git a/views/article.ejs b/views/article.ejs
index d803b6295..fe449dfc5 100644
--- a/views/article.ejs
+++ b/views/article.ejs
@@ -30,6 +30,11 @@
asset_url: '<%= asset_url ? asset_url : '' %>',
asset_id: '<%= asset_id ? asset_id : '' %>',
auth_token: '',
+ events: function(events) {
+ events.onAny(function(eventName, data) {
+ console.log(eventName, data);
+ });
+ },
plugin_config: {
/**
* You can disable rendering slot components of a plugin by doing:
diff --git a/yarn.lock b/yarn.lock
index 710b89fa2..e13fde3ca 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2961,6 +2961,10 @@ event-stream@~3.3.0:
stream-combiner "~0.0.4"
through "~2.3.1"
+eventemitter2@^4.1.2:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-4.1.2.tgz#0e1a8477af821a6ef3995b311bf74c23a5247f15"
+
eventemitter3@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-2.0.3.tgz#b5e1079b59fb5e1ba2771c0a993be060a58c99ba"