Merge branch 'master' into keyboard-shortcuts

This commit is contained in:
Kim Gardner
2017-03-03 09:59:45 -05:00
committed by GitHub
36 changed files with 1138 additions and 457 deletions
-1
View File
@@ -5,7 +5,6 @@
],
"plugins": [
"add-module-exports",
"transform-async-to-generator",
"transform-class-properties",
"transform-decorators-legacy",
"transform-object-assign",
+3
View File
@@ -4,6 +4,9 @@
"node": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2017
},
"rules": {
"indent": ["error",
2
-3
View File
@@ -1,8 +1,5 @@
FROM node:7.6
# Add node-gyp for bcrypt build support
RUN yarn global add node-gyp
# Create app directory
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "../.babelrc",
"plugins": [
"transform-async-to-generator",
]
}
@@ -14,28 +14,35 @@ const CoralHeader = ({handleLogout, restricted = false}) => (
<div>
<Navigation className={styles.nav}>
<IndexLink
id='dashboardNav'
className={styles.navLink}
to="/admin/dashboard"
activeClassName={styles.active}>
{lang.t('configure.dashboard')}
</IndexLink>
<Link
id='moderateNav'
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{lang.t('configure.moderate')}
</Link>
<Link className={styles.navLink}
<Link
id='streamsNav'
className={styles.navLink}
to="/admin/streams"
activeClassName={styles.active}>
{lang.t('configure.streams')}
</Link>
<Link className={styles.navLink}
<Link
id='communityNav'
className={styles.navLink}
to="/admin/community"
activeClassName={styles.active}>
{lang.t('configure.community')}
</Link>
<Link
id='configureNav'
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
@@ -1,3 +1,7 @@
.Reply {
position: relative;
}
.Comment {
}
+84 -27
View File
@@ -17,6 +17,7 @@ import PubDate from 'coral-plugin-pubdate/PubDate';
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
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 styles from './Comment.css';
@@ -25,6 +26,11 @@ const getActionSummary = (type, comment) => comment.action_summaries
.filter((a) => a.__typename === type)[0];
const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF') ;
// hold actions links (e.g. Like, Reply) along the comment footer
const ActionButton = ({children}) => {
return <span className="comment__action-button comment__action-button--nowrap">{ children }</span>;
};
class Comment extends React.Component {
constructor(props) {
@@ -74,7 +80,13 @@ class Comment extends React.Component {
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired
}).isRequired
}).isRequired,
// dispatch action to add a tag to a comment
addCommentTag: React.PropTypes.func,
// dispatch action to remove a tag from a comment
removeCommentTag: React.PropTypes.func,
}
render () {
@@ -94,7 +106,9 @@ class Comment extends React.Component {
loadMore,
setActiveReplyBox,
activeReplyBox,
deleteAction
deleteAction,
addCommentTag,
removeCommentTag,
} = this.props;
const like = getActionSummary('LikeActionSummary', comment);
@@ -103,6 +117,27 @@ class Comment extends React.Component {
let commentClass = parentId ? `reply ${styles.Reply}` : `comment ${styles.Comment}`;
commentClass += highlighted === comment.id ? ' highlighted-comment' : '';
// call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar)
const notifyOnError = (fn, errorToMessage) => async () => {
if (typeof errorToMessage !== 'function') {errorToMessage = (error) => error.message;}
try {
return await fn();
} catch (error) {
addNotification('error', errorToMessage(error));
throw error;
}
};
const addBestTag = notifyOnError(() => addCommentTag({
id: comment.id,
tag: BEST_TAG,
}), () => 'Failed to tag comment as best');
const removeBestTag = notifyOnError(() => removeCommentTag({
id: comment.id,
tag: BEST_TAG,
}), () => 'Failed to remove best comment tag');
return (
<div
className={commentClass}
@@ -112,35 +147,55 @@ class Comment extends React.Component {
<AuthorName
author={comment.user}/>
{ isStaff(comment.tags)
? <TagLabel isStaff={true}/>
? <TagLabel>Staff</TagLabel>
: null }
{ commentIsBest(comment)
? <TagLabel><BestIndicator /></TagLabel>
: null }
<PubDate created_at={comment.created_at} />
<Content body={comment.body} />
<div className="commentActionsLeft">
<LikeButton
like={like}
id={comment.id}
postLike={postLike}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser} />
<ReplyButton
onClick={() => setActiveReplyBox(comment.id)}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
banned={false} />
<div className="commentActionsLeft comment__action-container">
<ActionButton>
<LikeButton
like={like}
id={comment.id}
postLike={postLike}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser} />
</ActionButton>
<ActionButton>
<ReplyButton
onClick={() => setActiveReplyBox(comment.id)}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
banned={false} />
</ActionButton>
<ActionButton>
<IfUserCanModifyBest user={currentUser}>
<BestButton
isBest={commentIsBest(comment)}
addBest={addBestTag}
removeBest={removeBestTag} />
</IfUserCanModifyBest>
</ActionButton>
</div>
<div className="commentActionsRight">
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
<FlagComment
flag={flag && flag.current_user ? flag : dontagree}
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
postDontAgree={postDontAgree}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser} />
<div className="commentActionsRight comment__action-container">
<ActionButton>
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
</ActionButton>
<ActionButton>
<FlagComment
flag={flag && flag.current_user ? flag : dontagree}
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
postDontAgree={postDontAgree}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser} />
</ActionButton>
</div>
{
activeReplyBox === comment.id
@@ -172,6 +227,8 @@ class Comment extends React.Component {
postLike={postLike}
postFlag={postFlag}
deleteAction={deleteAction}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
showSignInDialog={showSignInDialog}
reactKey={reply.id}
key={reply.id}
+30 -18
View File
@@ -13,7 +13,7 @@ const {addNotification, clearNotification} = notificationActions;
const {fetchAssetSuccess} = assetActions;
import {queryStream} from 'coral-framework/graphql/queries';
import {postComment, postFlag, postLike, postDontAgree, deleteAction} from 'coral-framework/graphql/mutations';
import {postComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag} from 'coral-framework/graphql/mutations';
import {editName} from 'coral-framework/actions/user';
import {updateCountCache} from 'coral-framework/actions/asset';
import {Notification, notificationActions, authActions, assetActions, pym} from 'coral-framework';
@@ -55,7 +55,13 @@ class Embed extends Component {
data: React.PropTypes.shape({
loading: React.PropTypes.bool,
error: React.PropTypes.object
}).isRequired
}).isRequired,
// dispatch action to add a tag to a comment
addCommentTag: React.PropTypes.func,
// dispatch action to remove a tag from a comment
removeCommentTag: React.PropTypes.func,
}
componentDidMount () {
@@ -194,22 +200,26 @@ class Embed extends Component {
assetId={asset.id}
updateCountCache={this.props.updateCountCache}
/>
<Stream
addNotification={this.props.addNotification}
postItem={this.props.postItem}
setActiveReplyBox={this.setActiveReplyBox}
activeReplyBox={this.state.activeReplyBox}
asset={asset}
currentUser={user}
postLike={this.props.postLike}
postFlag={this.props.postFlag}
postDontAgree={this.props.postDontAgree}
getCounts={this.props.getCounts}
updateCountCache={this.props.updateCountCache}
loadMore={this.props.loadMore}
deleteAction={this.props.deleteAction}
showSignInDialog={this.props.showSignInDialog}
comments={asset.comments} />
<div className="embed__stream">
<Stream
addNotification={this.props.addNotification}
postItem={this.props.postItem}
setActiveReplyBox={this.setActiveReplyBox}
activeReplyBox={this.state.activeReplyBox}
asset={asset}
currentUser={user}
postLike={this.props.postLike}
postFlag={this.props.postFlag}
postDontAgree={this.props.postDontAgree}
getCounts={this.props.getCounts}
addCommentTag={this.props.addCommentTag}
removeCommentTag={this.props.removeCommentTag}
updateCountCache={this.props.updateCountCache}
loadMore={this.props.loadMore}
deleteAction={this.props.deleteAction}
showSignInDialog={this.props.showSignInDialog}
comments={asset.comments} />
</div>
<Notification
notifLength={4500}
clearNotification={this.props.clearNotification}
@@ -278,6 +288,8 @@ export default compose(
postFlag,
postLike,
postDontAgree,
addCommentTag,
removeCommentTag,
deleteAction,
queryStream
)(Embed);
+13 -3
View File
@@ -12,7 +12,13 @@ class Stream extends React.Component {
currentUser: PropTypes.shape({
username: PropTypes.string,
id: PropTypes.string
})
}),
// dispatch action to add a tag to a comment
addCommentTag: React.PropTypes.func,
// dispatch action to remove a tag from a comment
removeCommentTag: React.PropTypes.func,
}
constructor(props) {
@@ -52,11 +58,13 @@ class Stream extends React.Component {
postDontAgree,
loadMore,
deleteAction,
showSignInDialog
showSignInDialog,
addCommentTag,
removeCommentTag
} = this.props;
return (
<div>
<div id='stream'>
{
comments.map(comment =>
<Comment
@@ -70,6 +78,8 @@ class Stream extends React.Component {
postLike={postLike}
postFlag={postFlag}
postDontAgree={postDontAgree}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
loadMore={loadMore}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
+26 -5
View File
@@ -172,7 +172,7 @@ hr {
.coral-plugin-author-name-text {
display: inline-block;
margin: 10px 8px 10px 0;
margin: 10px 5px 10px 0;
font-weight: bold;
}
@@ -191,7 +191,7 @@ hr {
background-color: #4C1066;
color: white;
display: inline-block;
margin: 10px 10px;
margin: 0px 5px;
padding: 5px 5px;
border-radius: 2px;
}
@@ -218,10 +218,30 @@ hr {
width: 50%;
}
.material-icons {
font-size: 12px !important;
margin-left: 3px;
.comment__action-container .material-icons {
font-size: 12px;
margin-left: 3px;
}
button.comment__action-button,
.comment__action-button button {
cursor: pointer;
}
button.comment__action-button[disabled],
.comment__action-button[disabled] button {
cursor: inherit;
}
.comment__action-button--nowrap {
white-space: nowrap;
}
.commentStream .material-icons {
vertical-align: middle;
width: 1em;
font-size: 1em;
overflow: hidden;
}
.likedButton {
@@ -236,6 +256,7 @@ hr {
color: #696969;
display: inline-block;
font-size: .75rem;
margin-left: 5px;
}
.coral-plugin-permalinks-container {
+5 -4
View File
@@ -31,13 +31,14 @@ function buildStreamIframeUrl(talkBaseUrl, asset_url, comment, asset_id) {
function configurePymParent(pymParent, asset_url) {
let notificationOffset = 200;
let ready = false;
let cachedHeight;
// Resize parent iframe height when child height changes
pymParent.onMessage('height', function(height) {
// TODO: In local testing, this is firing nonstop. Maybe there's a bug on the inside?
// Or it's by design of pym... but that's very wasteful of CPU and DOM reflows (jank)
pymParent.el.querySelector('iframe').height = `${height }px`;
if (height !== cachedHeight) {
pymParent.el.firstChild.style.height = `${height}px`;
cachedHeight = height;
}
});
// Helps child show notifications at the right scrollTop
@@ -0,0 +1,13 @@
mutation AddCommentTag ($id: ID!, $tag: String!) {
addCommentTag(id:$id, tag:$tag) {
comment {
id
tags {
name
}
}
errors {
translation_key
}
}
}
@@ -4,6 +4,8 @@ import POST_FLAG from './postFlag.graphql';
import POST_LIKE from './postLike.graphql';
import POST_DONT_AGREE from './postDontAgree.graphql';
import DELETE_ACTION from './deleteAction.graphql';
import ADD_COMMENT_TAG from './addCommentTag.graphql';
import REMOVE_COMMENT_TAG from './removeCommentTag.graphql';
import commentView from '../fragments/commentView.graphql';
@@ -122,3 +124,27 @@ export const deleteAction = graphql(DELETE_ACTION, {
});
}}),
});
export const addCommentTag = graphql(ADD_COMMENT_TAG, {
props: ({mutate}) => ({
addCommentTag: ({id, tag}) => {
return mutate({
variables: {
id,
tag
}
});
}}),
});
export const removeCommentTag = graphql(REMOVE_COMMENT_TAG, {
props: ({mutate}) => ({
removeCommentTag: ({id, tag}) => {
return mutate({
variables: {
id,
tag
}
});
}}),
});
@@ -0,0 +1,13 @@
mutation RemoveCommentTag ($id: ID!, $tag: String!) {
removeCommentTag(id:$id, tag:$tag) {
comment {
id
tags {
name
}
}
errors {
translation_key
}
}
}
+106
View File
@@ -0,0 +1,106 @@
import React, {Component, PropTypes} from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
import classnames from 'classnames';
// tag string for best comments
export const BEST_TAG = 'BEST';
export const commentIsBest = ({tags} = {}) => {
const isBest = Array.isArray(tags) && tags.some(t => t.name === BEST_TAG);
return isBest;
};
const name = 'coral-plugin-best';
const lang = new I18n(translations);
// It would be best if the backend/api held this business logic
const canModifyBestTag = ({roles = []} = {}) => roles && ['ADMIN', 'MODERATOR'].some(role => roles.includes(role));
// Put this on a comment to show that it is best
export const BestIndicator = ({children = <i className={'material-icons'} aria-hidden={true}>favorite</i>}) => (
<span aria-label={lang.t('commentIsBest')}>
{ children }
</span>
);
/**
* Component that only renders children if the provided user prop can modify best tags
*/
export const IfUserCanModifyBest = ({user, children}) => {
if ( ! ( user && canModifyBestTag(user))) {return null;}
return children;
};
/**
* Button that lets a moderator tag a comment as "Best".
* Used to recognize really good comments.
*/
export class BestButton extends Component {
static propTypes = {
// whether the comment is already tagged as best
isBest: PropTypes.bool.isRequired,
// set that this comment is best
addBest: PropTypes.func.isRequired,
// remove the best status
removeBest: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this.onClickAddBest = this.onClickAddBest.bind(this);
this.onClickRemoveBest = this.onClickRemoveBest.bind(this);
}
state = {
isSaving: false
}
async onClickAddBest(e) {
e.preventDefault();
const {addBest} = this.props;
if ( ! addBest) {
console.warn('BestButton#onClickAddBest called even though there is no addBest prop. doing nothing');
return;
}
this.setState({isSaving: true});
try {
await addBest();
} finally {
this.setState({isSaving: false});
}
}
async onClickRemoveBest(e) {
e.preventDefault();
const {removeBest} = this.props;
if ( ! removeBest) {
console.warn('BestButton#onClickAddBest called even though there is no removeBest prop. doing nothing');
return;
}
this.setState({isSaving: true});
try {
await removeBest();
} finally {
this.setState({isSaving: false});
}
}
render() {
const {isBest, addBest, removeBest} = this.props;
const {isSaving} = this.state;
const disabled = isSaving || ! (isBest ? removeBest : addBest);
return (
<button onClick={isBest ? this.onClickRemoveBest : this.onClickAddBest}
disabled={disabled}
className={classnames(`${name}-button`, `e2e__${isBest ? 'unset' : 'set'}-best-comment`)}
aria-label={lang.t(isBest ? 'unsetBest' : 'setBest')}>
<i className={`${name}-icon material-icons`} aria-hidden={true}>
{ isBest ? 'favorite' : 'favorite_border' }
</i>
</button>
);
}
}
@@ -0,0 +1,12 @@
{
"en": {
"setBest": "Tag as Best",
"unsetBest": "Untag as Best",
"commentIsBest": "This comment is one of the best"
},
"es": {
"like": "Establecer como mejor",
"liked": "Desarmado como mejor",
"commentIsBest": "Este comentario es uno de los mejores"
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ class LikeButton extends Component {
};
return <div className={`${name}-container`}>
<button onClick={onLikeClick} className={`${name}-button ${liked && 'likedButton'}`}>
<button onClick={onLikeClick} className={`${name}-button ${liked ? 'likedButton' : ''}`}>
<span className={`${name}-button-text`}>{lang.t(liked ? 'liked' : 'like')}</span>
<i className={`${name}-icon material-icons`}
aria-hidden={true}>thumb_up</i>
+2 -1
View File
@@ -1,13 +1,14 @@
import React, {PropTypes} from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
import classnames from 'classnames';
const name = 'coral-plugin-replies';
const ReplyButton = ({banned, onClick}) => {
return (
<button
className={`${name}-reply-button`}
className={classnames(`${name}-reply-button`)}
onClick={onClick}>
{lang.t('reply')}
<i className={`${name}-icon material-icons`}
+2 -2
View File
@@ -1,7 +1,7 @@
import React from 'react';
const TagLabel = ({isStaff}) => <div className='coral-plugin-tag-label'>
{isStaff ? 'Staff' : ''}
const TagLabel = ({children}) => <div className='coral-plugin-tag-label'>
{children}
</div>;
export default TagLabel;
+29 -1
View File
@@ -189,11 +189,31 @@ const setCommentStatus = ({loaders: {Comments}}, {id, status}) => {
});
};
/**
* Adds a tag to a Comment
* @param {String} id identifier of the comment (uuid)
* @param {String} tag name of the tag
*/
const addCommentTag = ({user, loaders: {Comments}}, {id, tag}) => {
return CommentsService.addTag(id, tag, user.id);
};
/**
* Removes a tag from a Comment
* @param {String} id identifier of the comment (uuid)
* @param {String} tag name of the tag
*/
const removeCommentTag = ({user, loaders: {Comments}}, {id, tag}) => {
return CommentsService.removeTag(id, tag);
};
module.exports = (context) => {
let mutators = {
Comment: {
create: () => Promise.reject(errors.ErrNotAuthorized),
setCommentStatus: () => Promise.reject(errors.ErrNotAuthorized)
setCommentStatus: () => Promise.reject(errors.ErrNotAuthorized),
addCommentTag: () => Promise.reject(errors.ErrNotAuthorized),
removeCommentTag: () => Promise.reject(errors.ErrNotAuthorized),
}
};
@@ -205,5 +225,13 @@ module.exports = (context) => {
mutators.Comment.setCommentStatus = (action) => setCommentStatus(context, action);
}
if (context.user && context.user.can('mutation:addCommentTag')) {
mutators.Comment.addCommentTag = (action) => addCommentTag(context, action);
}
if (context.user && context.user.can('mutation:removeCommentTag')) {
mutators.Comment.removeCommentTag = (action) => removeCommentTag(context, action);
}
return mutators;
};
+8 -1
View File
@@ -1,5 +1,6 @@
const {Error: {ValidationError}} = require('mongoose');
const errors = require('../../errors');
const CommentsService = require('../../services/comments');
/**
* Wraps up a promise to return an object with the resolution of the promise
@@ -48,7 +49,13 @@ const RootMutation = {
},
setCommentStatus(_, {id, status}, {mutators: {Comment}}) {
return wrapResponse(null)(Comment.setCommentStatus({id, status}));
}
},
addCommentTag(_, {id, tag}, {mutators: {Comment}}) {
return wrapResponse('comment')(Comment.addCommentTag({id, tag}).then(() => CommentsService.findById(id)));
},
removeCommentTag(_, {id, tag}, {mutators: {Comment}}) {
return wrapResponse('comment')(Comment.removeCommentTag({id, tag}).then(() => CommentsService.findById(id)));
},
};
module.exports = RootMutation;
+20
View File
@@ -641,6 +641,20 @@ type SetCommentStatusResponse implements Response {
errors: [UserError]
}
# Response to addCommentTag mutation
type AddCommentTagResponse implements Response {
# An array of errors relating to the mutation that occured.
comment: Comment
errors: [UserError]
}
# Response to removeCommentTag mutation
type RemoveCommentTagResponse implements Response {
# An array of errors relating to the mutation that occured.
comment: Comment
errors: [UserError]
}
# All mutations for the application are defined on this object.
type RootMutation {
@@ -664,6 +678,12 @@ type RootMutation {
# Sets Comment status. Requires the `ADMIN` role.
setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse
# Add tag to comment.
addCommentTag(id: ID!, tag: String!): AddCommentTagResponse
# Remove tag from comment.
removeCommentTag(id: ID!, tag: String!): RemoveCommentTagResponse
}
################################################################################
+9 -1
View File
@@ -156,7 +156,9 @@ const USER_GRAPH_OPERATIONS = [
'mutation:deleteAction',
'mutation:editName',
'mutation:setUserStatus',
'mutation:setCommentStatus'
'mutation:setCommentStatus',
'mutation:addCommentTag',
'mutation:removeCommentTag',
];
/**
@@ -176,6 +178,12 @@ UserSchema.method('can', function(...actions) {
return false;
}
// {add,remove}CommentTag - requires admin and/or moderator role
const userCanModifyTags = user => ['ADMIN', 'MODERATOR'].some(r => user.hasRoles(r));
if (actions.some(a => ['mutation:removeCommentTag', 'mutation:addCommentTag'].includes(a)) && ! userCanModifyTags(this)) {
return false;
}
return true;
});
+2 -1
View File
@@ -49,7 +49,7 @@
},
"homepage": "https://github.com/coralproject/talk#readme",
"dependencies": {
"bcrypt": "^0.8.7",
"bcrypt": "^1.0.2",
"body-parser": "^1.15.2",
"cli-table": "^0.3.1",
"commander": "^2.9.0",
@@ -103,6 +103,7 @@
"babel-preset-es2015": "^6.18.0",
"babel-preset-stage-0": "^6.16.0",
"chai": "^3.5.0",
"chai-as-promised": "^6.0.0",
"chai-http": "^3.0.0",
"copy-webpack-plugin": "^4.0.0",
"css-loader": "^0.25.0",
+59 -14
View File
@@ -4,7 +4,8 @@ const ActionModel = require('../models/action');
const ActionsService = require('./actions');
const ALLOWED_TAGS = [
{name: 'STAFF'}
{name: 'STAFF'},
{name: 'BEST'},
];
const STATUSES = [
@@ -44,6 +45,11 @@ module.exports = class CommentsService {
/**
* Adds a tag if it doesn't already exist on the comment.
* @throws if tag is already added to the comment
* @throws if tag name is not in ALLOWED_TAGS
* @param {String} id the id of the comment to tag
* @param {String} name the name of the tag to add
* @param {String} assigned_by the user id for the user who added the tag
*/
static addTag(id, name, assigned_by) {
@@ -51,22 +57,61 @@ module.exports = class CommentsService {
return Promise.reject(new Error('tag not allowed'));
}
return CommentModel.update({
const filter = {
id,
tags: {
$ne: {
name
'tags.name': {$ne: name},
};
const update = {
$push: {tags: {
name,
assigned_by,
created_at: new Date()
}}
};
return CommentModel.update(filter, update)
.then(({nModified}) => {
switch (nModified) {
case 0:
// either the tag was already there, or the comment doesn't exist with that id...
throw new Error('Could not add tag to comment. Either the comment doesn\'t exist or the tag is already present.');
case 1:
// tag added
return;
default:
// this should never happen because no multi parameter and unique index on id
}
}
}, {
$push: {
tags: {
name,
assigned_by,
created_at: new Date()
});
}
/**
* Removes a tag from a comment
* @throws if the tag is not on the comment
* @param {String} id the id of the comment to tag
* @param {String} name the name of the tag to add
*/
static removeTag(id, name) {
const filter = {
id,
'tags.name': name,
};
const update = {$pull: {tags: {name}}};
return CommentModel.update(filter, update)
.then(({nModified}) => {
switch (nModified) {
case 0:
throw new Error('Could not remove tag from comment. Either the comment doesn\'t exist or the tag is not present');
case 1:
// tag removed
return;
default:
// this should never happen because no multi parameter and unique index on id
}
}
});
});
}
/**
+5
View File
@@ -8,6 +8,8 @@ const embedStreamCommands = {
},
approveComment() {
return this
.waitForElementVisible('@moderateNav')
.click('@moderateNav')
.waitForElementVisible('@moderationList')
.waitForElementVisible('@approveButton')
.click('@approveButton');
@@ -17,6 +19,9 @@ const embedStreamCommands = {
module.exports = {
commands: [embedStreamCommands],
elements: {
moderateNav: {
selector: '#moderateNav'
},
moderationList: {
selector: '#moderationList'
},
+20 -14
View File
@@ -117,49 +117,55 @@ module.exports = {
selector: '#commentBox .coral-plugin-commentbox-button'
},
likeButton: {
selector: '.comment .coral-plugin-likes-container .coral-plugin-likes-button'
selector: '.embed__stream .comment .coral-plugin-likes-container .coral-plugin-likes-button'
},
likeText: {
selector: '.comment .coral-plugin-likes-container .coral-plugin-likes-button .coral-plugin-likes-button-text'
selector: '.embed__stream .comment .coral-plugin-likes-container .coral-plugin-likes-button .coral-plugin-likes-button-text'
},
likesCount: {
selector: '.comment .coral-plugin-likes-container .coral-plugin-likes-button .coral-plugin-likes-like-count'
selector: '.embed__stream .comment .coral-plugin-likes-container .coral-plugin-likes-button .coral-plugin-likes-like-count'
},
flagButton: {
selector: '.comment .coral-plugin-flags-container .coral-plugin-flags-button'
selector: '.embed__stream .comment .coral-plugin-flags-container .coral-plugin-flags-button'
},
flagPopUp: {
selector: '.comment .coral-plugin-flags-popup'
selector: '.embed__stream .comment .coral-plugin-flags-popup'
},
flagCommentOption: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-radio-label[for="COMMENTS"]'
selector: '.embed__stream .comment .coral-plugin-flags-popup .coral-plugin-flags-popup-radio-label[for="COMMENTS"]'
},
flagUsernameOption: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-radio-label[for="USERS"]'
selector: '.embed__stream .comment .coral-plugin-flags-popup .coral-plugin-flags-popup-radio-label[for="USERS"]'
},
flagOtherOption: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-radio-label[for="other"]'
selector: '.embed__stream .comment .coral-plugin-flags-popup .coral-plugin-flags-popup-radio-label[for="other"]'
},
flagHeaderMessage: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-header'
selector: '.embed__stream .comment .coral-plugin-flags-popup .coral-plugin-flags-popup-header'
},
flagButtonText: {
selector: '.comment .coral-plugin-flags-button-text'
selector: '.embed__stream .comment .coral-plugin-flags-button-text'
},
flagDoneButton: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-button'
selector: '.embed__stream .comment .coral-plugin-flags-popup .coral-plugin-flags-popup-button'
},
permalinkButton: {
selector: '.comment .coral-plugin-permalinks-button'
selector: '.embed__stream .comment .coral-plugin-permalinks-button'
},
permalinkPopUp: {
selector: '.comment .coral-plugin-permalinks-popover.active'
selector: '.embed__stream .comment .coral-plugin-permalinks-popover.active'
},
permalinkInput: {
selector: '.comment .coral-plugin-permalinks-popover.active input'
selector: '.embed__stream .comment .coral-plugin-permalinks-popover.active input'
},
registerButton: {
selector: '#signInDialog #coralRegister'
},
setBestButton: {
selector: '.e2e__set-best-comment'
},
unsetBestButton: {
selector: '.e2e__unset-best-comment'
}
}
};
@@ -0,0 +1,25 @@
module.exports = {
'@tags': ['like', 'comments', 'commenter'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.navigate()
.ready();
embedStreamPage
.login(users.commenter);
},
'Commenters should not see the set-best-comment button': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.postComment('Hi everyone. Isn\'t this the BEST comment!?')
.waitForElementVisible('@likeButton')
.expect.element('@setBestButton').to.not.be.present;
},
after: client => {
client.end();
}
};
@@ -16,6 +16,7 @@ module.exports = {
embedStreamPage
.flagUsername()
.click('@flagButton')
.waitForElementVisible('@flagPopUp')
.waitForElementVisible('@flagUsernameOption')
.click('@flagUsernameOption')
@@ -15,6 +15,7 @@ module.exports = {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.postComment(`hi ${Math.random()}`)
.likeComment()
.waitForElementVisible('@likesCount', 2000)
.expect.element('@likeText').text.to.equal('Liked');
+10 -14
View File
@@ -41,7 +41,6 @@ module.exports = {
.setValue('#password', mockUser.pw)
.setValue('#confirmPassword', mockUser.pw)
.click('#coralSignUpButton')
.pause(5000)
.waitForElementVisible('#coralLogInButton', 10000)
.click('#coralLogInButton')
.waitForElementVisible('.coral-plugin-commentbox-button', 4000)
@@ -49,10 +48,10 @@ module.exports = {
// Post a comment
.setValue('.coral-plugin-commentbox-textarea', mockComment)
.click('.coral-plugin-commentbox-button')
.waitForElementVisible('.coral-plugin-content-text', 1000)
.waitForElementVisible('.embed__stream .coral-plugin-commentcontent-text', 1000)
// Verify that it appears
.assert.containsText('.coral-plugin-content-text', mockComment);
.assert.containsText('.embed__stream .coral-plugin-commentcontent-text', mockComment);
done();
})
.catch((err) => {
@@ -73,11 +72,7 @@ module.exports = {
// Post a comment
client.waitForElementVisible('.coral-plugin-commentbox-button', 2000)
.setValue('.coral-plugin-commentbox-textarea', mockComment)
.click('.coral-plugin-commentbox-button')
.waitForElementVisible('#coral-notif', 1000)
// Verify that it appears
.assert.containsText('#coral-notif', 'moderation team');
.click('.coral-plugin-commentbox-button');
done();
})
.catch((err) => {
@@ -102,15 +97,16 @@ module.exports = {
.click('.coral-plugin-commentbox-button')
// Post a reply
.waitForElementVisible('.coral-plugin-replies-reply-button', 5000)
.click('.coral-plugin-replies-reply-button')
.waitForElementVisible('.embed__stream .coral-plugin-replies-reply-button', 5000)
.click('.embed__stream .coral-plugin-replies-reply-button')
.waitForElementVisible('#replyText')
.setValue('#replyText', mockReply)
.click('.coral-plugin-replies-textarea .coral-plugin-commentbox-button')
.waitForElementVisible('.reply', 20000)
.click('.embed__stream .coral-plugin-replies-textarea .coral-plugin-commentbox-button')
.waitForElementVisible('.embed__stream .reply', 20000)
// Verify that it appears
.assert.containsText('.reply', mockReply);
.assert.containsText('.embed__stream .reply', mockReply);
done();
})
.catch((err) => {
@@ -171,7 +167,7 @@ module.exports = {
// Verify that comment count is correct
client.waitForElementVisible('.coral-plugin-comment-count-text', 2000)
.assert.containsText('.coral-plugin-comment-count-text', '4 Comments');
.assert.containsText('.coral-plugin-comment-count-text', '5 Comments');
done();
});
},
@@ -0,0 +1,50 @@
module.exports = {
'@tags': ['like', 'comments', 'commenter'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.navigate()
.ready();
embedStreamPage
.login(users.moderator);
},
'Moderator marks/unmarks their comment as BEST': client => {
const embedStreamPage = client.page.embedStreamPage();
const setBestCommentButton = '.e2e__set-best-comment';
const unsetBestCommentButton = '.e2e__unset-best-comment';
embedStreamPage
.postComment(`Hi everyone. Isn't this the BEST comment!? ${String(Math.random()).slice(2)}`)
.waitForElementVisible(setBestCommentButton, 2000)
.click(setBestCommentButton)
.waitForElementVisible(unsetBestCommentButton, 2000);
// on refresh, it should still be tagged as best :)
client.refresh();
embedStreamPage.ready()
// (bengo) I have no idea why, but if the selector here is '@unsetBestButton', it doesn't find it... I think nightwatch bug?
// this is why I am not using @elements. Advice appreciated.
.waitForElementVisible(unsetBestCommentButton, 2000);
// now remove the best tag
embedStreamPage
.click(unsetBestCommentButton);
embedStreamPage
.waitForElementVisible(setBestCommentButton, 2000);
// on refresh it should still be untagged best
client.refresh();
embedStreamPage.ready()
.waitForElementVisible(setBestCommentButton);
},
after: client => {
client.end();
}
};
+62
View File
@@ -0,0 +1,62 @@
const expect = require('chai').expect;
const {graphql} = require('graphql');
const schema = require('../../../graph/schema');
const Context = require('../../../graph/context');
const UserModel = require('../../../models/user');
const SettingsService = require('../../../services/settings');
const CommentsService = require('../../../services/comments');
describe('graph.mutations.addCommentTag', () => {
let comment;
beforeEach(async () => {
await SettingsService.init();
comment = await CommentsService.publicCreate({body: `hello there! ${ String(Math.random()).slice(2)}`});
});
const query = `
mutation AddCommentTag ($id: ID!, $tag: String!) {
addCommentTag(id:$id, tag:$tag) {
comment {
tags {
name
}
}
errors {
translation_key
}
}
}
`;
it('moderators can add tags to comments', async () => {
const user = new UserModel({roles: ['MODERATOR' ]});
const context = new Context({user});
const response = await graphql(schema, query, {}, context, {id: comment.id, tag: 'BEST'});
if (response.errors && response.errors.length) {
console.error(response.errors);
}
expect(response.errors).to.be.empty;
expect(response.data.addCommentTag.comment.tags).to.deep.equal([{name: 'BEST'}]);
});
describe('users who cant add tags', () => {
Object.entries({
'anonymous': undefined,
'regular commenter': new UserModel({}),
'banned moderator': new UserModel({roles: ['MODERATOR'], status: 'BANNED'})
}).forEach(([ userDescription, user ]) => {
it(userDescription, async function () {
const context = new Context({user});
const response = await graphql(schema, query, {}, context, {id: comment.id, tag: 'BEST'});
if (response.errors && response.errors.length) {
console.error(response.errors);
}
expect(response.errors).to.be.empty;
expect(response.data.addCommentTag.errors).to.deep.equal([{'translation_key':'NOT_AUTHORIZED'}]);
expect(response.data.addCommentTag.comment).to.be.null;
});
});
});
});
+69
View File
@@ -0,0 +1,69 @@
const expect = require('chai').expect;
const {graphql} = require('graphql');
const schema = require('../../../graph/schema');
const Context = require('../../../graph/context');
const UserModel = require('../../../models/user');
const SettingsService = require('../../../services/settings');
const CommentsService = require('../../../services/comments');
describe('graph.mutations.removeCommentTag', () => {
let comment;
beforeEach(async () => {
await SettingsService.init();
comment = await CommentsService.publicCreate({body: `hello there! ${ String(Math.random()).slice(2)}`});
});
const query = `
mutation RemoveCommentTag ($id: ID!, $tag: String!) {
removeCommentTag(id:$id, tag:$tag) {
comment {
tags {
name
}
}
errors {
translation_key
}
}
}
`;
it('moderators can add remove tags from comments', async () => {
const user = new UserModel({roles: ['MODERATOR' ]});
const context = new Context({user});
// add a tag first
await CommentsService.addTag(comment.id, 'BEST');
const response = await graphql(schema, query, {}, context, {id: comment.id, tag: 'BEST'});
if (response.errors && response.errors.length) {
console.error(response.errors);
}
expect(response.errors).to.be.empty;
expect(response.data.removeCommentTag.errors).to.be.null;
expect(response.data.removeCommentTag.comment.tags).to.deep.equal([]);
});
describe('users who cant remove tags', () => {
Object.entries({
'anonymous': undefined,
'regular commenter': new UserModel({}),
'banned moderator': new UserModel({roles: ['MODERATOR'], status: 'BANNED'})
}).forEach(([ userDescription, user ]) => {
it(userDescription, async function () {
const context = new Context({user});
// add a tag first
await CommentsService.addTag(comment.id, 'BEST');
const response = await graphql(schema, query, {}, context, {id: comment.id, tag: 'BEST'});
if (response.errors && response.errors.length) {
console.error(response.errors);
}
expect(response.errors).to.be.empty;
expect(response.data.removeCommentTag.errors).to.deep.equal([{'translation_key':'NOT_AUTHORIZED'}]);
expect(response.data.removeCommentTag.comment).to.be.null;
});
});
});
});
+60 -1
View File
@@ -8,7 +8,7 @@ const CommentsService = require('../../services/comments');
const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
const expect = require('chai').expect;
const expect = require('chai').use(require('chai-as-promised')).expect;
describe('services.CommentsService', () => {
const comments = [{
@@ -68,6 +68,7 @@ describe('services.CommentsService', () => {
}];
const users = [{
id: 'u1',
email: 'stampi@gmail.com',
username: 'Stampi',
password: '1Coral!!'
@@ -218,6 +219,64 @@ describe('services.CommentsService', () => {
});
describe('#addTag', () => {
it('adds a tag', async () => {
const commentId = comments[0].id;
const tagName = 'BEST';
const userId = users[0].id;
await CommentsService.addTag(commentId, tagName, userId);
const updatedComment = await CommentsService.findById(commentId);
expect(updatedComment.tags.length).to.equal(1);
expect(updatedComment.tags[0].name).to.equal(tagName);
expect(updatedComment.tags[0].assigned_by).to.equal(userId);
expect(updatedComment.tags[0].created_at).to.be.an.instanceof(Date);
});
it('can\'t add a tag to comment id that doesn\'t exist', async () => {
const commentId = 'fakenews';
const tagName = 'BEST';
const userId = users[0].id;
await expect(CommentsService.addTag(commentId, tagName, userId)).to.be.rejected;
});
it('can\'t add same tag.name twice', async () => {
const commentId = comments[0].id;
const tagName = 'BEST';
const userId = users[0].id;
// first time
await CommentsService.addTag(commentId, tagName, userId);
// second time should fail
await expect(CommentsService.addTag(commentId, tagName, userId)).to.be.rejected;
});
});
describe('#removeTag', () => {
it('removes a tag', async () => {
const commentId = comments[0].id;
const tagName = 'BEST';
await CommentsService.addTag(commentId, tagName, users[0].id);
const updatedComment = await CommentsService.findById(commentId);
expect(updatedComment.tags.length).to.equal(1);
// ok now to remove it
await CommentsService.removeTag(commentId, tagName);
const updatedComment2 = await CommentsService.findById(commentId);
expect(updatedComment2.tags.length).to.equal(0);
});
it('throws if removing a tag that isn\'t there', async () => {
const commentId = comments[0].id;
// just make sure it has no tags to start
const updatedComment2 = await CommentsService.findById(commentId);
expect(updatedComment2.tags.length).to.equal(0);
const tagName = 'BEST';
// ok now to remove it
await expect(CommentsService.removeTag(commentId, tagName)).to.be.rejected;
});
});
describe('#changeStatus', () => {
it('should change the status of a comment from no status', () => {
+353 -343
View File
File diff suppressed because it is too large Load Diff