From cb95e9a30842559f1edd9b36c9c2f814e1915760 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 21 Jul 2017 22:54:45 +0700 Subject: [PATCH] Implement support for events --- client/coral-embed-stream/src/index.js | 29 ++- client/coral-embed/src/index.js | 12 ++ .../components/EventEmitterProvider.js | 18 ++ client/coral-framework/hocs/index.js | 3 + client/coral-framework/hocs/withEmit.js | 23 +++ client/coral-framework/hocs/withMutation.js | 14 +- client/coral-framework/hocs/withQuery.js | 188 ++++++++++++------ client/coral-framework/services/store.js | 12 ++ package.json | 1 + views/article.ejs | 5 + yarn.lock | 4 + 11 files changed, 239 insertions(+), 70 deletions(-) create mode 100644 client/coral-framework/components/EventEmitterProvider.js create mode 100644 client/coral-framework/hocs/withEmit.js 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"