diff --git a/client/coral-admin/src/graphql/index.js b/client/coral-admin/src/graphql/index.js index 915fe2989..dea68c5e8 100644 --- a/client/coral-admin/src/graphql/index.js +++ b/client/coral-admin/src/graphql/index.js @@ -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); diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js index e85cbed8c..8b90f5acf 100644 --- a/client/coral-admin/src/index.js +++ b/client/coral-admin/src/index.js @@ -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(); diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index d5ca5c840..8df83da2a 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -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, diff --git a/client/coral-admin/src/routes/Moderation/hoc/withQueueConfig.js b/client/coral-admin/src/routes/Moderation/hoc/withQueueConfig.js new file mode 100644 index 000000000..a56051810 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/hoc/withQueueConfig.js @@ -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 ; + } + } + + return WithQueueConfig; +}); diff --git a/client/coral-admin/src/routes/Moderation/queueConfig.js b/client/coral-admin/src/routes/Moderation/queueConfig.js index b9e9e5320..9a23ccc6c 100644 --- a/client/coral-admin/src/routes/Moderation/queueConfig.js +++ b/client/coral-admin/src/routes/Moderation/queueConfig.js @@ -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(), }; diff --git a/client/coral-embed-stream/src/containers/StreamTabPanel.js b/client/coral-embed-stream/src/containers/StreamTabPanel.js index 7b91d58ee..152f34e39 100644 --- a/client/coral-embed-stream/src/containers/StreamTabPanel.js +++ b/client/coral-embed-stream/src/containers/StreamTabPanel.js @@ -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) => ( - - - - )); + const {plugins} = this.context; + return this.getSlotComponents(props.tabSlot).map((PluginComponent) => { + const pluginProps = plugins.getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData); + return ( + + + + ); + }); } getPluginTabPaneElements(props = this.props) { - return this.getSlotComponents(props.tabPaneSlot).map((PluginComponent) => ( - - - - )); + const {plugins} = this.context; + return this.getSlotComponents(props.tabPaneSlot).map((PluginComponent) => { + const pluginProps = plugins.getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData); + return ( + + + + ); + }); } render() { diff --git a/client/coral-embed-stream/src/graphql/index.js b/client/coral-embed-stream/src/graphql/index.js index b4c5ce163..9ec9a2132 100644 --- a/client/coral-embed-stream/src/graphql/index.js +++ b/client/coral-embed-stream/src/graphql/index.js @@ -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); diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js index ac38d2eab..dca4e31b4 100644 --- a/client/coral-embed-stream/src/index.js +++ b/client/coral-embed-stream/src/index.js @@ -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; diff --git a/client/coral-framework/components/IfSlotIsEmpty.js b/client/coral-framework/components/IfSlotIsEmpty.js index e9211fc57..557ee3b3d 100644 --- a/client/coral-framework/components/IfSlotIsEmpty.js +++ b/client/coral-framework/components/IfSlotIsEmpty.js @@ -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() { diff --git a/client/coral-framework/components/IfSlotIsNotEmpty.js b/client/coral-framework/components/IfSlotIsNotEmpty.js index e7a0e83ce..4e3abfae4 100644 --- a/client/coral-framework/components/IfSlotIsNotEmpty.js +++ b/client/coral-framework/components/IfSlotIsNotEmpty.js @@ -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() { diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index c0cca143c..dad5d5cf2 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -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 = ; + const props = plugins.getSlotComponentProps(DefaultComponent, reduxState, this.getSlotProps(this.props), queryData); + children = ; } return ( diff --git a/client/coral-framework/components/TalkProvider.js b/client/coral-framework/components/TalkProvider.js index 0f44af43f..1350244f6 100644 --- a/client/coral-framework/components/TalkProvider.js +++ b/client/coral-framework/components/TalkProvider.js @@ -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; diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js deleted file mode 100644 index b73c89c64..000000000 --- a/client/coral-framework/helpers/plugins.js +++ /dev/null @@ -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(); diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js index 9bc06290e..be6d07fed 100644 --- a/client/coral-framework/hocs/withFragments.js +++ b/client/coral-framework/hocs/withFragments.js @@ -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. diff --git a/client/coral-framework/hocs/withMutation.js b/client/coral-framework/hocs/withMutation.js index 7c3dcfa1a..46ec19eb7 100644 --- a/client/coral-framework/hocs/withMutation.js +++ b/client/coral-framework/hocs/withMutation.js @@ -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; }; diff --git a/client/coral-framework/hocs/withQuery.js b/client/coral-framework/hocs/withQuery.js index f19c3209e..c0825f28b 100644 --- a/client/coral-framework/hocs/withQuery.js +++ b/client/coral-framework/hocs/withQuery.js @@ -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); } diff --git a/client/coral-framework/services/bootstrap.js b/client/coral-framework/services/bootstrap.js index 6a18695f7..0aa2c4c35 100644 --- a/client/coral-framework/services/bootstrap.js +++ b/client/coral-framework/services/bootstrap.js @@ -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(), }; diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js index a354d68bc..ebd248f0d 100644 --- a/client/coral-framework/services/client.js +++ b/client/coral-framework/services/client.js @@ -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(); diff --git a/client/coral-framework/services/graphqlRegistry.js b/client/coral-framework/services/graphqlRegistry.js index 104ec41dd..5ca549183 100644 --- a/client/coral-framework/services/graphqlRegistry.js +++ b/client/coral-framework/services/graphqlRegistry.js @@ -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); } diff --git a/client/coral-framework/services/plugins.js b/client/coral-framework/services/plugins.js new file mode 100644 index 000000000..f51e33702 --- /dev/null +++ b/client/coral-framework/services/plugins.js @@ -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); +} diff --git a/plugin-api/beta/client/services/index.js b/plugin-api/beta/client/services/index.js index 54ad90fe7..4d2281dc8 100644 --- a/plugin-api/beta/client/services/index.js +++ b/plugin-api/beta/client/services/index.js @@ -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); -}