Merge branch 'master' into prefix-redux-actions-plugins

This commit is contained in:
Kiwi
2017-07-26 11:15:44 +02:00
committed by GitHub
22 changed files with 447 additions and 168 deletions
+2 -1
View File
@@ -1,9 +1,10 @@
# excluded because we'll likely need to rebuild this.
node_modules
# scripts are used during development and testing, not
# most scripts are used during development and testing, not
# production.
scripts
!scripts/generateIntrospectionResult.js
# documentation should not be visable in production.
docs
+11 -3
View File
@@ -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(
<ApolloProvider client={getClient()} store={store}>
<App />
</ApolloProvider>
<EventEmitterProvider eventEmitter={eventEmitter}>
<ApolloProvider client={getClient()} store={store}>
<App />
</ApolloProvider>
</EventEmitterProvider>
, document.querySelector('#root')
);
@@ -14,7 +14,7 @@
.filterWrapper {
position: absolute;
right: 0;
margin-top: 6px;
margin-top: 4px;
}
.highlightedContainer {
@@ -26,4 +26,4 @@
margin-top: 28px;
padding-bottom: 50px;
min-height: 600px;
}
}
+18 -4
View File
@@ -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(
<ApolloProvider client={client} store={store}>
<AppRouter />
</ApolloProvider>
<EventEmitterProvider eventEmitter={eventEmitter}>
<ApolloProvider client={client} store={store}>
<AppRouter />
</ApolloProvider>
</EventEmitterProvider>
, document.querySelector('#talk-embed-stream-container')
);
+13
View File
@@ -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';
@@ -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;
+3
View File
@@ -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';
+27
View File
@@ -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 <WrappedComponent
{...this.props}
emit={this.emit}
/>;
}
}
return WithEmit;
};
+107 -90
View File
@@ -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 <Wrapped {...this.props} />;
}
return memoized;
};
return (props) => {
const Wrapped = getWrapped();
return <Wrapped {...props} />;
};
};
+132 -61
View File
@@ -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 <Wrapped {...props} />;
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 <Wrapped {...this.props} />;
}
};
};
+14
View File
@@ -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});
};
}
+16
View File
@@ -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,
),
];
+10
View File
@@ -68,3 +68,13 @@
pointer-events: none;
}
@custom-media --small-viewport (max-width: 320px);
@media (--small-viewport) {
.buttonSub, .buttonSubActive, .buttonSubActive:hover, .buttonSubActive:focus {
font-size: 0.96em;
padding-right: 5px;
padding-right: 5px;
}
}
+1
View File
@@ -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",
@@ -5,6 +5,10 @@
composes: buttonReset from "coral-framework/styles/reset.css";
}
.button:hover {
cursor: pointer;
}
.list {
background: white;
position: absolute;
@@ -29,3 +33,27 @@
font-size: 14px;
vertical-align: middle;
}
@custom-media --small-viewport (max-width: 425px);
.filterText {
display: inline-block;
}
.filterIcon {
vertical-align: middle;
display: none;
}
@media (--small-viewport) {
.filterText {
display: none;
}
.filterIcon {
display: inline-block;
}
}
@@ -1,8 +1,9 @@
import React from 'react';
import cn from 'classnames';
import styles from './ViewingOptions.css';
import {Slot, ClickOutside} from 'plugin-api/beta/client/components';
import {t} from 'plugin-api/beta/client/services';
import {Icon} from 'plugin-api/beta/client/components/ui';
import {Slot, ClickOutside} from 'plugin-api/beta/client/components';
const ViewingOptions = (props) => {
const toggleOpen = () => {
@@ -23,7 +24,9 @@ const ViewingOptions = (props) => {
<ClickOutside onClickOutside={handleClickOutside}>
<div className={cn([styles.root, 'coral-plugin-viewing-options'])}>
<div>
<button className={styles.button} onClick={toggleOpen}>Viewing Options
<button className={styles.button} onClick={toggleOpen}>
<Icon className={styles.filterIcon} name="filter_list" />
<span className={styles.filterText}>{t('viewing_options')}</span>
{props.open ? <Icon name="arrow_drop_up" className={styles.icon}/> : <Icon name="arrow_drop_down" className={styles.icon}/>}
</button>
</div>
@@ -1,9 +1,11 @@
import ViewingOptions from './containers/ViewingOptions';
import reducer from './reducer';
import translations from './translations.yml';
export default {
reducer,
slots: {
streamFilter: [ViewingOptions]
}
},
translations
};
@@ -0,0 +1,4 @@
en:
viewing_options: "Viewing Options"
es:
viewing_options: "Opciones de visualización"
@@ -24,6 +24,15 @@
cursor: pointer;
}
.noSelect {
-ms-user-select:none;
-moz-user-select: none;
-webkit-user-select: none;
-webkit-touch-callout:none;
user-select: none;
-webkit-tap-highlight-color:rgba(0,0,0,0);
}
.tooltip {
top: 36px;
left: auto;
@@ -15,13 +15,15 @@ export default class Tag extends React.Component {
}
showTooltip = () => {
showTooltip = e => {
e.preventDefault();
this.setState({
tooltip: true
});
}
hideTooltip = () => {
hideTooltip = (e) => {
e.preventDefault();
this.setState({
tooltip: false
});
@@ -30,10 +32,13 @@ export default class Tag extends React.Component {
render() {
const {tooltip} = this.state;
return(
<div onMouseEnter={this.showTooltip} onMouseLeave={this.hideTooltip} >
<div className={styles.noSelect} onMouseEnter={this.showTooltip}
onMouseLeave={this.hideTooltip} onTouchStart={this.showTooltip}
onTouchEnd={this.hideTooltip}>
{
isTagged(this.props.comment.tags, 'FEATURED') ? (
<span className={cn(styles.tag, {[styles.on]: tooltip})}>
<span
className={cn(styles.tag, styles.noSelect, {[styles.on]: tooltip})}>
{t('talk-plugin-featured-comments.featured')}
</span>
) : null
+11
View File
@@ -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:
+4
View File
@@ -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"