diff --git a/.eslintignore b/.eslintignore
index d506a20c2..fc0d50a25 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -3,4 +3,5 @@ client/lib
**/*.html
plugins/*
!plugins/coral-plugin-facebook-auth
-node_modules
\ No newline at end of file
+!plugins/coral-plugin-respect
+node_modules
diff --git a/.gitignore b/.gitignore
index 6b7b187d9..8982613ac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,4 +16,5 @@ coverage/
plugins.json
plugins/*
-!plugins/coral-plugin-facebook-auth
\ No newline at end of file
+!plugins/coral-plugin-facebook-auth
+!plugins/coral-plugin-respect
diff --git a/.nodemon.json b/.nodemon.json
index 4c48c707e..7f7fd3d59 100644
--- a/.nodemon.json
+++ b/.nodemon.json
@@ -1,5 +1,5 @@
{
"verbose": true,
- "ignore": ["test/*", "client/*", "dist/*"],
+ "ignore": ["test/*", "client/*", "dist/*", "plugins/*/client"],
"ext": "js,json,graphql"
}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 72c75ce27..94e2be437 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -10,10 +10,11 @@ By contributing to this project you agree to the [Code of Conduct](https://coral
You can view what the Coral Team is working on next here https://www.pivotaltracker.com/n/projects/1863625.
-You can view product ideas and our longer term roadmap here https://trello.com/b/ILND751a/talk.
+If you'd like to see our longer term roadmap, [get in touch.](https://coralproject.net/contact.html)
## Contribute to the documentation
+
Clear docs are a prerequisite for a successful open source project. We value non-code and code contributions equally.
We are looking for _documentarians_ to:
@@ -55,7 +56,7 @@ Please [contact us](https://github.com/coralproject/talk/wiki/Contact-Us) early
To get an idea of where the Coral Team is going, see:
-* our [product/design Trello board](https://trello.com/b/ILND751a/talk),
+* our [Slack channel](https://coralprojectslackin.herokuapp.com/), where you can talk directly with the Team and other community members,
* our [current stories](https://www.pivotaltracker.com/n/projects/1863625), and
* our [issues](https://github.com/coralproject/talk/issues).
diff --git a/client/coral-docs/src/index.js b/client/coral-docs/src/index.js
index 5d8c49d9d..e706203a8 100644
--- a/client/coral-docs/src/index.js
+++ b/client/coral-docs/src/index.js
@@ -1,8 +1,8 @@
import React from 'react';
-import ReactDOM from 'react-dom';
+import {render} from 'react-dom';
import {GraphQLDocs} from 'graphql-docs';
import fetcher from './services/fetcher';
// Render the application into the DOM
-ReactDOM.render( , document.querySelector('#root'));
+render( , document.querySelector('#root'));
diff --git a/client/coral-embed-stream/src/Comment.js b/client/coral-embed-stream/src/Comment.js
index d970a5f35..45f2ef084 100644
--- a/client/coral-embed-stream/src/Comment.js
+++ b/client/coral-embed-stream/src/Comment.js
@@ -19,6 +19,7 @@ import FlagComment from 'coral-plugin-flags/FlagComment';
import LikeButton from 'coral-plugin-likes/LikeButton';
import {BestButton, IfUserCanModifyBest, BEST_TAG, commentIsBest, BestIndicator} from 'coral-plugin-best/BestButton';
import LoadMore from 'coral-embed-stream/src/LoadMore';
+import {Slot} from 'coral-framework';
import styles from './Comment.css';
@@ -157,6 +158,7 @@ class Comment extends React.Component {
?
: null }
+
@@ -187,6 +189,7 @@ class Comment extends React.Component {
removeBest={removeBestTag} />
+
@@ -241,7 +244,8 @@ class Comment extends React.Component {
showSignInDialog={showSignInDialog}
reactKey={reply.id}
key={reply.id}
- comment={reply} />;
+ comment={reply}
+ />;
})
}
{
diff --git a/client/coral-embed-stream/src/Embed.js b/client/coral-embed-stream/src/Embed.js
index 016536708..d9b5c8310 100644
--- a/client/coral-embed-stream/src/Embed.js
+++ b/client/coral-embed-stream/src/Embed.js
@@ -313,5 +313,5 @@ export default compose(
addCommentTag,
removeCommentTag,
deleteAction,
- queryStream
+ queryStream,
)(Embed);
diff --git a/client/coral-embed-stream/src/Stream.js b/client/coral-embed-stream/src/Stream.js
index 34145ed4d..66c19e0ab 100644
--- a/client/coral-embed-stream/src/Stream.js
+++ b/client/coral-embed-stream/src/Stream.js
@@ -41,7 +41,8 @@ class Stream extends React.Component {
deleteAction,
showSignInDialog,
addCommentTag,
- removeCommentTag
+ removeCommentTag,
+ pluginProps
} = this.props;
return (
@@ -67,7 +68,9 @@ class Stream extends React.Component {
showSignInDialog={showSignInDialog}
key={comment.id}
reactKey={comment.id}
- comment={comment} />
+ comment={comment}
+ pluginProps={pluginProps}
+ />
)
}
diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css
index c712d1696..2ef617593 100644
--- a/client/coral-embed-stream/style/default.css
+++ b/client/coral-embed-stream/style/default.css
@@ -11,7 +11,7 @@ html, body {
}
body {
- font-family: 'Lato', sans-serif;
+ font-family: Helvetica, 'Helvetica Neue', Verdana, sans-serif;
width: 100%;
font-size: 14px;
margin: 0px;
@@ -255,13 +255,13 @@ hr {
.commentActionsRight, .replyActionsRight {
display: flex;
justify-content: flex-end;
- width: 50%;
+ width: 30%;
}
.commentActionsLeft, .replyActionsLeft {
display: flex;
justify-content: flex-start;
float: left;
- width: 50%;
+ width: 70%;
}
.comment__action-container .material-icons {
diff --git a/client/coral-framework/actions/asset.js b/client/coral-framework/actions/asset.js
index fa927e58a..50efa3dcb 100644
--- a/client/coral-framework/actions/asset.js
+++ b/client/coral-framework/actions/asset.js
@@ -3,7 +3,7 @@ import coralApi from '../helpers/response';
import {addNotification} from '../actions/notification';
import {pym} from 'coral-framework';
-import I18n from '../../coral-framework/modules/i18n/i18n';
+import I18n from 'coral-framework/modules/i18n/i18n';
import translations from './../translations';
const lang = new I18n(translations);
diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js
index 01174cf05..11e7bd996 100644
--- a/client/coral-framework/actions/auth.js
+++ b/client/coral-framework/actions/auth.js
@@ -1,3 +1,5 @@
+import {gql} from 'react-apollo';
+import client from 'coral-framework/services/client';
import I18n from '../../coral-framework/modules/i18n/i18n';
import translations from './../translations';
const lang = new I18n(translations);
@@ -5,6 +7,20 @@ import * as actions from '../constants/auth';
import coralApi, {base} from '../helpers/response';
import {pym} from 'coral-framework';
+const ME_QUERY = gql`
+ query Me {
+ me {
+ status
+ }
+ }
+`;
+
+function fetchMe() {
+ return client.query({
+ fetchPolicy: 'network-only',
+ query: ME_QUERY});
+}
+
// Dialog Actions
export const showSignInDialog = (offset = 0) => ({type: actions.SHOW_SIGNIN_DIALOG, offset});
export const hideSignInDialog = () => ({type: actions.HIDE_SIGNIN_DIALOG});
@@ -52,6 +68,7 @@ export const fetchSignIn = (formData) => (dispatch) => {
const isAdmin = !!user && !!user.roles.filter(i => i === 'ADMIN').length;
dispatch(signInSuccess(user, isAdmin));
dispatch(hideSignInDialog());
+ fetchMe();
})
.catch(error => {
if (error.metadata) {
@@ -104,6 +121,7 @@ export const facebookCallback = (err, data) => dispatch => {
dispatch(signInFacebookSuccess(user));
dispatch(hideSignInDialog());
dispatch(showCreateUsernameDialog());
+ fetchMe();
} catch (err) {
dispatch(signInFacebookFailure(err));
return;
@@ -151,7 +169,10 @@ const logOutFailure = () => ({type: actions.LOGOUT_FAILURE});
export const logout = () => dispatch => {
dispatch(logOutRequest());
return coralApi('/auth', {method: 'DELETE'})
- .then(() => dispatch(logOutSuccess()))
+ .then(() => {
+ dispatch(logOutSuccess());
+ fetchMe();
+ })
.catch(error => dispatch(logOutFailure(error)));
};
diff --git a/client/coral-framework/actions/index.js b/client/coral-framework/actions/index.js
new file mode 100644
index 000000000..65f7b87d3
--- /dev/null
+++ b/client/coral-framework/actions/index.js
@@ -0,0 +1,9 @@
+import * as authActions from './auth';
+import * as assetActions from './asset';
+import * as notificationActions from './notification';
+
+export default {
+ authActions,
+ assetActions,
+ notificationActions,
+};
diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js
new file mode 100644
index 000000000..3d1d61328
--- /dev/null
+++ b/client/coral-framework/components/Slot.js
@@ -0,0 +1,19 @@
+import React, {Component} from 'react';
+import {getSlotElements} from 'coral-framework/helpers/plugins';
+
+class Slot extends Component {
+ render() {
+ const {fill, ...rest} = this.props;
+ return (
+
+ {getSlotElements(fill, rest)}
+
+ );
+ }
+}
+
+Slot.propTypes = {
+ fill: React.PropTypes.string
+};
+
+export default Slot;
diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js
new file mode 100644
index 000000000..e0d345db4
--- /dev/null
+++ b/client/coral-framework/helpers/plugins.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import merge from 'lodash/merge';
+import flatten from 'lodash/flatten';
+import plugins from 'pluginsConfig';
+
+export const pluginReducers = merge(
+ ...plugins
+ .filter(o => o.module.reducer)
+ .map(o => ({...o.module.reducer}))
+);
+
+/**
+ * Returns React Elements for given slot.
+ */
+export function getSlotElements(slot, props = {}) {
+ const components = flatten(plugins
+ .filter(o => o.module.slots[slot])
+ .map(o => o.module.slots[slot]));
+ return components
+ .map((component, i) => React.createElement(component, {...props, key: i}));
+}
diff --git a/client/coral-framework/index.js b/client/coral-framework/index.js
index 2900466ee..96750bffd 100644
--- a/client/coral-framework/index.js
+++ b/client/coral-framework/index.js
@@ -1,15 +1,16 @@
import store from './services/store';
import pym from './services/PymConnection';
import I18n from './modules/i18n/i18n';
-import * as authActions from './actions/auth';
-import * as assetActions from './actions/asset';
-import * as notificationActions from './actions/notification';
+import actions from './actions';
+import Slot from './components/Slot';
-export {
+// TODO (bc): Deprecate old actions. Spreading actions is now needed.
+
+export default {
pym,
+ Slot,
I18n,
store,
- authActions,
- assetActions,
- notificationActions
+ actions,
+ ...actions
};
diff --git a/client/coral-framework/loaders/plugins-loader.js b/client/coral-framework/loaders/plugins-loader.js
new file mode 100644
index 000000000..c3534df32
--- /dev/null
+++ b/client/coral-framework/loaders/plugins-loader.js
@@ -0,0 +1,33 @@
+/**
+ * Executes `source` to retrieve plugins configuration
+ * and loads the `index.js` of specified plugins.
+ *
+ * Outputs a module that looks like the following:
+ *
+ * module.exports = [{plugin: string, module: object}, ...]
+ *
+ */
+const {stripIndent} = require('common-tags');
+
+function getPluginList(config) {
+ if (config && config.client) {
+ return config.client.map(x => typeof x === 'string' ? x : Object.keys(x)[0]);
+ }
+
+ return [];
+}
+
+module.exports = function(source) {
+ this.cacheable();
+ const config = this.exec(source, this.resourcePath);
+ const plugins = getPluginList(config).map((plugin) => `{
+ module: require('${plugin}/client'),
+ plugin: '${plugin}'
+ }`);
+
+ return stripIndent`
+ module.exports = [
+ ${plugins.join(',')}
+ ];
+ `;
+};
diff --git a/client/coral-framework/reducers/index.js b/client/coral-framework/reducers/index.js
index 9e98c901a..fae7e60d5 100644
--- a/client/coral-framework/reducers/index.js
+++ b/client/coral-framework/reducers/index.js
@@ -1,9 +1,11 @@
import auth from './auth';
import user from './user';
import asset from './asset';
+import {pluginReducers} from '../helpers/plugins';
export default {
auth,
user,
asset,
+ ...pluginReducers
};
diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js
index 7744ea9bd..3ad43fee4 100644
--- a/client/coral-framework/services/client.js
+++ b/client/coral-framework/services/client.js
@@ -16,9 +16,11 @@ export const client = new ApolloClient({
queryTransformer: addTypename,
dataIdFromObject: (result) => {
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
- return result.__typename + result.id; // eslint-disable-line no-underscore-dangle
+ return `${result.__typename}_${result.id}`; // eslint-disable-line no-underscore-dangle
}
return null;
},
networkInterface: networkInterfaceWithSubscriptions,
});
+
+export default client;
diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js
new file mode 100644
index 000000000..6d589cf23
--- /dev/null
+++ b/client/coral-framework/utils/index.js
@@ -0,0 +1,7 @@
+/**
+* getActionSummary
+* retrieves the action summary based on the type and the comment
+*/
+
+export const getActionSummary = (type, comment) =>
+ comment.action_summaries.filter(a => a.__typename === type)[0];
diff --git a/graph/helpers/response.js b/graph/helpers/response.js
new file mode 100644
index 000000000..ebdbc02ec
--- /dev/null
+++ b/graph/helpers/response.js
@@ -0,0 +1,31 @@
+const errors = require('../../errors');
+const {Error: {ValidationError}} = require('mongoose');
+
+/**
+ * Wraps up a promise to return an object with the resolution of the promise
+ * keyed at `key` or an error caught at `errors`.
+ */
+
+const wrapResponse = (key) => (promise) => {
+ return promise.then((value) => {
+ let res = {};
+ if (key) {
+ res[key] = value;
+ }
+ return res;
+ }).catch((err) => {
+ if (err instanceof errors.APIError) {
+ return {
+ errors: [err]
+ };
+ } else if (err instanceof ValidationError) {
+
+ // TODO: wrap this with one of our internal errors.
+ throw err;
+ }
+
+ throw err;
+ });
+};
+
+module.exports = wrapResponse;
diff --git a/graph/hooks.js b/graph/hooks.js
index 3fe1306cf..499eb60b1 100644
--- a/graph/hooks.js
+++ b/graph/hooks.js
@@ -1,4 +1,7 @@
-const {forEachField} = require('graphql-tools');
+const {
+ GraphQLObjectType,
+ GraphQLInterfaceType
+} = require('graphql');
const debug = require('debug')('talk:graph:schema');
/**
@@ -22,6 +25,33 @@ const defaultResolveFn = (source, args, context, {fieldName}) => {
}
};
+// This function is pretty much copied verbatim from the graphql-tools repo:
+// https://github.com/apollographql/graphql-tools/blob/b12973c86e00be209d04af0184780998056051c4/src/schemaGenerator.ts#L180-L194
+// With the small alteration that we look for the `resolveType` function on the
+// schema so we can wrap post hooks around it to provide additional resolve
+// points.
+const forEachField = (schema, fn) => {
+ const typeMap = schema.getTypeMap();
+ Object.keys(typeMap).forEach((typeName) => {
+ const type = typeMap[typeName];
+
+ if (type instanceof GraphQLObjectType || type instanceof GraphQLInterfaceType) {
+
+ // Here we capture the change to extract the resolve type. We pass this
+ // with the `isResolveType = true` to introduce the specific beheviour.
+ if ('resolveType' in type) {
+ fn(type, typeName, '__resolveType', true);
+ }
+
+ const fields = type.getFields();
+ Object.keys(fields).forEach((fieldName) => {
+ const field = fields[fieldName];
+ fn(field, typeName, fieldName);
+ });
+ }
+ });
+};
+
/**
* Decorates the schema with pre and post hooks as provided by the Plugin
* Manager.
@@ -29,7 +59,7 @@ const defaultResolveFn = (source, args, context, {fieldName}) => {
* @param {Array} hooks hooks to apply to the schema
* @return {void}
*/
-const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeName, fieldName) => {
+const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeName, fieldName, isResolveType = false) => {
// Pull out the pre/post hooks from the available hooks.
const {
@@ -85,6 +115,48 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa
return;
}
+ // If this is a resolve type, we need to do some specific things to handle
+ // this type of field.
+ if (isResolveType) {
+
+ // Warn if we have any pre hooks.
+ if (pre.length !== 0) {
+ throw new Error(`invalid pre hooks were found for ${typeName}.${fieldName}, only post hooks are supported on the __resolveType hook`);
+ }
+
+ // This only needs to do something if post hooks are defined.
+ if (post.length === 0) {
+ return;
+ }
+
+ // Cache the original resolverType function.
+ let resolveType = field.resolveType;
+
+ // Return the function to handle the resolveType hooks.
+ field.resolveType = (obj, context, info) => {
+ let type = resolveType(obj, context, info);
+
+ // Only if a previous resolver was unable to resolve the field type do we
+ // progress to the hooks (in order!) to resolve the field name until we
+ // have resolved it.
+ if (typeof type !== 'undefined' && type != null) {
+ return type;
+ }
+
+ // We will walk through the post hooks until we find the right one. This
+ // follows what redux does to combine existing reducers.
+ for (let i = 0; i < post.length; i++) {
+ let resolveType = post[i];
+ type = resolveType(obj, context, info);
+ if (typeof type !== 'undefined' && type != null) {
+ return type;
+ }
+ }
+ };
+
+ return;
+ }
+
// Cache the original resolve function, this emulates the beheviour found in
// graphql-tools: https://github.com/apollographql/graphql-tools/blob/6e9cc124b10d673448386041e6c3d058bc205a02/src/schemaGenerator.ts#L423-L425
let resolve = field.resolve;
@@ -102,7 +174,7 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa
await Promise.all(pre.map((pre) => pre(obj, args, context, info)));
// Resolve the field.
- let result = resolve(obj, args, context, info);
+ let result = await resolve(obj, args, context, info);
// Insure all post hooks after we've resolved the field with the result
// passed in as the fifth argument.
diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js
index a474407d2..3d5ea9ad9 100644
--- a/graph/resolvers/root_mutation.js
+++ b/graph/resolvers/root_mutation.js
@@ -1,33 +1,6 @@
-const {Error: {ValidationError}} = require('mongoose');
-const errors = require('../../errors');
+const wrapResponse = require('../helpers/response');
const CommentsService = require('../../services/comments');
-/**
- * Wraps up a promise to return an object with the resolution of the promise
- * keyed at `key` or an error caught at `errors`.
- */
-const wrapResponse = (key) => (promise) => {
- return promise.then((value) => {
- let res = {};
- if (key) {
- res[key] = value;
- }
- return res;
- }).catch((err) => {
- if (err instanceof errors.APIError) {
- return {
- errors: [err]
- };
- } else if (err instanceof ValidationError) {
-
- // TODO: wrap this with one of our internal errors.
- throw err;
- }
-
- throw err;
- });
-};
-
const RootMutation = {
createComment(_, {asset_id, parent_id, body}, {mutators: {Comment}}) {
return wrapResponse('comment')(Comment.create({asset_id, parent_id, body}));
diff --git a/package.json b/package.json
index b13d1dce4..c305fc952 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"postinstall": "./bin/cli plugins reconcile --skip-remote",
"start": "./bin/cli serve -j -w",
"dev-start": "nodemon -w . -w bin/cli -w bin/cli-serve --config .nodemon.json --exec \"./bin/cli -c .env serve -j -w\"",
- "build": "NODE_ENV=production webpack --progress -p --config webpack.config.js --bail",
+ "build": "NODE_ENV=production webpack -p --config webpack.config.js --bail",
"build-watch": "NODE_ENV=development webpack --progress --config webpack.config.js --watch",
"lint": "eslint bin/* .",
"lint-fix": "eslint bin/* . --fix",
@@ -93,7 +93,7 @@
"parse-duration": "^0.1.1",
"passport": "^0.3.2",
"passport-local": "^1.0.0",
- "react-apollo": "^1.0.0-rc.3",
+ "react-apollo": "^1.0.0",
"react-recaptcha": "^2.2.6",
"redis": "^2.7.1",
"uuid": "^3.0.1",
@@ -102,8 +102,9 @@
"semver": "^5.3.0"
},
"devDependencies": {
- "apollo-client": "^0.8.3",
+ "apollo-client": "^1.0.0",
"autoprefixer": "^6.5.2",
+ "babel-cli": "^6.24.0",
"babel-core": "^6.24.0",
"babel-eslint": "^7.2.1",
"babel-jest": "^19.0.0",
@@ -117,10 +118,12 @@
"babel-plugin-transform-react-jsx": "^6.23.0",
"babel-polyfill": "^6.23.0",
"babel-preset-es2015": "^6.24.0",
+ "babel-preset-react": "^6.23.0",
"babel-preset-stage-0": "^6.16.0",
"chai": "^3.5.0",
"chai-as-promised": "^6.0.0",
"chai-http": "^3.0.0",
+ "common-tags": "^1.4.0",
"copy-webpack-plugin": "^4.0.0",
"css-loader": "^0.27.3",
"dialog-polyfill": "^0.4.4",
diff --git a/plugins.default.json b/plugins.default.json
index 40702cde9..b3443af48 100644
--- a/plugins.default.json
+++ b/plugins.default.json
@@ -1,5 +1,9 @@
{
"server": [
+ "coral-plugin-respect",
"coral-plugin-facebook-auth"
+ ],
+ "client": [
+ "coral-plugin-respect"
]
}
diff --git a/plugins.env.js b/plugins.env.js
new file mode 100644
index 000000000..7be520cc0
--- /dev/null
+++ b/plugins.env.js
@@ -0,0 +1 @@
+module.exports = JSON.parse(process.env.TALK_PLUGINS_JSON);
diff --git a/plugins.js b/plugins.js
index 949a5b9ab..02cdb58e1 100644
--- a/plugins.js
+++ b/plugins.js
@@ -3,9 +3,10 @@ const path = require('path');
const resolve = require('resolve');
const debug = require('debug')('talk:plugins');
const Joi = require('joi');
+const amp = require('app-module-path');
-// Add support for require rewriting.
-require('app-module-path').addPath(__dirname);
+// Add the current path to the module root.
+amp.addPath(__dirname);
let plugins = {};
@@ -13,13 +14,13 @@ let plugins = {};
// file isn't loaded, but continuing. Else, like a parsing error, throw it and
// crash the program.
try {
- let defaultPlugins = path.join(__dirname, 'plugins.default.json');
+ let envPlugins = path.join(__dirname, 'plugins.env.js');
let customPlugins = path.join(__dirname, 'plugins.json');
- let envPluginJSON = process.env.TALK_PLUGINS_JSON;
+ let defaultPlugins = path.join(__dirname, 'plugins.default.json');
- if (envPluginJSON && envPluginJSON.length > 0) {
+ if (process.env.TALK_PLUGINS_JSON && process.env.TALK_PLUGINS_JSON.length > 0) {
debug('Now using TALK_PLUGINS_JSON environment variable for plugins');
- plugins = JSON.parse(envPluginJSON);
+ plugins = require(envPlugins);
} else if (fs.existsSync(customPlugins)) {
debug(`Now using ${customPlugins} for plugins`);
plugins = JSON.parse(fs.readFileSync(customPlugins, 'utf8'));
@@ -35,17 +36,20 @@ try {
}
}
+/**
+ * All the hooks from plugins must match the schema defined here.
+ */
const hookSchemas = {
passport: Joi.func().arity(1),
router: Joi.func().arity(1),
context: Joi.object().pattern(/\w/, Joi.func().maxArity(1)),
- hooks: Joi.object({
+ hooks: Joi.object().pattern(/\w/, Joi.object().pattern(/(?:__resolveType|\w+)/, Joi.object({
pre: Joi.func(),
post: Joi.func()
- }),
+ }))),
loaders: Joi.object().pattern(/\w/, Joi.object().pattern(/\w/, Joi.func())),
mutators: Joi.object().pattern(/\w/, Joi.object().pattern(/\w/, Joi.func())),
- resolvers: Joi.object().pattern(/\w/, Joi.object().pattern(/\w/, Joi.func())),
+ resolvers: Joi.object().pattern(/\w/, Joi.object().pattern(/(?:__resolveType|\w+)/, Joi.func())),
typeDefs: Joi.string()
};
@@ -82,6 +86,12 @@ function pluginPath(name) {
}
}
+/**
+ * Itterates over the plugins and gets the plugin path's, version, and name.
+ *
+ * @param {Array} plugins
+ * @returns {Array}
+ */
function itteratePlugins(plugins) {
return plugins.map((p) => {
let plugin = {};
@@ -111,6 +121,12 @@ function itteratePlugins(plugins) {
});
}
+// Add each plugin folder to the allowed import path so that they can import our
+// internal dependancies.
+Object.keys(plugins).forEach((type) => itteratePlugins(plugins[type]).forEach((plugin) => {
+ amp.enableForDir(path.dirname(plugin.path));
+}));
+
/**
* Stores a reference to a section for a section of Plugins.
*/
diff --git a/plugins/coral-plugin-respect/client/.babelrc b/plugins/coral-plugin-respect/client/.babelrc
new file mode 100644
index 000000000..60be246eb
--- /dev/null
+++ b/plugins/coral-plugin-respect/client/.babelrc
@@ -0,0 +1,14 @@
+{
+ "presets": [
+ "es2015"
+ ],
+ "plugins": [
+ "add-module-exports",
+ "transform-class-properties",
+ "transform-decorators-legacy",
+ "transform-object-assign",
+ "transform-object-rest-spread",
+ "transform-async-to-generator",
+ "transform-react-jsx"
+ ]
+}
\ No newline at end of file
diff --git a/plugins/coral-plugin-respect/client/.eslintrc.json b/plugins/coral-plugin-respect/client/.eslintrc.json
new file mode 100644
index 000000000..9fe56bd14
--- /dev/null
+++ b/plugins/coral-plugin-respect/client/.eslintrc.json
@@ -0,0 +1,23 @@
+{
+ "env": {
+ "browser": true,
+ "es6": true,
+ "mocha": true
+ },
+ "parserOptions": {
+ "sourceType": "module",
+ "ecmaFeatures": {
+ "experimentalObjectRestSpread": true,
+ "jsx": true
+ }
+ },
+ "parser": "babel-eslint",
+ "plugins": [
+ "react"
+ ],
+ "rules": {
+ "react/jsx-uses-react": "error",
+ "react/jsx-uses-vars": "error",
+ "no-console": ["warn", { "allow": ["warn", "error"] }]
+ }
+}
diff --git a/plugins/coral-plugin-respect/client/components/Icon.js b/plugins/coral-plugin-respect/client/components/Icon.js
new file mode 100644
index 000000000..c24841e97
--- /dev/null
+++ b/plugins/coral-plugin-respect/client/components/Icon.js
@@ -0,0 +1,6 @@
+import React from 'react';
+import cn from 'classnames';
+
+export default ({className}) => (
+
+);
diff --git a/plugins/coral-plugin-respect/client/components/RespectButton.js b/plugins/coral-plugin-respect/client/components/RespectButton.js
new file mode 100644
index 000000000..9e851460e
--- /dev/null
+++ b/plugins/coral-plugin-respect/client/components/RespectButton.js
@@ -0,0 +1,67 @@
+import React, {Component} from 'react';
+import styles from './style.css';
+import Icon from './Icon';
+
+import {I18n} from 'coral-framework';
+import cn from 'classnames';
+import translations from '../translations.json';
+
+const lang = new I18n(translations);
+
+class RespectButton extends Component {
+
+ handleClick = () => {
+ const {postRespect, showSignInDialog, deleteAction, commentId} = this.props;
+ const {me, comment} = this.props.data;
+
+ const respect = comment.action_summaries[0];
+ const respected = (respect && respect.current_user);
+
+ // If the current user does not exist, trigger sign in dialog.
+ if (!me) {
+ const offset = document.getElementById(`c_${commentId}`).getBoundingClientRect().top - 75;
+ showSignInDialog(offset);
+ return;
+ }
+
+ // If the current user is banned, do nothing.
+ if (me.status === 'BANNED') {
+ return;
+ }
+
+ if (!respected) {
+ postRespect({
+ item_id: commentId,
+ item_type: 'COMMENTS'
+ });
+ } else {
+ deleteAction(respect.current_user.id);
+ }
+ }
+
+ render() {
+ const {comment} = this.props.data;
+ const respect = comment && comment.action_summaries && comment.action_summaries[0];
+ const respected = respect && respect.current_user;
+ let count = respect ? respect.count : 0;
+
+ return (
+
+
+ {lang.t(respected ? 'respected' : 'respect')}
+
+ {count > 0 && count}
+
+
+ );
+ }
+}
+
+RespectButton.propTypes = {
+ data: React.PropTypes.object.isRequired
+};
+
+export default RespectButton;
+
diff --git a/plugins/coral-plugin-respect/client/components/style.css b/plugins/coral-plugin-respect/client/components/style.css
new file mode 100644
index 000000000..32f9a8959
--- /dev/null
+++ b/plugins/coral-plugin-respect/client/components/style.css
@@ -0,0 +1,30 @@
+.respect {
+ display: inline-block;
+ }
+
+.button {
+ color: #2a2a2a;
+ margin: 5px 10px 5px 0px;
+ background: none;
+ padding: 0px;
+ border: none;
+ font-size: inherit;
+
+ &:hover {
+ color: #767676;
+ cursor: pointer;
+ }
+
+ &.respected {
+ color: #c98211;
+
+ &:hover {
+ color: #e59614;
+ cursor: pointer;
+ }
+ }
+}
+
+.icon {
+ padding: 0 5px;
+}
diff --git a/plugins/coral-plugin-respect/client/containers/RespectButton.js b/plugins/coral-plugin-respect/client/containers/RespectButton.js
new file mode 100644
index 000000000..f102a00b5
--- /dev/null
+++ b/plugins/coral-plugin-respect/client/containers/RespectButton.js
@@ -0,0 +1,140 @@
+import {compose, gql, graphql} from 'react-apollo';
+import {connect} from 'react-redux';
+import {bindActionCreators} from 'redux';
+import get from 'lodash/get';
+
+import {showSignInDialog} from 'coral-framework/actions/auth';
+import RespectButton from '../components/RespectButton';
+
+// TODO: use `update` instead of `updateQueries` for optimistic mutations.
+// See https://dev-blog.apollodata.com/apollo-clients-new-imperative-store-api-6cb69318a1e3
+// and https://github.com/apollographql/apollo-client/issues/1224
+
+export const RESPECT_QUERY = gql`
+ query RespectQuery($commentId: ID!) {
+ comment(id: $commentId) {
+ id
+ action_summaries {
+ ... on RespectActionSummary {
+ count
+ current_user {
+ id
+ }
+ }
+ }
+ }
+ me {
+ status
+ }
+ }
+`;
+
+const withQuery = graphql(RESPECT_QUERY);
+
+const withDeleteAction = graphql(gql`
+ mutation deleteAction($id: ID!) {
+ deleteAction(id:$id) {
+ errors {
+ translation_key
+ }
+ }
+ }
+`, {
+ props: ({mutate}) => ({
+ deleteAction: (id) => {
+ return mutate({
+ variables: {id},
+ optimisticResponse: {
+ deleteAction: {
+ __typename: 'DeleteActionResponse',
+ errors: null,
+ }
+ },
+ updateQueries: {
+ RespectQuery: (prev) => {
+ if (get(prev, 'comment.action_summaries.0.current_user.id') !== id) {
+ return prev;
+ }
+ const next = {
+ ...prev,
+ comment: {
+ ...prev.comment,
+ action_summaries: [{
+ __typename: 'RespectActionSummary',
+ count: prev.comment.action_summaries[0].count - 1,
+ current_user: null,
+ }],
+ }
+ };
+ return next;
+ },
+ },
+ });
+ },
+ }),
+});
+
+const withPostRespect = graphql(gql`
+ mutation createRespect($respect: CreateRespectInput!) {
+ createRespect(respect: $respect) {
+ respect {
+ id
+ }
+ errors {
+ translation_key
+ }
+ }
+ }
+`, {
+ props: ({mutate}) => ({
+ postRespect: (respect) => {
+ return mutate({
+ variables: {respect},
+ optimisticResponse: {
+ createRespect: {
+ __typename: 'CreateRespectResponse',
+ errors: null,
+ respect: {
+ __typename: 'RespectAction',
+ id: 'pending',
+ },
+ }
+ },
+ updateQueries: {
+ RespectQuery: (prev, {mutationResult, queryVariables}) => {
+ if (queryVariables.commentId !== respect.item_id) {
+ return prev;
+ }
+ const respectAction = mutationResult.data.createRespect.respect;
+ const count = prev.action_summaries ? prev.action_summaries.count : 0;
+ const next = {
+ ...prev,
+ comment: {
+ ...prev.comment,
+ action_summaries: [{
+ __typename: 'RespectActionSummary',
+ count: count + 1,
+ current_user: respectAction,
+ }],
+ }
+ };
+ return next;
+ },
+ },
+ });
+ },
+ }),
+});
+
+const mapDispatchToProps = dispatch =>
+ bindActionCreators({showSignInDialog}, dispatch);
+
+const enhance = compose(
+ connect(null, mapDispatchToProps),
+ withDeleteAction,
+ withPostRespect,
+ withQuery,
+);
+
+export default enhance(RespectButton);
+
diff --git a/plugins/coral-plugin-respect/client/index.js b/plugins/coral-plugin-respect/client/index.js
new file mode 100644
index 000000000..2caa6611b
--- /dev/null
+++ b/plugins/coral-plugin-respect/client/index.js
@@ -0,0 +1,6 @@
+import RespectButton from './containers/RespectButton';
+export default {
+ slots: {
+ commentDetail: [RespectButton],
+ }
+};
diff --git a/plugins/coral-plugin-respect/client/translations.json b/plugins/coral-plugin-respect/client/translations.json
new file mode 100644
index 000000000..643f32ccf
--- /dev/null
+++ b/plugins/coral-plugin-respect/client/translations.json
@@ -0,0 +1,10 @@
+{
+ "en": {
+ "respect": "Respect",
+ "respected": "Respected"
+ },
+ "es": {
+ "respect": "Respeto",
+ "respected": "Respetado"
+ }
+}
diff --git a/plugins/coral-plugin-respect/index.js b/plugins/coral-plugin-respect/index.js
new file mode 100644
index 000000000..5e960976d
--- /dev/null
+++ b/plugins/coral-plugin-respect/index.js
@@ -0,0 +1,36 @@
+const {readFileSync} = require('fs');
+const path = require('path');
+const wrapResponse = require('../../graph/helpers/response');
+
+module.exports = {
+ typeDefs: readFileSync(path.join(__dirname, 'server/typeDefs.graphql'), 'utf8'),
+ resolvers: {
+ RootMutation: {
+ createRespect(_, {respect: {item_id, item_type}}, {mutators: {Action}}) {
+ return wrapResponse('respect')(Action.create({item_id, item_type, action_type: 'RESPECT'}));
+ }
+ }
+ },
+ hooks: {
+ Action: {
+ __resolveType: {
+ post({action_type}) {
+ switch (action_type) {
+ case 'RESPECT':
+ return 'RespectAction';
+ }
+ }
+ }
+ },
+ ActionSummary: {
+ __resolveType: {
+ post({action_type}) {
+ switch (action_type) {
+ case 'RESPECT':
+ return 'RespectActionSummary';
+ }
+ }
+ }
+ }
+ }
+};
diff --git a/plugins/coral-plugin-respect/server/typeDefs.graphql b/plugins/coral-plugin-respect/server/typeDefs.graphql
new file mode 100644
index 000000000..6639aa454
--- /dev/null
+++ b/plugins/coral-plugin-respect/server/typeDefs.graphql
@@ -0,0 +1,54 @@
+enum ACTION_TYPE {
+
+ # Represents a Respect.
+ RESPECT
+}
+
+input CreateRespectInput {
+
+ # The item's id for which we are to create a respect.
+ item_id: ID!
+
+ # The type of the item for which we are to create the respect.
+ item_type: ACTION_ITEM_TYPE!
+}
+
+# RespectAction is used by users who "respect" a specific entity.
+type RespectAction implements Action {
+
+ # The ID of the action.
+ id: ID!
+
+ # The author of the action.
+ user: User
+
+ # The time when the Action was updated.
+ updated_at: Date
+
+ # The time when the Action was created.
+ created_at: Date
+}
+
+type RespectActionSummary implements ActionSummary {
+
+ # The count of actions with this group.
+ count: Int
+
+ # The current user's action.
+ current_user: RespectAction
+}
+
+type CreateRespectResponse implements Response {
+
+ # The respect that was created.
+ respect: RespectAction
+
+ # An array of errors relating to the mutation that occurred.
+ errors: [UserError]
+}
+
+type RootMutation {
+
+ # Creates a respect on an entity.
+ createRespect(respect: CreateRespectInput!): CreateRespectResponse
+}
diff --git a/test/client/.babelrc b/test/client/.babelrc
index 9bd819c32..633f93f42 100644
--- a/test/client/.babelrc
+++ b/test/client/.babelrc
@@ -1,3 +1,3 @@
{
- "extends": "../../client/.babelrc"
+ "extends": "../../.babelrc"
}
diff --git a/test/helpers/index.test.html b/test/helpers/index.test.html
index 4619df971..92ff1eb3a 100644
--- a/test/helpers/index.test.html
+++ b/test/helpers/index.test.html
@@ -6,7 +6,6 @@
Coral - (Beta)
-