diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js
index 382f5101d..14437c51b 100644
--- a/client/coral-admin/src/index.js
+++ b/client/coral-admin/src/index.js
@@ -2,6 +2,8 @@ import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import smoothscroll from 'smoothscroll-polyfill';
+import EventEmitter from 'eventemitter2';
+import EventEmitterProvider from 'coral-framework/components/EventEmitterProvider';
import {getClient} from 'coral-framework/services/client';
import store from './services/store';
@@ -12,13 +14,19 @@ import 'react-mdl/extra/material.js';
import './graphql';
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
+const eventEmitter = new EventEmitter();
+
+// TODO: pass redux actions through the emitter.
+
loadPluginsTranslations();
injectPluginsReducers();
smoothscroll.polyfill();
render(
-
-
-
+
+
+
+
+
, document.querySelector('#root')
);
diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js
index 9020a8457..c40df74f1 100644
--- a/client/coral-embed-stream/src/index.js
+++ b/client/coral-embed-stream/src/index.js
@@ -5,15 +5,19 @@ 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 {createReduxEmitter} from 'coral-framework/services/events';
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({wildcard: true});
loadPluginsTranslations();
injectPluginsReducers();
@@ -42,11 +46,21 @@ if (!window.opener) {
} else {
init();
}
+
+ // Pass any events through our parent.
+ eventEmitter.onAny((eventName, value) => {
+ pym.sendMessage('event', JSON.stringify({eventName, value}));
+ });
+
+ // Add a redux listener to pass through all actions to our event emitter.
+ addListener(createReduxEmitter(eventEmitter));
}
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..fda97c4b3 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({wildcard: true});
// 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,12 @@ function configurePymParent(pymParent, opts) {
window.open(url, '_blank').focus();
});
+ // Pass events from iframe to the event emitter
+ pymParent.onMessage('event', (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..3a3216c8a
--- /dev/null
+++ b/client/coral-framework/hocs/withEmit.js
@@ -0,0 +1,27 @@
+import React from 'react';
+const PropTypes = require('prop-types');
+
+/**
+ * WithEmit provides a property `emit: (eventName, value)`
+ * to the wrapped component.
+ */
+export default (WrappedComponent) => {
+ class WithEmit extends React.Component {
+ static contextTypes = {
+ eventEmitter: PropTypes.object,
+ };
+
+ emit = (eventName, value) => {
+ this.context.eventEmitter.emit(eventName, value);
+ };
+
+ render() {
+ return ;
+ }
+ }
+
+ return WithEmit;
+};
diff --git a/client/coral-framework/hocs/withMutation.js b/client/coral-framework/hocs/withMutation.js
index d3ba73ecd..4488b0e7f 100644
--- a/client/coral-framework/hocs/withMutation.js
+++ b/client/coral-framework/hocs/withMutation.js
@@ -5,8 +5,8 @@ import uniq from 'lodash/uniq';
import flatten from 'lodash/flatten';
import isEmpty from 'lodash/isEmpty';
import {getMutationOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry';
-import {getStore} from 'coral-framework/services/store';
import {getDefinitionName, getResponseErrors} from '../utils';
+import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
class ResponseErrors extends Error {
@@ -36,98 +36,115 @@ export default (document, config = {}) => (WrappedComponent) => {
options: config.options || {},
props: config.props || ((data) => ({mutate: data.mutate()})),
};
- const wrappedProps = (data) => {
- const name = getDefinitionName(document);
- const callbacks = getMutationOptions(name);
- const mutate = (base) => {
- const variables = base.variables || config.options.variables;
- const configs = callbacks.map((cb) => cb({variables, state: getStore().getState()}));
- const optimisticResponse = merge(
- base.optimisticResponse || config.options.optimisticResponse,
- ...configs.map((cfg) => cfg.optimisticResponse),
- );
-
- const refetchQueries = flatten(uniq([
- base.refetchQueries || config.options.refetchQueries,
- ...configs.map((cfg) => cfg.refetchQueries),
- ].filter((i) => i)));
-
- const updateCallbacks =
- [base.update || config.options.update]
- .concat(...configs.map((cfg) => cfg.update))
- .filter((i) => i);
-
- const update = (proxy, result) => {
- if (getResponseErrors(result)) {
-
- // Do not run updates when we have mutation errors.
- return;
- }
- updateCallbacks.forEach((cb) => cb(proxy, result));
- };
-
- const updateQueries =
- [
- base.updateQueries || config.options.updateQueries,
- ...configs.map((cfg) => cfg.updateQueries)
- ]
- .filter((i) => i)
- .reduce((res, map) => {
- Object.keys(map).forEach((key) => {
- if (!(key in res)) {
- res[key] = (prev, result) => {
- if (getResponseErrors(result.mutationResult)) {
-
- // Do not run updates when we have mutation errors.
- return prev;
- }
- return map[key](prev, result);
- };
- } else {
- const existing = res[key];
- res[key] = (prev, result) => {
- const next = existing(prev, result);
- return map[key](next, result);
- };
- }
- });
- return res;
- }, {});
-
- const wrappedConfig = {
- variables,
- optimisticResponse,
- refetchQueries,
- updateQueries,
- update,
- };
- if (isEmpty(wrappedConfig.optimisticResponse)) {
- delete wrappedConfig.optimisticResponse;
- }
- return data.mutate(wrappedConfig)
- .then((res) => {
- const errors = getResponseErrors(res);
- if (errors) {
- throw new ResponseErrors(errors);
- }
- return Promise.resolve(res);
- });
+ return class WithMutation extends React.Component {
+ static contextTypes = {
+ eventEmitter: PropTypes.object,
+ store: PropTypes.object,
};
- return config.props({...data, mutate});
- };
- // Lazily resolve fragments from graphRegistry to support circular dependencies.
- let memoized = null;
- const getWrapped = () => {
- if (!memoized) {
- memoized = graphql(resolveFragments(document), {...config, props: wrappedProps})(WrappedComponent);
+ // Lazily resolve fragments from graphRegistry to support circular dependencies.
+ memoized = null;
+
+ wrappedProps = (data) => {
+ const name = getDefinitionName(document);
+ const callbacks = getMutationOptions(name);
+ const mutate = (base) => {
+ const variables = base.variables || config.options.variables;
+ const configs = callbacks.map((cb) => cb({variables, state: this.context.store.getState()}));
+
+ const optimisticResponse = merge(
+ base.optimisticResponse || config.options.optimisticResponse,
+ ...configs.map((cfg) => cfg.optimisticResponse),
+ );
+
+ const refetchQueries = flatten(uniq([
+ base.refetchQueries || config.options.refetchQueries,
+ ...configs.map((cfg) => cfg.refetchQueries),
+ ].filter((i) => i)));
+
+ const updateCallbacks =
+ [base.update || config.options.update]
+ .concat(...configs.map((cfg) => cfg.update))
+ .filter((i) => i);
+
+ const update = (proxy, result) => {
+ if (getResponseErrors(result)) {
+
+ // Do not run updates when we have mutation errors.
+ return;
+ }
+ updateCallbacks.forEach((cb) => cb(proxy, result));
+ };
+
+ const updateQueries =
+ [
+ base.updateQueries || config.options.updateQueries,
+ ...configs.map((cfg) => cfg.updateQueries)
+ ]
+ .filter((i) => i)
+ .reduce((res, map) => {
+ Object.keys(map).forEach((key) => {
+ if (!(key in res)) {
+ res[key] = (prev, result) => {
+ if (getResponseErrors(result.mutationResult)) {
+
+ // Do not run updates when we have mutation errors.
+ return prev;
+ }
+ return map[key](prev, result);
+ };
+ } else {
+ const existing = res[key];
+ res[key] = (prev, result) => {
+ const next = existing(prev, result);
+ return map[key](next, result);
+ };
+ }
+ });
+ return res;
+ }, {});
+
+ const wrappedConfig = {
+ variables,
+ optimisticResponse,
+ refetchQueries,
+ updateQueries,
+ update,
+ };
+ 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, data: res.data});
+ return Promise.resolve(res);
+ })
+ .catch((error) => {
+ this.context.eventEmitter.emit(`mutation.${name}.error`, {variables, error});
+ throw new error;
+ });
+ };
+ return config.props({...data, mutate});
+ };
+
+ getWrapped = () => {
+ if (!this.memoized) {
+ this.memoized = graphql(resolveFragments(document), {...config, props: this.wrappedProps})(WrappedComponent);
+ }
+ return this.memoized;
+ };
+
+ render() {
+ const Wrapped = this.getWrapped();
+ return ;
}
- return memoized;
- };
-
- return (props) => {
- const Wrapped = getWrapped();
- return ;
};
};
diff --git a/client/coral-framework/hocs/withQuery.js b/client/coral-framework/hocs/withQuery.js
index ec190466c..adb67b7b3 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,146 @@ 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);
+
+ const {root} = separateDataAndRoot(data);
+ this.context.eventEmitter.emit(`query.${name}.${status}`, {variables, data: root});
}
- 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) => {
+
+ // Resolve document fragments before passing it to `apollo-client`.
+ return args.data.subscribeToMore({
+ ...stmArgs,
+ document: resolveFragments(stmArgs.document),
+ onError: (err) => {
+ if (stmArgs.onErr) {
+ return stmArgs.onErr(err);
+ }
+ throw err;
+ },
+ });
+ },
+ fetchMore: (lmArgs) => {
+ const fetchName = getDefinitionName(lmArgs.query);
+ this.context.eventEmitter.emit(
+ `query.${name}.fetchMore.${fetchName}.begin`,
+ {variables: lmArgs.variables});
+
+ // Resolve document fragments before passing it to `apollo-client`.
+ return args.data.fetchMore({
+ ...lmArgs,
+ query: resolveFragments(lmArgs.query),
+ })
+ .then((res) => {
+ this.context.eventEmitter.emit(
+ `query.${name}.fetchMore.${fetchName}.success`,
+ {variables: lmArgs.variables, data: res.data});
+ return Promise.resolve(res);
+ })
+ .catch((err) => {
+ this.context.eventEmitter.emit(
+ `query.${name}.fetchMore.${fetchName}.error`,
+ {variables: lmArgs.variables, error: err});
+ throw err;
+ });
+ },
+ },
+ };
+ 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/events.js b/client/coral-framework/services/events.js
new file mode 100644
index 000000000..a1a2f4448
--- /dev/null
+++ b/client/coral-framework/services/events.js
@@ -0,0 +1,14 @@
+export function createReduxEmitter(eventEmitter) {
+ return (action) => {
+
+ // Handle apollo actions.
+ if (action.type.startsWith('APOLLO_')) {
+ if (action.type === 'APOLLO_SUBSCRIPTION_RESULT') {
+ const {operationName, variables, result: {data}} = action;
+ eventEmitter.emit(`subscription.${operationName}.data`, {variables, data});
+ }
+ return;
+ }
+ eventEmitter.emit(`action.${action.type}`, {action});
+ };
+}
diff --git a/client/coral-framework/services/store.js b/client/coral-framework/services/store.js
index 4d6dafe20..f87a887a3 100644
--- a/client/coral-framework/services/store.js
+++ b/client/coral-framework/services/store.js
@@ -3,12 +3,22 @@ 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));
}
+/**
+ * Add a action listener to the redux store.
+ * The action is passed as the first argument to the callback.
+ */
+export function addListener(cb) {
+ listeners.push(cb);
+}
+
export function getStore() {
if (window.coralStore) {
return window.coralStore;
@@ -21,11 +31,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 f3ba19d1f..65c802ba8 100644
--- a/package.json
+++ b/package.json
@@ -78,6 +78,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..f1a7ba5d9 100644
--- a/views/article.ejs
+++ b/views/article.ejs
@@ -30,6 +30,17 @@
asset_url: '<%= asset_url ? asset_url : '' %>',
asset_id: '<%= asset_id ? asset_id : '' %>',
auth_token: '',
+ /**
+ * You can listen to events using the example below.
+ * The argument passed is the event emitter from
+ * https://github.com/asyncly/EventEmitter2
+ *
+ * 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 d2177f384..255dc7415 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2957,6 +2957,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"