Remove global dependency on registry and plugins

This commit is contained in:
Chi Vinh Le
2017-08-23 01:41:58 +07:00
parent 54ea0b1a3f
commit fccbcce7a8
21 changed files with 582 additions and 496 deletions
+1 -4
View File
@@ -1,6 +1,4 @@
import {add} from 'coral-framework/services/graphqlRegistry';
const extension = {
export default {
mutations: {
SetUserStatus: () => ({
refetchQueries: ['CoralAdmin_Community'],
@@ -11,4 +9,3 @@ const extension = {
},
};
add(extension);
+3 -3
View File
@@ -6,10 +6,10 @@ import {createContext} from 'coral-framework/services/bootstrap';
import reducers from './reducers';
import App from './components/App';
import 'react-mdl/extra/material.js';
import './graphql';
import plugins from 'pluginsConfig';
import graphqlExtension from './graphql';
import pluginsConfig from 'pluginsConfig';
const context = createContext(reducers, plugins);
const context = createContext({reducers, graphqlExtension, pluginsConfig});
smoothscroll.polyfill();
@@ -26,25 +26,26 @@ import {
storySearchChange,
clearState
} from 'actions/moderation';
import withQueueConfig from '../hoc/withQueueConfig';
import {Spinner} from 'coral-ui';
import Moderation from '../components/Moderation';
import Comment from './Comment';
import queueConfig from '../queueConfig';
import baseQueueConfig from '../queueConfig';
function prepareNotificationText(text) {
return truncate(text, {length: 50}).replace('\n', ' ');
}
function getAssetId(props) {
if (props.params.tabOrId && !(props.params.tabOrId in queueConfig)) {
if (props.params.tabOrId && !(props.params.tabOrId in props.queueConfig)) {
return props.params.tabOrId;
}
return props.params.id || null;
}
function getTab(props) {
if (props.params.tabOrId && props.params.tabOrId in queueConfig) {
if (props.params.tabOrId && props.params.tabOrId in props.queueConfig) {
return props.params.tabOrId;
}
return props.params.tab || null;
@@ -54,7 +55,7 @@ class ModerationContainer extends Component {
subscriptions = [];
handleCommentChange = (root, comment, notify) => {
return handleCommentChange(root, comment, this.props.data.variables.sort, notify, queueConfig, this.activeTab);
return handleCommentChange(root, comment, this.props.data.variables.sort, notify, this.props.queueConfig, this.activeTab);
};
get activeTab() {
@@ -161,8 +162,8 @@ class ModerationContainer extends Component {
cursor: this.props.root[tab].endCursor,
sort: this.props.data.variables.sort,
asset_id: this.props.data.variables.asset_id,
statuses: queueConfig[tab].statuses,
action_type: queueConfig[tab].action_type,
statuses: this.props.queueConfig[tab].statuses,
action_type: this.props.queueConfig[tab].action_type,
};
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
@@ -206,7 +207,7 @@ class ModerationContainer extends Component {
}
const premodEnabled = assetId ? isPremod(asset.settings.moderation) : isPremod(settings.moderation);
const currentQueueConfig = Object.assign({}, queueConfig);
const currentQueueConfig = Object.assign({}, this.props.queueConfig);
if (premodEnabled) {
delete currentQueueConfig.new;
} else {
@@ -304,7 +305,7 @@ const commentConnectionFragment = gql`
${Comment.fragments.comment}
`;
const withModQueueQuery = withQuery(gql`
const withModQueueQuery = withQuery(({queueConfig}) => gql`
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER, $allAssets: Boolean!) {
${Object.keys(queueConfig).map((queue) => `
${queue}: comments(query: {
@@ -352,7 +353,7 @@ const withModQueueQuery = withQuery(gql`
},
});
const withQueueCountPolling = withQuery(gql`
const withQueueCountPolling = withQuery(({queueConfig}) => gql`
query CoralAdmin_ModerationCountPoll($asset_id: ID) {
${Object.keys(queueConfig).map((queue) => `
${queue}Count: commentCount(query: {
@@ -398,6 +399,7 @@ const mapDispatchToProps = (dispatch) => ({
});
export default compose(
withQueueConfig(baseQueueConfig),
connect(mapStateToProps, mapDispatchToProps),
withSetCommentStatus,
withQueueCountPolling,
@@ -0,0 +1,26 @@
import React from 'react';
import hoistStatics from 'recompose/hoistStatics';
import PropTypes from 'prop-types';
/**
* WithQueueConfig takes a `queueConfig` parameter that is
* passed down as a prop enriched with queue config data from plugins.
*/
export default (queueConfig) => hoistStatics((WrappedComponent) => {
class WithQueueConfig extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
pluginsConfig = this.context.plugins.getModQueueConfigs();
render() {
return <WrappedComponent
{...this.props}
queueConfig={{...queueConfig, ...this.pluginsConfig}}
/>;
}
}
return WithQueueConfig;
});
@@ -1,5 +1,4 @@
import t from 'coral-framework/services/i18n';
import {getModQueueConfigs} from 'coral-framework/helpers/plugins';
export default {
premod: {
@@ -33,5 +32,4 @@ export default {
icon: 'question_answer',
name: t('modqueue.all'),
},
...getModQueueConfigs(),
};
@@ -2,13 +2,15 @@ import React from 'react';
import StreamTabPanel from '../components/StreamTabPanel';
import {connect} from 'react-redux';
import omit from 'lodash/omit';
import {getSlotComponents, getSlotComponentProps} from 'coral-framework/helpers/plugins';
import {Tab, TabPane} from 'coral-ui';
import {getShallowChanges} from 'coral-framework/utils';
import isEqual from 'lodash/isEqual';
import PropTypes from 'prop-types';
class StreamTabPanelContainer extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
componentDidMount() {
this.fallbackAllTab();
@@ -43,28 +45,37 @@ class StreamTabPanelContainer extends React.Component {
}
getSlotComponents(slot, props = this.props) {
return getSlotComponents(slot, props.reduxState, props.slotProps, props.queryData);
const {plugins} = this.context;
return plugins.getSlotComponents(slot, props.reduxState, props.slotProps, props.queryData);
}
getPluginTabElements(props = this.props) {
return this.getSlotComponents(props.tabSlot).map((PluginComponent) => (
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData)}
active={this.props.activeTab === PluginComponent.talkPluginName}
/>
</Tab>
));
const {plugins} = this.context;
return this.getSlotComponents(props.tabSlot).map((PluginComponent) => {
const pluginProps = plugins.getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData);
return (
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...pluginProps}
active={this.props.activeTab === PluginComponent.talkPluginName}
/>
</Tab>
);
});
}
getPluginTabPaneElements(props = this.props) {
return this.getSlotComponents(props.tabPaneSlot).map((PluginComponent) => (
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData)}
/>
</TabPane>
));
const {plugins} = this.context;
return this.getSlotComponents(props.tabPaneSlot).map((PluginComponent) => {
const pluginProps = plugins.getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData);
return (
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...pluginProps}
/>
</TabPane>
);
});
}
render() {
@@ -1,10 +1,9 @@
import {gql} from 'react-apollo';
import {add} from 'coral-framework/services/graphqlRegistry';
import update from 'immutability-helper';
import uuid from 'uuid/v4';
import {insertCommentIntoEmbedQuery, removeCommentFromEmbedQuery} from './utils';
const extension = {
export default {
fragments: {
EditCommentResponse: gql`
fragment CoralEmbedStream_EditCommentResponse on EditCommentResponse {
@@ -223,4 +222,3 @@ const extension = {
},
};
add(extension);
+3 -3
View File
@@ -2,15 +2,15 @@ import React from 'react';
import {render} from 'react-dom';
import {checkLogin, handleAuthToken, logout} from 'coral-embed-stream/src/actions/auth';
import './graphql';
import graphqlExtension from './graphql';
import {addExternalConfig} from 'coral-embed-stream/src/actions/config';
import {createContext} from 'coral-framework/services/bootstrap';
import AppRouter from './AppRouter';
import reducers from './reducers';
import TalkProvider from 'coral-framework/components/TalkProvider';
import plugins from 'pluginsConfig';
import pluginsConfig from 'pluginsConfig';
const context = createContext(reducers, plugins);
const context = createContext({reducers, graphqlExtension, pluginsConfig});
// TODO: move init code into `bootstrap` service after auth has been refactored.
const {store, pym} = context;
@@ -1,11 +1,13 @@
import React from 'react';
import {connect} from 'react-redux';
import {isSlotEmpty} from 'coral-framework/helpers/plugins';
import PropTypes from 'prop-types';
import omit from 'lodash/omit';
import {getShallowChanges} from 'coral-framework/utils';
class IfSlotIsEmpty extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
@@ -22,7 +24,7 @@ class IfSlotIsEmpty extends React.Component {
isSlotEmpty(props = this.props) {
const {slot, className: _a, reduxState, component: _b = 'div', children: _c, ...rest} = props;
return isSlotEmpty(slot, reduxState, rest);
return this.context.plugins.isSlotEmpty(slot, reduxState, rest);
}
render() {
@@ -1,11 +1,13 @@
import React from 'react';
import {connect} from 'react-redux';
import {isSlotEmpty} from 'coral-framework/helpers/plugins';
import PropTypes from 'prop-types';
import omit from 'lodash/omit';
import {getShallowChanges} from 'coral-framework/utils';
class IfSlotIsNotEmpty extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
@@ -22,7 +24,7 @@ class IfSlotIsNotEmpty extends React.Component {
isSlotEmpty(props = this.props) {
const {slot, className: _a, reduxState, component: _b = 'div', children: _c, ...rest} = props;
return isSlotEmpty(slot, reduxState, rest);
return this.context.plugins.isSlotEmpty(slot, reduxState, rest);
}
render() {
+10 -3
View File
@@ -2,14 +2,18 @@ import React from 'react';
import cn from 'classnames';
import styles from './Slot.css';
import {connect} from 'react-redux';
import {getSlotElements, getSlotComponentProps} from 'coral-framework/helpers/plugins';
import omit from 'lodash/omit';
import PropTypes from 'prop-types';
import isEqual from 'lodash/isEqual';
import {getShallowChanges} from 'coral-framework/utils';
const emptyConfig = {};
class Slot extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
@@ -30,15 +34,18 @@ class Slot extends React.Component {
}
getChildren(props = this.props) {
return getSlotElements(props.fill, props.reduxState, this.getSlotProps(props), props.queryData);
const {plugins} = this.context;
return plugins.getSlotElements(props.fill, props.reduxState, this.getSlotProps(props), props.queryData);
}
render() {
const {plugins} = this.context;
const {inline = false, className, reduxState, defaultComponent: DefaultComponent, queryData} = this.props;
let children = this.getChildren();
const pluginConfig = reduxState.config.pluginConfig || emptyConfig;
if (children.length === 0 && DefaultComponent) {
children = <DefaultComponent {...getSlotComponentProps(DefaultComponent, reduxState, this.getSlotProps(this.props), queryData)} />;
const props = plugins.getSlotComponentProps(DefaultComponent, reduxState, this.getSlotProps(this.props), queryData);
children = <DefaultComponent {...props} />;
}
return (
@@ -8,6 +8,8 @@ class TalkProvider extends React.Component {
eventEmitter: this.props.eventEmitter,
pym: this.props.pym,
plugins: this.props.plugins,
rest: this.props.rest,
graphqlRegistry: this.props.graphqlRegistry,
};
}
@@ -24,7 +26,9 @@ class TalkProvider extends React.Component {
TalkProvider.childContextTypes = {
pym: PropTypes.object,
eventEmitter: PropTypes.object,
plugins: PropTypes.array,
plugins: PropTypes.object,
rest: PropTypes.func,
graphqlRegistry: PropTypes.object,
};
export default TalkProvider;
-166
View File
@@ -1,166 +0,0 @@
import React from 'react';
import uniq from 'lodash/uniq';
import pick from 'lodash/pick';
import merge from 'lodash/merge';
import flattenDeep from 'lodash/flattenDeep';
import isEmpty from 'lodash/isEmpty';
import flatten from 'lodash/flatten';
import mapValues from 'lodash/mapValues';
import {getDisplayName} from 'coral-framework/helpers/hoc';
import camelize from './camelize';
import plugins from 'pluginsConfig';
import uuid from 'uuid/v4';
// This is returned for pluginConfig when it is empty.
const emptyConfig = {};
export function getSlotComponents(slot, reduxState, props = {}, queryData = {}) {
const pluginConfig = reduxState.config.plugin_config || emptyConfig;
return flatten(plugins
// Filter out components that have slots and have been disabled in `plugin_config`
.filter((o) => o.module.slots && (!pluginConfig || !pluginConfig[o.name] || !pluginConfig[o.name].disable_components))
.filter((o) => o.module.slots[slot])
.map((o) => o.module.slots[slot])
)
.filter((component) => {
if(!component.isExcluded) {
return true;
}
let resolvedProps = getSlotComponentProps(component, reduxState, props, queryData);
if (component.mapStateToProps) {
resolvedProps = {...resolvedProps, ...component.mapStateToProps(reduxState)};
}
return !component.isExcluded(resolvedProps);
});
}
export function isSlotEmpty(slot, reduxState, props = {}, queryData = {}) {
return getSlotComponents(slot, reduxState, props, queryData).length === 0;
}
// Memoize the warnings so we only show them once.
const memoizedWarnings = [];
// withWarnings decorates the props of queryData with a proxy that
// prints a warning when accessing deeper props.
function withWarnings(component, queryData) {
if (process.env.NODE_ENV !== 'production' && window.Proxy) {
// Show warnings when accessing queryData only when not in production.
return mapValues(queryData, (value, key) => {
// Keep null values..
if (!queryData[key]) {
return queryData[key];
}
return new Proxy(queryData[key], {
get(target, name) {
// Only care about the components defined in the plugins.
if (component.talkPluginName) {
const warning = `'${getDisplayName(component)}' of '${component.talkPluginName}' accessed '${key}.${name}' but did not define fragments using the withFragment HOC`;
if (memoizedWarnings.indexOf(warning) === -1) {
console.warn(warning);
memoizedWarnings.push(warning);
}
}
return queryData[key][name];
}
});
});
}
return queryData;
}
/**
* getSlotComponentProps calculate the props we would pass to the slot component.
* query datas are only passed to the component if it is defined in `component.fragments`.
*/
export function getSlotComponentProps(component, reduxState, props, queryData) {
const pluginConfig = reduxState.config.plugin_config || emptyConfig;
return {
...props,
config: pluginConfig,
...(
component.fragments
? pick(queryData, Object.keys(component.fragments))
: withWarnings(component, queryData)
)
};
}
/**
* Returns React Elements for given slot.
*/
export function getSlotElements(slot, reduxState, props = {}, queryData = {}) {
return getSlotComponents(slot, reduxState, props, queryData)
.map((component, i) => {
return React.createElement(component, {key: i, ...getSlotComponentProps(component, reduxState, props, queryData)});
});
}
export function getSlotFragments(slot, part) {
const components = uniq(flattenDeep(plugins
.filter((o) => o.module.slots ? o.module.slots[slot] : false)
.map((o) => o.module.slots[slot])
));
const documents = components
.map((c) => c.fragments)
.filter((fragments) => fragments && fragments[part])
.reduce((res, fragments) => {
res.push(fragments[part]);
return res;
}, []);
return documents;
}
export function getGraphQLExtensions() {
return plugins
.map((o) => pick(o.module, ['mutations', 'queries', 'fragments']))
.filter((o) => !isEmpty(o));
}
export function getModQueueConfigs() {
return merge(...plugins
.map((o) => o.module.modQueues)
.filter((o) => o));
}
export function getTranslations(plugins) {
return plugins
.map((o) => o.module.translations)
.filter((o) => o);
}
export function getReducers(plugins) {
return merge(
...plugins
.filter((o) => o.module.reducer)
.map((o) => ({[camelize(o.name)] : o.module.reducer}))
);
}
function addMetaDataToSlotComponents() {
// Add talkPluginName to Slot Components.
plugins.forEach((plugin) => {
const slots = plugin.module.slots;
slots && Object.keys(slots).forEach((slot) => {
slots[slot].forEach((component) => {
// Attach plugin name to the component
component.talkPluginName = plugin.name;
// Attach uuid to the component
component.talkUuid = uuid();
});
});
});
}
addMetaDataToSlotComponents();
+17 -2
View File
@@ -1,9 +1,9 @@
import React from 'react';
import graphql from 'graphql-anywhere';
import {resolveFragments} from 'coral-framework/services/graphqlRegistry';
import mapValues from 'lodash/mapValues';
import hoistStatics from 'recompose/hoistStatics';
import {getShallowChanges} from 'coral-framework/utils';
import PropTypes from 'prop-types';
import union from 'lodash/union';
// TODO: Should not depend on `props.data`
@@ -63,7 +63,22 @@ function hasEqualLeaves(a, b, path = '') {
export default (fragments) => hoistStatics((BaseComponent) => {
class WithFragments extends React.Component {
fragments = mapValues(fragments, (val) => resolveFragments(val));
static contextTypes = {
graphqlRegistry: PropTypes.object,
};
get graphqlRegistry() {
return this.context.graphqlRegistry;
}
resolveDocument(documentOrCallback) {
const document = typeof documentOrCallback === 'function'
? documentOrCallback(this.props, this.context)
: documentOrCallback;
return this.graphqlRegistry.resolveFragments(document);
}
fragments = mapValues(fragments, (val) => this.resolveDocument(val));
fragmentKeys = Object.keys(fragments).sort();
// Cache variables between lifecycles to speed up render.
+14 -3
View File
@@ -4,7 +4,6 @@ import merge from 'lodash/merge';
import uniq from 'lodash/uniq';
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';
@@ -43,8 +42,20 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
static contextTypes = {
eventEmitter: PropTypes.object,
store: PropTypes.object,
graphqlRegistry: PropTypes.object,
};
get graphqlRegistry() {
return this.context.graphqlRegistry;
}
resolveDocument(documentOrCallback) {
const document = typeof documentOrCallback === 'function'
? documentOrCallback(this.props, this.context)
: documentOrCallback;
return this.graphqlRegistry.resolveFragments(document);
}
// Lazily resolve fragments from graphRegistry to support circular dependencies.
memoized = null;
@@ -56,7 +67,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
propsWrapper = (data) => {
const name = getDefinitionName(document);
const callbacks = getMutationOptions(name);
const callbacks = this.graphqlRegistry.getMutationOptions(name);
const mutate = (base) => {
const variables = base.variables || config.options.variables;
const configs = callbacks.map((cb) => cb({variables, state: this.context.store.getState()}));
@@ -167,7 +178,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
getWrapped = () => {
if (!this.memoized) {
this.memoized = graphql(resolveFragments(document), {...config, props: this.propsWrapper})(WrappedComponent);
this.memoized = graphql(this.resolveDocument(document), {...config, props: this.propsWrapper})(WrappedComponent);
}
return this.memoized;
};
+22 -8
View File
@@ -1,6 +1,5 @@
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';
import hoistStatics from 'recompose/hoistStatics';
@@ -37,17 +36,28 @@ function networkStatusToString(networkStatus) {
* apply query options registered in the graphRegistry.
*/
export default (document, config = {}) => hoistStatics((WrappedComponent) => {
const name = getDefinitionName(document);
return class WithQuery extends React.Component {
static contextTypes = {
eventEmitter: PropTypes.object,
graphqlRegistry: PropTypes.object,
};
// Lazily resolve fragments from graphRegistry to support circular dependencies.
memoized = null;
lastNetworkStatus = null;
data = null;
name = '';
get graphqlRegistry() {
return this.context.graphqlRegistry;
}
resolveDocument(documentOrCallback) {
const document = typeof documentOrCallback === 'function'
? documentOrCallback(this.props, this.context)
: documentOrCallback;
return this.graphqlRegistry.resolveFragments(document);
}
emitWhenNeeded(data) {
const {variables, networkStatus} = data;
@@ -93,11 +103,12 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
refetch: data.refetch,
updateQuery: data.updateQuery,
subscribeToMore: (stmArgs) => {
const resolvedDocument = this.resolveDocument(stmArgs.document);
// Resolve document fragments before passing it to `apollo-client`.
return data.subscribeToMore({
...stmArgs,
document: resolveFragments(stmArgs.document),
document: resolvedDocument,
onError: (err) => {
if (stmArgs.onErr) {
return stmArgs.onErr(err);
@@ -107,7 +118,8 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
});
},
fetchMore: (lmArgs) => {
const fetchName = getDefinitionName(lmArgs.query);
const resolvedDocument = this.resolveDocument(lmArgs.query);
const fetchName = getDefinitionName(resolvedDocument);
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.begin`,
{variables: lmArgs.variables});
@@ -115,7 +127,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
// Resolve document fragments before passing it to `apollo-client`.
return data.fetchMore({
...lmArgs,
query: resolveFragments(lmArgs.query),
query: resolvedDocument,
})
.then((res) => {
this.context.eventEmitter.emit(
@@ -156,7 +168,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
const base = (typeof this.wrappedConfig.options === 'function')
? this.wrappedConfig.options(data)
: this.wrappedConfig.options;
const configs = getQueryOptions(name);
const configs = this.graphqlRegistry.getQueryOptions(name);
const reducerCallbacks =
[base.reducer || ((i) => i)]
.concat(...configs.map((cfg) => cfg.reducer))
@@ -178,8 +190,10 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
getWrapped = () => {
if (!this.memoized) {
const resolvedDocument = this.resolveDocument(document);
this.name = getDefinitionName(resolvedDocument);
this.memoized = graphql(
resolveFragments(document),
resolvedDocument,
{...this.wrappedConfig, options: this.wrappedOptions},
)(WrappedComponent);
}
+18 -4
View File
@@ -6,10 +6,12 @@ import {createReduxEmitter} from './events';
import {createRestClient} from './rest';
import thunk from 'redux-thunk';
import {loadTranslations} from './i18n';
import {getTranslations, getReducers} from '../helpers/plugins';
import bowser from 'bowser';
import * as Storage from '../helpers/storage';
import {BASE_PATH} from 'coral-framework/constants/url';
import {createPluginsService} from './plugins';
import {createGraphQLRegistry} from './graphqlRegistry';
import globalFragments from 'coral-framework/graphql/fragments';
/**
* getAuthToken returns the active auth token or null
@@ -34,7 +36,7 @@ const getAuthToken = (store) => {
return null;
};
export function createContext(reducers, plugins) {
export function createContext({reducers = {}, pluginsConfig = [], graphqlExtension = {}}) {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const eventEmitter = new EventEmitter({wildcard: true});
let store = null;
@@ -56,16 +58,28 @@ export function createContext(reducers, plugins) {
liveUri: `${protocol}://${location.host}${BASE_PATH}api/v1/live`,
token,
});
const plugins = createPluginsService(pluginsConfig);
const graphqlRegistry = createGraphQLRegistry(plugins.getSlotFragments.bind(plugins));
const context = {
client,
pym,
plugins,
eventEmitter,
rest,
graphqlRegistry,
};
// Load framework fragments.
Object.keys(globalFragments).forEach((key) => graphqlRegistry.addFragment(key, globalFragments[key]));
// Register graphql extension
graphqlRegistry.add(graphqlExtension);
// Register plugin graphql extensions.
plugins.getGraphQLExtensions().forEach((ext) => graphqlRegistry.add(ext));
// Load plugin translations.
getTranslations(plugins).forEach((t) => loadTranslations(t));
plugins.getTranslations().forEach((t) => loadTranslations(t));
// Pass any events through our parent.
eventEmitter.onAny((eventName, value) => {
@@ -74,7 +88,7 @@ export function createContext(reducers, plugins) {
const finalReducers = {
...reducers,
...getReducers(plugins),
...plugins.getReducers(),
apollo: client.reducer(),
};
@@ -64,11 +64,6 @@ export function createClient(options = {}) {
});
client.resetWebsocket = () => {
if (wsClient === null) {
// Nothing to reset!
return;
}
// Close socket connection which will also unregister subscriptions on the server-side.
wsClient.close();
+241 -253
View File
@@ -1,6 +1,4 @@
import {getDefinitionName, mergeDocuments} from 'coral-framework/utils';
import {getGraphQLExtensions, getSlotFragments} from 'coral-framework/helpers/plugins';
import globalFragments from 'coral-framework/graphql/fragments';
import uniq from 'lodash/uniq';
import {gql} from 'react-apollo';
@@ -14,272 +12,262 @@ import {gql} from 'react-apollo';
*/
gql.disableFragmentWarnings();
const fragments = {};
const mutationOptions = {};
const queryOptions = {};
const getTypeName = (ast) => ast.definitions[0].typeCondition.name.value;
/**
* Add fragment
*
* Example:
* addFragment('MyFragment', gql`
* fragment Plugin_MyFragment on Comment {
* body
* }
* `);
*/
export function addFragment(key, document) {
const type = getTypeName(document);
const name = getDefinitionName(document);
if (!(key in fragments)) {
fragments[key] = {type, names: [name], documents: [document]};
} else {
if (type !== fragments[key].type) {
console.error(`Type mismatch ${type} !== ${fragments[key].type}`);
class GraphQLRegistry {
fragments = {};
mutationOptions = {};
queryOptions = {};
constructor(getSlotFragments) {
this.getSlotFragments = getSlotFragments;
}
/**
* Add fragment
*
* Example:
* addFragment('MyFragment', gql`
* fragment Plugin_MyFragment on Comment {
* body
* }
* `);
*/
addFragment(key, document) {
const type = getTypeName(document);
const name = getDefinitionName(document);
if (!(key in this.fragments)) {
this.fragments[key] = {type, names: [name], documents: [document]};
} else {
if (type !== this.fragments[key].type) {
console.error(`Type mismatch ${type} !== ${this.fragments[key].type}`);
}
this.fragments[key].names.push(name);
this.fragments[key].documents.push(document);
}
fragments[key].names.push(name);
fragments[key].documents.push(document);
}
}
/**
* Add mutation options.
*
* Example:
* // state is the current redux state, which is sometimes
* // necessary to fill the optimistic response.
* addMutationOptions('PostComment', ({variables, state}) => ({
* optimisticResponse: {
* CreateComment: {
* extra: '',
* },
* },
* refetchQueries: [],
* updateQueries: {
* EmbedQuery: (previous, data) => {
* return previous;
* },
* },
* update: (proxy, result) => {
* },
* })
*/
export function addMutationOptions(key, config) {
if (!(key in mutationOptions)) {
mutationOptions[key] = [config];
} else {
mutationOptions[key].push(config);
}
}
/**
* Add query options.
*
* Example:
* addQueryOptions('EmbedQuery', {
* reducer: (previousResult, action, variables) => previousResult,
* });
*/
export function addQueryOptions(key, config) {
if (!(key in queryOptions)) {
queryOptions[key] = [config];
} else {
queryOptions[key].push(config);
}
}
/**
* Add all fragments, mutation options, and query options defined in the object.
*
* Example:
* add({
* fragments: {
* CreateCommentResponse: gql`
* fragment CoralRandomEmoji_CreateCommentResponse on CreateCommentResponse {
* [...]
* }`,
* },
* mutations: {
* // state is the current redux state, which is sometimes
* // necessary to fill the optimistic response.
* PostComment: ({variables, state}) => ({
* optimisticResponse: {
* [...]
* },
* refetchQueries: [],
* updateQueries: {
* EmbedQuery: (previous, data) => {
* return previous;
* },
* },
* update: (proxy, result) => {
* },
* })
* },
* queries: {
* EmbedQuery: {
* reducer: (previousResult, action, variables) => {
* return previousResult;
* },
* },
* },
* });
*/
export function add(extension) {
Object.keys(extension.fragments || []).forEach((key) => addFragment(key, extension.fragments[key]));
Object.keys(extension.mutations || []).forEach((key) => addMutationOptions(key, extension.mutations[key]));
Object.keys(extension.queries || []).forEach((key) => addQueryOptions(key, extension.queries[key]));
}
/**
* Get a list of mutation options.
*/
export function getMutationOptions(key) {
init();
return mutationOptions[key] || [];
}
/**
* Get a list of query options.
*/
export function getQueryOptions(key) {
init();
return queryOptions[key] || [];
}
/**
* getSlotFragmentDocument handles `key`s in the form of TalkSlot_SlotName_Resource.
* It parses the slot name and the resource and usees the plugin API to assemble
* the fragment document.
*/
function getSlotFragmentDocument(key) {
const match = key.match(/TalkSlot_(.*)_(.*)/);
if (!match) {
return '';
}
const slot = match[1][0].toLowerCase() + match[1].substr(1);
const resource = match[2];
const documents = getSlotFragments(slot, resource);
if (documents.length === 0) {
return '';
}
const names = documents.map((d) => getDefinitionName(d));
const typeName = getTypeName(documents[0]);
// Assemble arguments for `gql` to call it directly without using template literals.
const main = `
fragment ${key} on ${typeName} {
...${names.join('\n...')}\n
/**
* Add mutation options.
*
* Example:
* // state is the current redux state, which is sometimes
* // necessary to fill the optimistic response.
* addMutationOptions('PostComment', ({variables, state}) => ({
* optimisticResponse: {
* CreateComment: {
* extra: '',
* },
* },
* refetchQueries: [],
* updateQueries: {
* EmbedQuery: (previous, data) => {
* return previous;
* },
* },
* update: (proxy, result) => {
* },
* })
*/
addMutationOptions(key, config) {
if (!(key in this.mutationOptions)) {
this.mutationOptions[key] = [config];
} else {
this.mutationOptions[key].push(config);
}
`;
return mergeDocuments([main, ...documents]);
}
/**
* getRegistryFragmentDocument assembles a fragment document using
* all registered fragment under given `key`.
*/
function getRegistryFragmentDocument(key) {
if (!(key in fragments)) {
return '';
}
let documents = fragments[key].documents;
let fields = `...${fragments[key].names.join('\n...')}\n`;
// Assemble arguments for `gql` to call it directly without using template literals.
const main = `
fragment ${key} on ${fragments[key].type} {
${fields}
/**
* Add query options.
*
* Example:
* addQueryOptions('EmbedQuery', {
* reducer: (previousResult, action, variables) => previousResult,
* });
*/
addQueryOptions(key, config) {
if (!(key in this.queryOptions)) {
this.queryOptions[key] = [config];
} else {
this.queryOptions[key].push(config);
}
`;
return mergeDocuments([main, ...documents]);
}
}
/**
* getFragmentDocument returns a fragment that assembles all registered
* fragments under given `key` or if `key` refers to Slot fragments it will
* return the slot fragments specified by this key.
*/
export function getFragmentDocument(key) {
init();
return getRegistryFragmentDocument(key) || getSlotFragmentDocument(key);
}
/**
* Add all fragments, mutation options, and query options defined in the object.
*
* Example:
* add({
* fragments: {
* CreateCommentResponse: gql`
* fragment CoralRandomEmoji_CreateCommentResponse on CreateCommentResponse {
* [...]
* }`,
* },
* mutations: {
* // state is the current redux state, which is sometimes
* // necessary to fill the optimistic response.
* PostComment: ({variables, state}) => ({
* optimisticResponse: {
* [...]
* },
* refetchQueries: [],
* updateQueries: {
* EmbedQuery: (previous, data) => {
* return previous;
* },
* },
* update: (proxy, result) => {
* },
* })
* },
* queries: {
* EmbedQuery: {
* reducer: (previousResult, action, variables) => {
* return previousResult;
* },
* },
* },
* });
*/
add(extension) {
Object.keys(extension.fragments || []).forEach((key) => this.addFragment(key, extension.fragments[key]));
Object.keys(extension.mutations || []).forEach((key) => this.addMutationOptions(key, extension.mutations[key]));
Object.keys(extension.queries || []).forEach((key) => this.addQueryOptions(key, extension.queries[key]));
}
// The fragments and configs are lazily loaded to allow circular dependencies to work.
// TODO: We might want to change this to an explicit add after we have lazy Queries and Mutations.
let initialized = false;
/**
* Get a list of mutation options.
*/
getMutationOptions(key) {
return this.mutationOptions[key] || [];
}
function init() {
if (initialized) { return; }
initialized = true;
/**
* Get a list of query options.
*/
getQueryOptions(key) {
return this.queryOptions[key] || [];
}
// Add fragments from framework.
[globalFragments].forEach((map) =>
Object.keys(map).forEach((key) => addFragment(key, map[key]))
);
// Add configs from plugins.
getGraphQLExtensions().forEach((ext) => add(ext));
}
/**
* resolveFragments finds fragment spread names and attachs
* the related fragment document to the given root document.
*/
export function resolveFragments(document) {
if (document.loc.source) {
// Remember keys that we have already resolved.
const resolvedKeys = [];
// Spreads from slots that are empty and need to be removed.
// (works around the issue that we don't know the resource type
// if we don't have a fragment)
const spreadsToBeRemoved = [];
// fragments to be attached.
const subFragments = [];
// body contains the final result.
let body = document.loc.source.body;
let done = false;
while (!done) {
done = true;
const matchedSubFragments = body.match(/\.\.\.([_a-zA-Z][_a-zA-Z0-9]*)/g) || [];
uniq(matchedSubFragments.map((f) => f.replace('...', '')))
.filter((key) => resolvedKeys.indexOf(key) === -1)
.forEach((key) => {
const doc = getFragmentDocument(key);
if (doc) {
subFragments.push(doc);
// We found a new fragment, so we are not done yet.
done = false;
} else if(key.startsWith('TalkSlot_')) {
spreadsToBeRemoved.push(key);
}
resolvedKeys.push(key);
});
body = mergeDocuments([body, ...subFragments]).loc.source.body;
/**
* getSlotFragmentDocument handles `key`s in the form of TalkSlot_SlotName_Resource.
* It parses the slot name and the resource and usees the plugin API to assemble
* the fragment document.
*/
getSlotFragmentDocument(key) {
const match = key.match(/TalkSlot_(.*)_(.*)/);
if (!match) {
return '';
}
spreadsToBeRemoved.forEach((key) => {
const regex = new RegExp(`\\.\\.\\.${key}\n`, 'g');
body = body.replace(regex, '');
});
const slot = match[1][0].toLowerCase() + match[1].substr(1);
const resource = match[2];
const documents = this.getSlotFragments(slot, resource);
return gql`${body}`;
} else {
console.warn('Can only resolve fragments from documents definied using the gql tag.');
if (documents.length === 0) {
return '';
}
const names = documents.map((d) => getDefinitionName(d));
const typeName = getTypeName(documents[0]);
// Assemble arguments for `gql` to call it directly without using template literals.
const main = `
fragment ${key} on ${typeName} {
...${names.join('\n...')}\n
}
`;
return mergeDocuments([main, ...documents]);
}
/**
* getRegistryFragmentDocument assembles a fragment document using
* all registered fragment under given `key`.
*/
getRegistryFragmentDocument(key) {
if (!(key in this.fragments)) {
return '';
}
let documents = this.fragments[key].documents;
let fields = `...${this.fragments[key].names.join('\n...')}\n`;
// Assemble arguments for `gql` to call it directly without using template literals.
const main = `
fragment ${key} on ${this.fragments[key].type} {
${fields}
}
`;
return mergeDocuments([main, ...documents]);
}
/**
* getFragmentDocument returns a fragment that assembles all registered
* fragments under given `key` or if `key` refers to Slot fragments it will
* return the slot fragments specified by this key.
*/
getFragmentDocument(key) {
return this.getRegistryFragmentDocument(key) || this.getSlotFragmentDocument(key);
}
/**
* resolveFragments finds fragment spread names and attachs
* the related fragment document to the given root document.
*/
resolveFragments(document) {
if (document.loc.source) {
// Remember keys that we have already resolved.
const resolvedKeys = [];
// Spreads from slots that are empty and need to be removed.
// (works around the issue that we don't know the resource type
// if we don't have a fragment)
const spreadsToBeRemoved = [];
// fragments to be attached.
const subFragments = [];
// body contains the final result.
let body = document.loc.source.body;
let done = false;
while (!done) {
done = true;
const matchedSubFragments = body.match(/\.\.\.([_a-zA-Z][_a-zA-Z0-9]*)/g) || [];
uniq(matchedSubFragments.map((f) => f.replace('...', '')))
.filter((key) => resolvedKeys.indexOf(key) === -1)
.forEach((key) => {
const doc = this.getFragmentDocument(key);
if (doc) {
subFragments.push(doc);
// We found a new fragment, so we are not done yet.
done = false;
} else if(key.startsWith('TalkSlot_')) {
spreadsToBeRemoved.push(key);
}
resolvedKeys.push(key);
});
body = mergeDocuments([body, ...subFragments]).loc.source.body;
}
spreadsToBeRemoved.forEach((key) => {
const regex = new RegExp(`\\.\\.\\.${key}\n`, 'g');
body = body.replace(regex, '');
});
return gql`${body}`;
} else {
console.warn('Can only resolve fragments from documents definied using the gql tag.');
}
return document;
}
return document;
}
export function createGraphQLRegistry(getSlotFragments) {
return new GraphQLRegistry(getSlotFragments);
}
+174
View File
@@ -0,0 +1,174 @@
import React from 'react';
import uniq from 'lodash/uniq';
import pick from 'lodash/pick';
import merge from 'lodash/merge';
import flattenDeep from 'lodash/flattenDeep';
import isEmpty from 'lodash/isEmpty';
import flatten from 'lodash/flatten';
import mapValues from 'lodash/mapValues';
import {getDisplayName} from 'coral-framework/helpers/hoc';
import camelize from '../helpers/camelize';
import uuid from 'uuid/v4';
// This is returned for pluginConfig when it is empty.
const emptyConfig = {};
// Memoize the warnings so we only show them once.
const memoizedWarnings = [];
// withWarnings decorates the props of queryData with a proxy that
// prints a warning when accessing deeper props.
function withWarnings(component, queryData) {
if (process.env.NODE_ENV !== 'production' && window.Proxy) {
// Show warnings when accessing queryData only when not in production.
return mapValues(queryData, (value, key) => {
// Keep null values..
if (!queryData[key]) {
return queryData[key];
}
return new Proxy(queryData[key], {
get(target, name) {
// Only care about the components defined in the plugins.
if (component.talkPluginName) {
const warning = `'${getDisplayName(component)}' of '${component.talkPluginName}' accessed '${key}.${name}' but did not define fragments using the withFragment HOC`;
if (memoizedWarnings.indexOf(warning) === -1) {
console.warn(warning);
memoizedWarnings.push(warning);
}
}
return queryData[key][name];
}
});
});
}
return queryData;
}
function addMetaDataToSlotComponents(plugins) {
// Add talkPluginName to Slot Components.
plugins.forEach((plugin) => {
const slots = plugin.module.slots;
slots && Object.keys(slots).forEach((slot) => {
slots[slot].forEach((component) => {
// Attach plugin name to the component
component.talkPluginName = plugin.name;
// Attach uuid to the component
component.talkUuid = uuid();
});
});
});
}
class PluginsService {
constructor(plugins) {
this.plugins = plugins;
addMetaDataToSlotComponents(plugins);
}
getSlotComponents(slot, reduxState, props = {}, queryData = {}) {
const pluginConfig = reduxState.config.plugin_config || emptyConfig;
return flatten(this.plugins
// Filter out components that have slots and have been disabled in `plugin_config`
.filter((o) => o.module.slots && (!pluginConfig || !pluginConfig[o.name] || !pluginConfig[o.name].disable_components))
.filter((o) => o.module.slots[slot])
.map((o) => o.module.slots[slot])
)
.filter((component) => {
if(!component.isExcluded) {
return true;
}
let resolvedProps = this.getSlotComponentProps(component, reduxState, props, queryData);
if (component.mapStateToProps) {
resolvedProps = {...resolvedProps, ...component.mapStateToProps(reduxState)};
}
return !component.isExcluded(resolvedProps);
});
}
isSlotEmpty(slot, reduxState, props = {}, queryData = {}) {
return this.getSlotComponents(slot, reduxState, props, queryData).length === 0;
}
/**
* getSlotComponentProps calculate the props we would pass to the slot component.
* query datas are only passed to the component if it is defined in `component.fragments`.
*/
getSlotComponentProps(component, reduxState, props, queryData) {
const pluginConfig = reduxState.config.plugin_config || emptyConfig;
return {
...props,
config: pluginConfig,
...(
component.fragments
? pick(queryData, Object.keys(component.fragments))
: withWarnings(component, queryData)
)
};
}
/**
* Returns React Elements for given slot.
*/
getSlotElements(slot, reduxState, props = {}, queryData = {}) {
return this.getSlotComponents(slot, reduxState, props, queryData)
.map((component, i) => {
return React.createElement(component, {key: i, ...this.getSlotComponentProps(component, reduxState, props, queryData)});
});
}
getSlotFragments(slot, part) {
const components = uniq(flattenDeep(this.plugins
.filter((o) => o.module.slots ? o.module.slots[slot] : false)
.map((o) => o.module.slots[slot])
));
const documents = components
.map((c) => c.fragments)
.filter((fragments) => fragments && fragments[part])
.reduce((res, fragments) => {
res.push(fragments[part]);
return res;
}, []);
return documents;
}
getGraphQLExtensions() {
return this.plugins
.map((o) => pick(o.module, ['mutations', 'queries', 'fragments']))
.filter((o) => !isEmpty(o));
}
getModQueueConfigs() {
return merge(...this.plugins
.map((o) => o.module.modQueues)
.filter((o) => o));
}
getTranslations() {
return this.plugins
.map((o) => o.module.translations)
.filter((o) => o);
}
getReducers() {
return merge(
...this.plugins
.filter((o) => o.module.reducer)
.map((o) => ({[camelize(o.name)] : o.module.reducer}))
);
}
}
export function createPluginsService(plugins) {
return new PluginsService(plugins);
}
-6
View File
@@ -1,9 +1,3 @@
export {t, timeago} from 'coral-framework/services/i18n';
export {can} from 'coral-framework/services/perms';
import {isSlotEmpty as ise} from 'coral-framework/helpers/plugins';
// @TODO: Deprecated.
export function isSlotEmpty(...args) {
console.warn('A plugin is using `isSlotEmpty` which has been deprecated, please port to the new API using the `IfSlotIsEmpty` and `IfSlotIsNotEmpty` components.');
return ise(...args);
}