Merge pull request #479 from coralproject/plugin_examples

Hooks and Context for Comment Plugins And Off-Topic Plugin
This commit is contained in:
Belén Curcio
2017-04-18 13:52:30 -03:00
committed by GitHub
30 changed files with 367 additions and 84 deletions
+2
View File
@@ -18,4 +18,6 @@ plugins.json
plugins/*
!plugins/coral-plugin-facebook-auth
!plugins/coral-plugin-respect
!plugins/coral-plugin-offtopic
**/node_modules/*
+2 -2
View File
@@ -168,7 +168,7 @@ class Comment extends React.Component {
? <TagLabel><BestIndicator /></TagLabel>
: null }
<PubDate created_at={comment.created_at} />
<Slot fill="commentInfoBar" commentId={comment.id} />
<Slot fill="commentInfoBar" comment={comment} commentId={comment.id} inline/>
{ (currentUser && (comment.user.id !== currentUser.id))
? <span className={styles.topRightMenu}>
@@ -209,7 +209,7 @@ class Comment extends React.Component {
removeBest={removeBestTag} />
</IfUserCanModifyBest>
</ActionButton>
<Slot fill="commentDetail" commentId={comment.id} />
<Slot fill="commentDetail" comment={comment} commentId={comment.id} inline/>
</div>
<div className="commentActionsRight comment__action-container">
<ActionButton>
@@ -0,0 +1,3 @@
.inline {
display: inline-block;
}
+4 -3
View File
@@ -1,13 +1,14 @@
import React, {Component} from 'react';
import {getSlotElements} from 'coral-framework/helpers/plugins';
import styles from './Slot.css';
class Slot extends Component {
render() {
const {fill, ...rest} = this.props;
const {fill, inline = false, ...rest} = this.props;
return (
<span>
<div className={inline ? styles.inline : ''}>
{getSlotElements(fill, rest)}
</span>
</div>
);
}
}
@@ -20,12 +20,11 @@ export const postComment = graphql(POST_COMMENT, {
fragments: commentView
}),
props: ({ownProps, mutate}) => ({
postItem: ({asset_id, body, parent_id}) =>
mutate({
postItem: comment => {
const {asset_id, body, parent_id, tags = []} = comment;
return mutate({
variables: {
asset_id,
body,
parent_id
comment
},
optimisticResponse: {
createComment: {
@@ -39,14 +38,14 @@ export const postComment = graphql(POST_COMMENT, {
parent_id,
asset_id,
action_summaries: [],
tags: [],
tags,
status: null,
id: 'pending'
}
}
},
updateQueries: {
AssetQuery: (oldData, {mutationResult:{data:{createComment:{comment}}}}) => {
AssetQuery: (oldData, {mutationResult: {data: {createComment: {comment}}}}) => {
if (oldData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
return oldData;
@@ -62,8 +61,8 @@ export const postComment = graphql(POST_COMMENT, {
...oldData.asset,
comments: oldData.asset.comments.map((oldComment) => {
return oldComment.id === parent_id
? {...oldComment, replies: [...oldComment.replies, comment]}
: oldComment;
? {...oldComment, replies: [...oldComment.replies, comment]}
: oldComment;
})
}
};
@@ -83,7 +82,8 @@ export const postComment = graphql(POST_COMMENT, {
return updatedAsset;
}
}
})
});
}
}),
});
@@ -1,7 +1,7 @@
#import "../fragments/commentView.graphql"
mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) {
createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) {
mutation CreateComment ($comment: CreateCommentInput!) {
createComment(comment: $comment) {
comment {
...commentView
replyCount
+2
View File
@@ -1,11 +1,13 @@
import auth from './auth';
import user from './user';
import asset from './asset';
import {reducer as commentBox} from '../../coral-plugin-commentbox';
import {pluginReducers} from '../helpers/plugins';
export default {
auth,
user,
asset,
commentBox,
...pluginReducers
};
+108 -38
View File
@@ -2,56 +2,57 @@ import React, {Component, PropTypes} from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
import {Button} from 'coral-ui';
import {Slot} from 'coral-framework';
import {connect} from 'react-redux';
const name = 'coral-plugin-commentbox';
class CommentBox extends Component {
static propTypes = {
commentPostedHandler: PropTypes.func,
postItem: PropTypes.func.isRequired,
cancelButtonClicked: PropTypes.func,
assetId: PropTypes.string.isRequired,
parentId: PropTypes.string,
authorId: PropTypes.string.isRequired,
isReply: PropTypes.bool.isRequired,
canPost: PropTypes.bool,
currentUser: PropTypes.object
}
constructor(props) {
super(props);
state = {
body: '',
username: ''
this.state = {
username: '',
body: '',
hooks: {
preSubmit: [],
postSubmit: []
}
};
}
postComment = () => {
const {
commentPostedHandler,
postItem,
assetId,
updateCountCache,
isReply,
countCache,
assetId,
parentId,
postItem,
countCache,
addNotification,
authorId
updateCountCache,
commentPostedHandler
} = this.props;
let comment = {
body: this.state.body,
asset_id: assetId,
author_id: authorId,
parent_id: parentId
parent_id: parentId,
body: this.state.body,
...this.props.commentBox
};
if (this.props.charCount && this.state.body.length > this.props.charCount) {
return;
}
!isReply && updateCountCache(assetId, countCache + 1);
// Execute preSubmit Hooks
this.state.hooks.preSubmit.forEach(hook => hook());
postItem(comment, 'comments')
.then(({data}) => {
const postedComment = data.createComment.comment;
// Execute postSubmit Hooks
this.state.hooks.postSubmit.forEach(hook => hook(data));
if (postedComment.status === 'REJECTED') {
addNotification('error', lang.t('comment-post-banned-word'));
!isReply && updateCountCache(assetId, countCache);
@@ -64,14 +65,67 @@ class CommentBox extends Component {
commentPostedHandler();
}
})
.catch((err) => console.error(err));
.catch((err) => console.error(err));
this.setState({body: ''});
}
registerHook = (hookType = '', hook = () => {}) => {
if (typeof hook !== 'function') {
return console.warn(`Hooks must be functions. Please check your ${hookType} hooks`);
} else if (typeof hookType === 'string') {
this.setState(state => ({
hooks: {
...state.hooks,
[hookType]: [
...state.hooks[hookType],
hook
]
}
}));
return {
hookType,
hook
};
} else {
return console.warn('hookTypes must be a string. Please check your hooks');
}
}
unregisterHook = hookData => {
const {hookType, hook} = hookData;
this.setState(state => {
let newHooks = state.hooks[newHooks];
const idx = state.hooks[hookType].indexOf(hook);
if (idx !== -1) {
newHooks = [
...state.hooks[hookType].slice(0, idx),
...state.hooks[hookType].slice(idx + 1)
];
}
return {
hooks: {
...state.hooks,
[hookType]: newHooks
}
};
});
}
handleChange = e => this.setState({body: e.target.value});
render () {
const {styles, isReply, authorId, charCount} = this.props;
let {cancelButtonClicked} = this.props;
const length = this.state.body.length;
const enablePostComment = !length || (charCount && length > charCount);
if (isReply && typeof cancelButtonClicked !== 'function') {
console.warn('the CommentBox component should have a cancelButtonClicked callback defined if it lives in a Reply');
@@ -93,33 +147,35 @@ class CommentBox extends Component {
value={this.state.body}
placeholder={lang.t('comment')}
id={isReply ? 'replyText' : 'commentText'}
onChange={(e) => this.setState({body: e.target.value})}
onChange={this.handleChange}
rows={3}/>
</div>
<div className={`${name}-char-count ${length > charCount ? `${name}-char-max` : ''}`}>
{
charCount &&
`${charCount - length} ${lang.t('characters-remaining')}`
}
{charCount && `${charCount - length} ${lang.t('characters-remaining')}`}
</div>
<div className={`${name}-button-container`}>
<Slot
fill="commentBoxDetail"
registerHook={this.registerHook}
unregisterHook={this.unregisterHook}
inline
/>
{
isReply && (
<Button
cStyle='darkGrey'
className={`${name}-cancel-button`}
onClick={() => {
cancelButtonClicked('');
}}>
onClick={() => cancelButtonClicked('')}>
{lang.t('cancel')}
</Button>
)
}
{ authorId && (
<Button
cStyle={!length || (charCount && length > charCount) ? 'lightGrey' : 'darkGrey'}
cStyle={enablePostComment ? 'lightGrey' : 'darkGrey'}
className={`${name}-button`}
onClick={this.postComment}>
onClick={this.postComment}
disabled={enablePostComment ? 'disabled' : ''}>
{lang.t('post')}
</Button>
)
@@ -129,6 +185,20 @@ class CommentBox extends Component {
}
}
export default CommentBox;
CommentBox.propTypes = {
commentPostedHandler: PropTypes.func,
postItem: PropTypes.func.isRequired,
cancelButtonClicked: PropTypes.func,
assetId: PropTypes.string.isRequired,
parentId: PropTypes.string,
authorId: PropTypes.string.isRequired,
isReply: PropTypes.bool.isRequired,
canPost: PropTypes.bool,
currentUser: PropTypes.object
};
const mapStateToProps = ({commentBox}) => ({commentBox});
export default connect(mapStateToProps, null)(CommentBox);
const lang = new I18n(translations);
@@ -0,0 +1,9 @@
export const addTag = tag => ({
type: 'ADD_TAG',
tag
});
export const removeTag = idx => ({
type: 'REMOVE_TAG',
idx
});
@@ -0,0 +1,2 @@
export const ADD_TAG = 'ADD_TAG';
export const REMOVE_TAG = 'REMOVE_TAG';
+5
View File
@@ -0,0 +1,5 @@
import reducer from './reducer';
export default {
reducer
};
+25
View File
@@ -0,0 +1,25 @@
import {ADD_TAG, REMOVE_TAG} from './constants';
const initialState = {
tags: []
};
export default function commentBox (state = initialState, action) {
switch (action.type) {
case ADD_TAG :
return {
...state,
tags: [...state.tags, action.tag]
};
case REMOVE_TAG :
return {
...state,
tags: [
...state.tags.slice(0, action.idx),
...state.tags.slice(action.idx + 1)
]
};
default :
return state;
}
}
@@ -0,0 +1,6 @@
.slot {
display: inline-block;
div {
display: inline-block;
}
}
+6 -3
View File
@@ -16,11 +16,14 @@ const Wordlist = require('../../services/wordlist');
* @param {String} [status='NONE'] the status of the new comment
* @return {Promise} resolves to the created comment
*/
const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null}, status = 'NONE') => {
const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null, tags = []}, status = 'NONE') => {
let tags = [];
// Building array of tags
tags = tags.map(tag => ({name: tag}));
// If admin or moderator, adding STAFF tag
if (user.hasRoles('ADMIN') || user.hasRoles('MODERATOR')) {
tags = [{name: 'STAFF'}];
tags.push({name: 'STAFF'});
}
return CommentsService.publicCreate({
+2 -2
View File
@@ -2,8 +2,8 @@ const wrapResponse = require('../helpers/response');
const CommentsService = require('../../services/comments');
const RootMutation = {
createComment(_, {asset_id, parent_id, body}, {mutators: {Comment}}) {
return wrapResponse('comment')(Comment.create({asset_id, parent_id, body}));
createComment(_, {comment}, {mutators: {Comment}}) {
return wrapResponse('comment')(Comment.create(comment));
},
createLike(_, {like: {item_id, item_type}}, {mutators: {Action}}) {
return wrapResponse('like')(Action.create({item_id, item_type, action_type: 'LIKE'}));
+21 -1
View File
@@ -602,6 +602,26 @@ input CreateLikeInput {
item_type: ACTION_ITEM_TYPE!
}
enum TAG_TYPE {
STAFF
}
input CreateCommentInput {
# The asset id
asset_id: ID!
# The id of the parent comment
parent_id: ID
# The body of the comment
body: String!
# Tags
tags: [TAG_TYPE]
}
type CreateLikeResponse implements Response {
# The like that was created.
@@ -728,7 +748,7 @@ type StopIgnoringUserResponse implements Response {
type RootMutation {
# Creates a comment on the asset.
createComment(asset_id: ID!, parent_id: ID, body: String!): CreateCommentResponse
createComment(comment: CreateCommentInput!): CreateCommentResponse
# Creates a like on an entity.
createLike(like: CreateLikeInput!): CreateLikeResponse
View File
@@ -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"
]
}
@@ -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"] }]
}
}
@@ -0,0 +1,38 @@
import React from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {addTag, removeTag} from 'coral-plugin-commentbox/actions';
import styles from './styles.css';
class OffTopicCheckbox extends React.Component {
label = 'OFF_TOPIC';
handleChange = (e) => {
if (e.target.checked) {
this.props.addTag(this.label)
} else {
const idx = this.props.commentBox.tags.indexOf(this.label);
this.props.removeTag(idx);
}
}
render() {
return (
<div className={styles.offTopic}>
<label className={styles.offTopicLabel}>
<input type="checkbox" onChange={this.handleChange}/>
Off-Topic
</label>
</div>
)
}
}
const mapStateToProps = ({commentBox}) => ({commentBox});
const mapDispatchToProps = dispatch =>
bindActionCreators({addTag, removeTag}, dispatch);
export default connect(mapStateToProps, mapDispatchToProps)(OffTopicCheckbox);
@@ -0,0 +1,18 @@
import React from 'react';
import styles from './styles.css';
const isOffTopic = (tags) => {
return !!tags.filter(tag => tag.name === 'OFF_TOPIC').length
}
export default (props) => (
<span>
{
isOffTopic(props.comment.tags) ? (
<span className={styles.tag}>
Off-topic
</span>
) : null
}
</span>
);
@@ -0,0 +1,18 @@
.offTopic {
height: 100%;
}
.offTopicLabel {
padding: 10px 20px;
display: block;
}
.tag {
background: rgba(245, 188, 168, 0.6);
display: inline-block;
margin: 0px 5px;
padding: 5px 5px;
border-radius: 2px;
}
@@ -0,0 +1,9 @@
import OffTopicCheckbox from './components/OffTopicCheckbox';
import OffTopicTag from './components/OffTopicTag';
export default {
slots: {
commentBoxDetail: [OffTopicCheckbox],
commentInfoBar: [OffTopicTag]
}
};
+6
View File
@@ -0,0 +1,6 @@
const {readFileSync} = require('fs');
const path = require('path');
module.exports = {
typeDefs: readFileSync(path.join(__dirname, 'server/typeDefs.graphql'), 'utf8')
};
@@ -0,0 +1,4 @@
## Extending TAG_TYPE by adding OFF_TOPIC Tag
enum TAG_TYPE {
OFF_TOPIC
}
@@ -1,4 +1,5 @@
import RespectButton from './containers/RespectButton';
export default {
slots: {
commentDetail: [RespectButton],
+8 -7
View File
@@ -3,10 +3,10 @@ const CommentModel = require('../models/comment');
const ActionModel = require('../models/action');
const ActionsService = require('./actions');
const ALLOWED_TAGS = [
{name: 'STAFF'},
{name: 'BEST'},
];
// const ALLOWED_TAGS = [
// {name: 'STAFF'},
// {name: 'BEST'},
// ];
const STATUSES = [
'ACCEPTED',
@@ -53,9 +53,10 @@ module.exports = class CommentsService {
*/
static addTag(id, name, assigned_by) {
if (ALLOWED_TAGS.find((t) => t.name === name) == null) {
return Promise.reject(new Error('tag not allowed'));
}
// Disabling allowed tags until we are able to extend them
// if (ALLOWED_TAGS.find((t) => t.name === name) == null) {
// return Promise.reject(new Error('tag not allowed'));
// }
const filter = {
id,
+6 -3
View File
@@ -12,8 +12,8 @@ describe('graph.mutations.createComment', () => {
beforeEach(() => SettingsService.init());
const query = `
mutation CreateComment($body: String = "Here's my comment!") {
createComment(asset_id: "123", body: $body) {
mutation CreateComment($comment: CreateCommentInput = {asset_id: 123, body: "Here's my comment!"}) {
createComment(comment: $comment) {
comment {
id
status
@@ -170,7 +170,10 @@ describe('graph.mutations.createComment', () => {
const context = new Context({user: new UserModel({status: 'ACTIVE'})});
return graphql(schema, query, {}, context, {
body
comment: {
asset_id: '123',
body
}
})
.then(({data, errors}) => {
expect(errors).to.be.undefined;
@@ -1,10 +1,10 @@
const expect = require('chai').expect;
const {graphql} = require('graphql');
const schema = require('../../../graph/schema');
const Context = require('../../../graph/context');
const UsersService = require('../../../services/users');
const SettingsService = require('../../../services/settings');
const schema = require('../../../../graph/schema');
const Context = require('../../../../graph/context');
const UsersService = require('../../../../services/users');
const SettingsService = require('../../../../services/settings');
const ignoreUserMutation = `
mutation ignoreUser ($id: ID!) {
@@ -94,7 +94,7 @@ describe('graph.mutations.stopIgnoringUser', () => {
if (response.errors && response.errors.length) {
console.error(response.errors);
}
expect(response.errors).to.be.empty;
expect(response.errors).to.be.empty;
});
const stopIgnoringUserMutation = `
@@ -112,7 +112,7 @@ describe('graph.mutations.stopIgnoringUser', () => {
if (stopIgnoringUserResponse.errors && stopIgnoringUserResponse.errors.length) {
console.error(stopIgnoringUserResponse.errors);
}
expect(stopIgnoringUserResponse.errors).to.be.empty;
expect(stopIgnoringUserResponse.errors).to.be.empty;
// now check my ignored users
const myIgnoredUsersResponse = await graphql(schema, getMyIgnoredUsersQuery, {}, context, {});
@@ -1,12 +1,12 @@
const expect = require('chai').expect;
const {graphql} = require('graphql');
const schema = require('../../../graph/schema');
const Context = require('../../../graph/context');
const UsersService = require('../../../services/users');
const SettingsService = require('../../../services/settings');
const Asset = require('../../../models/asset');
const CommentsService = require('../../../services/comments');
const schema = require('../../../../graph/schema');
const Context = require('../../../../graph/context');
const UsersService = require('../../../../services/users');
const SettingsService = require('../../../../services/settings');
const Asset = require('../../../../models/asset');
const CommentsService = require('../../../../services/comments');
describe('graph.queries.asset', () => {
beforeEach(async () => {
@@ -87,7 +87,7 @@ describe('graph.queries.asset', () => {
`;
const assetCommentsResponse = await graphql(schema, assetCommentsWithoutIgnoredQuery, {}, context, {assetId, assetUrl, excludeIgnored: true});
const comments = assetCommentsResponse.data.asset.comments;
expect(comments.length).to.equal(2);
expect(comments.length).to.equal(2);
});
});