Merge branch 'master' into story-134624635

This commit is contained in:
gaba
2017-01-31 15:00:14 -08:00
113 changed files with 2255 additions and 1670 deletions
+14 -28
View File
@@ -4,38 +4,13 @@
* Module dependencies.
*/
const program = require('commander');
const pkg = require('../package.json');
const dotenv = require('dotenv');
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.version(pkg.version)
.option('-c, --config [path]', 'Specify the configuration file to load')
.parse(process.argv);
if (program.config) {
let r = dotenv.config({
path: program.config
});
if (r.error) {
throw r.error;
}
}
// Perform rewrites to the runtime environment variables based on the contents
// of the process.env.REWRITE_ENV if it exists. This is done here as it is the
// entrypoint for the entire application.
require('env-rewrite').rewrite();
const util = require('../util');
const program = require('./commander');
program
.command('serve', 'serve the application')
.command('assets', 'interact with assets')
.command('settings', 'work with the application settings')
.command('setup', 'setup the application')
.command('jobs', 'work with the job queues')
.command('users', 'work with the application auth')
.parse(process.argv);
@@ -43,4 +18,15 @@ program
// If there is no command listed, output help.
if (!process.argv.slice(2).length) {
program.outputHelp();
return;
}
// The ensures that the child process that is created here is always cleaned up
// properly when the parent process dies.
util.onshutdown([
(signal) => {
if ((program.runningCommand.killed === false) && (program.runningCommand.exitCode === null)) {
program.runningCommand.kill(signal);
}
}
]);
+1 -5
View File
@@ -4,8 +4,7 @@
* Module dependencies.
*/
const program = require('commander');
const pkg = require('../package.json');
const program = require('./commander');
const parseDuration = require('parse-duration');
const Table = require('cli-table');
const AssetModel = require('../models/asset');
@@ -86,9 +85,6 @@ function refreshAssets(ageString) {
// Setting up the program command line arguments.
//==============================================================================
program
.version(pkg.version);
program
.command('list')
.description('list all the assets in the database')
+1 -1
View File
@@ -4,7 +4,7 @@
* Module dependencies.
*/
const program = require('commander');
const program = require('./commander');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const util = require('../util');
+7 -19
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env node
const app = require('../app');
const program = require('./commander');
const debug = require('debug')('talk:server');
const http = require('http');
const init = require('../init');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const kue = require('../services/kue');
@@ -86,27 +86,15 @@ function onListening() {
* Start the app.
*/
function startApp() {
init().then(() => {
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
}
/**
* Module dependencies.
*/
const program = require('commander');
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const program = require('commander');
const mongoose = require('../services/mongoose');
const SettingsService = require('../services/settings');
const util = require('../util');
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.command('init')
.description('initilizes the talk settings')
.action(() => {
const defaults = {
moderation: 'PRE',
wordlist: {
banned: [],
suspect: []
}
};
SettingsService
.init(defaults)
.then(() => {
console.log('Created settings object.');
util.shutdown();
})
.catch((err) => {
console.error(`failed to create the settings object ${JSON.stringify(err)}`);
util.shutdown(1);
});
});
program.parse(process.argv);
// If there is no command listed, output help.
if (!process.argv.slice(2).length) {
program.outputHelp();
util.shutdown();
}
Executable
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const program = require('./commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const SettingModel = require('../models/setting');
const SettingsService = require('../services/settings');
const util = require('../util');
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.description('runs the setup wizard to setup the application')
.option('--defaults', 'apply defaults for config instead of prompting')
.parse(process.argv);
//==============================================================================
// Setup the application
//==============================================================================
SettingsService
.init()
.then((settings) => {
if (program.defaults) {
return settings.save();
}
console.log('We\'ll ask you some questions in order to setup your installation of Talk.\n');
return inquirer.prompt([
{
type: 'input',
name: 'organizationName',
message: 'Organization Name',
default: settings.organizationName,
validate: (input) => {
if (input && input.length > 0) {
return true;
}
return 'Organization Name is required.';
}
},
{
type: 'list',
choices: SettingModel.MODERATION_OPTIONS,
name: 'moderation',
default: settings.moderation,
message: 'Select a moderation mode'
},
{
type: 'confirm',
name: 'requireEmailConfirmation',
default: settings.requireEmailConfirmation,
message: 'Should emails always be confirmed'
}
])
.then((answers) => {
// Update the settings that were changed.
Object.keys(answers).forEach((key) => {
if (answers[key] !== undefined) {
settings[key] = answers[key];
}
});
return settings.save();
});
})
.then(() => {
console.log('Talk is now installed!');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
+137 -109
View File
@@ -4,105 +4,132 @@
* Module dependencies.
*/
const program = require('commander');
const pkg = require('../package.json');
const prompt = require('prompt');
const program = require('./commander');
const inquirer = require('inquirer');
const UsersService = require('../services/users');
const UserModel = require('../models/user');
const mongoose = require('../services/mongoose');
const util = require('../util');
const Table = require('cli-table');
const validateRequired = (msg = 'Field is required', len = 1) => (input) => {
if (input && input.length >= len) {
return true;
}
return msg;
};
// Regeister the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
function getUserCreateAnswers(options) {
if (options.flag_mode) {
let user = {
email: options.email,
password: options.password,
confirmPassword: options.password,
displayName: options.name,
roles: []
};
if (options.role && UserModel.USER_ROLES.indexOf(options.role) > -1) {
user.roles = [options.role];
}
return Promise.resolve(user);
}
return inquirer.prompt([
{
name: 'email',
message: 'Email',
format: 'email',
validate: validateRequired('Email is required')
},
{
name: 'password',
message: 'Password',
type: 'password',
filter: (password) => {
return UsersService
.isValidPassword(password)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'confirmPassword',
message: 'Confirm Password',
type: 'password',
filter: (confirmPassword) => {
return UsersService
.isValidPassword(confirmPassword)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'displayName',
message: 'Display Name',
filter: (displayName) => {
return UsersService
.isValidDisplayName(displayName)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'roles',
message: 'User Role',
type: 'checkbox',
choices: UserModel.USER_ROLES
}
]);
}
/**
* Prompts for input and registers a user based on those.
*/
function createUser(options) {
return new Promise((resolve, reject) => {
if (options.flag_mode) {
return resolve({
email: options.email,
password: options.password,
displayName: options.name,
role: options.role
});
}
prompt.start();
prompt.get([
{
name: 'email',
description: 'Email',
format: 'email',
required: true
},
{
name: 'password',
description: 'Password',
hidden: true,
required: true
},
{
name: 'confirmPassword',
description: 'Confirm Password',
hidden: true,
required: true
},
{
name: 'displayName',
description: 'Display Name',
required: true
},
{
name: 'role',
description: 'User Role',
required: false
}
], (err, result) => {
if (err) {
return reject(err);
getUserCreateAnswers(options)
.then((answers) => {
if (answers.password !== answers.confirmPassword) {
return Promise.reject(new Error('Passwords do not match'));
}
if (result.password !== result.confirmPassword) {
return reject(new Error('Passwords do not match'));
}
return answers;
})
.then((answers) => {
return UsersService
.createLocalUser(answers.email.trim(), answers.password.trim(), answers.displayName.trim())
.then((user) => {
console.log(`Created user ${user.id}.`);
resolve(result);
});
})
.then((result) => {
return UsersService.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim())
.then((user) => {
console.log(`Created user ${user.id}.`);
let role = result.role ? result.role.trim() : null;
if (role && role.length > 0) {
return UsersService
.addRoleToUser(user.id, role)
.then(() => {
console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown();
});
} else {
util.shutdown();
}
if (answers.roles.length > 0) {
return Promise.all(answers.roles.map((role) => {
return UsersService
.addRoleToUser(user.id, role)
.then(() => {
console.log(`Added the role ${role} to User ${user.id}.`);
});
}));
}
});
})
.then(() => {
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown();
});
});
}
/**
@@ -127,44 +154,34 @@ function deleteUser(userID) {
* Changes the password for a user.
*/
function passwd(userID) {
prompt.start();
prompt.get([
inquirer.prompt([
{
name: 'password',
description: 'Password',
hidden: true,
required: true
message: 'Password',
type: 'password',
validate: validateRequired('Password is required')
},
{
name: 'confirmPassword',
description: 'Confirm Password',
hidden: true,
required: true
message: 'Confirm Password',
type: 'password',
validate: validateRequired('Confirm Password is required')
}
], (err, result) => {
if (err) {
console.error(err);
util.shutdown();
return;
])
.then((answers) => {
if (answers.password !== answers.confirmPassword) {
return Promise.reject(new Error('Password mismatch'));
}
if (result.password !== result.confirmPassword) {
console.error(new Error('Password mismatch'));
util.shutdown(1);
return;
}
UsersService
.changePassword(userID, result.password)
.then(() => {
console.log('Password changed.');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
return UsersService.changePassword(userID, answers.password);
})
.then(() => {
console.log('Password changed.');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
@@ -273,6 +290,13 @@ function mergeUsers(dstUserID, srcUserID) {
* @param {String} role the role to add
*/
function addRole(userID, role) {
if (UserModel.USER_ROLES.indexOf(role) === -1) {
console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`);
util.shutdown(1);
return;
}
UsersService
.addRoleToUser(userID, role)
.then(() => {
@@ -291,6 +315,13 @@ function addRole(userID, role) {
* @param {String} role the role to remove
*/
function removeRole(userID, role) {
if (UserModel.USER_ROLES.indexOf(role) === -1) {
console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`);
util.shutdown(1);
return;
}
UsersService
.removeRoleFromUser(userID, role)
.then(() => {
@@ -375,9 +406,6 @@ function enableUser(userID) {
// Setting up the program command line arguments.
//==============================================================================
program
.version(pkg.version);
program
.command('create')
.option('--email [email]', 'Email to use')
+46
View File
@@ -0,0 +1,46 @@
const pkg = require('../package.json');
const dotenv = require('dotenv');
const fs = require('fs');
const program = require('commander');
const util = require('../util');
// Perform rewrites to the runtime environment variables based on the contents
// of the process.env.REWRITE_ENV if it exists. This is done here as it is the
// entrypoint for the entire application.
require('env-rewrite').rewrite();
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
const parseArgs = require('minimist')(process.argv.slice(2), {
alias: {
'c': 'config'
},
string: [
'config',
'pid'
],
default: {
'config': null,
'pid': null
}
});
if (parseArgs.config) {
let envConfig = dotenv.parse(fs.readFileSync(parseArgs.config, {encoding: 'utf8'}));
Object.keys(envConfig).forEach((k) => {
process.env[k] = envConfig[k];
});
}
// If the `--pid` flag is used, put the current pid there.
if (parseArgs.pid) {
util.pid(parseArgs.pid);
}
module.exports = program
.version(pkg.version)
.option('-c, --config [path]', 'Specify the configuration file to load')
.option('--pid [path]', 'Specify a path to output the current PID to');
+3 -1
View File
@@ -14,13 +14,15 @@ database:
post:
# Initialize the settings in the database, this will create indicies for the
# database.
- ./bin/cli settings init
- ./bin/cli setup --defaults
- sleep 2
test:
override:
# Run the tests using the junit reporter.
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml NPM_PACKAGE_CONFIG_MOCHA_REPORTER=mocha-junit-reporter npm run test
# Run the e2e test suite
- E2E_REPORT_PATH=$CIRCLE_TEST_REPORTS/e2e npm run e2e
deployment:
release:
+4 -1
View File
@@ -14,7 +14,10 @@ export const checkLogin = () => dispatch => {
const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length;
dispatch(checkLoginSuccess(result.user, isAdmin));
})
.catch(error => dispatch(checkLoginFailure(error)));
.catch(error => {
console.error(error);
dispatch(checkLoginFailure(`${error.message}`));
});
};
// LogOut Actions
+14 -4
View File
@@ -15,17 +15,18 @@ export const fetchModerationQueueComments = () => {
return Promise.all([
coralApi('/queue/comments/pending'),
coralApi('/queue/users/pending'),
coralApi('/queue/comments/rejected'),
coralApi('/queue/comments/flagged')
])
.then(([pending, rejected, flagged]) => {
.then(([pendingComments, pendingUsers, rejected, flagged]) => {
/* Combine seperate calls into a single object */
flagged.comments.forEach(comment => comment.flagged = true);
return {
comments: [...pending.comments, ...rejected.comments, ...flagged.comments],
users: [...pending.users, ...rejected.users, ...flagged.users],
actions: [...pending.actions, ...rejected.actions, ...flagged.actions]
comments: [...pendingComments.comments, ...rejected.comments, ...flagged.comments],
users: [...pendingComments.users, ...pendingUsers.users, ...rejected.users, ...flagged.users],
actions: [...pendingComments.actions, ...pendingUsers.actions, ...rejected.actions, ...flagged.actions]
};
})
.then(addUsersCommentsActions.bind(this, dispatch));
@@ -41,6 +42,15 @@ export const fetchPendingQueue = () => {
};
};
export const fetchPendingUsersQueue = () => {
return dispatch => {
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
return coralApi('/queue/users/pending')
.then(addUsersCommentsActions.bind(this, dispatch));
};
};
export const fetchRejectedQueue = () => {
return dispatch => {
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
+12 -4
View File
@@ -1,5 +1,5 @@
import coralApi from '../../../coral-framework/helpers/response';
import * as actions from '../constants/user';
import * as userTypes from '../constants/users';
/**
* Action disptacher related to users
@@ -7,9 +7,17 @@ import * as actions from '../constants/user';
// change status of a user
export const userStatusUpdate = (status, userId, commentId) => {
return (dispatch) => {
dispatch({type: actions.UPDATE_STATUS_REQUEST});
dispatch({type: userTypes.UPDATE_STATUS_REQUEST});
return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
.then(res => dispatch({type: actions.UPDATE_STATUS_SUCCESS, res}))
.catch(error => dispatch({type: actions.UPDATE_STATUS_FAILURE, error}));
.then(res => dispatch({type: userTypes.UPDATE_STATUS_SUCCESS, res}))
.catch(error => dispatch({type: userTypes.UPDATE_STATUS_FAILURE, error}));
};
};
// change status of a user
export const sendNotificationEmail = (userId, subject, body) => {
return (dispatch) => {
return coralApi(`/users/${userId}/email`, {method: 'POST', body: {subject, body}})
.catch(error => dispatch({type: userTypes.USER_EMAIL_FAILURE, error}));
};
};
@@ -0,0 +1,48 @@
import React from 'react';
import styles from './ModerationList.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations.json';
import {FabButton, Button, Icon} from 'coral-ui';
const ActionButton = ({option, type, comment = {}, user, menuOptionsMap, onClickAction, onClickShowBanDialog}) =>
{
const banned = user.status === 'BANNED';
if (option === 'flag' && (type === 'USERS' || comment.status || comment.flagged === true)) {
return null;
}
if (option === 'ban') {
return (
<div className={styles.ban}>
<Button
className={`ban ${styles.banButton}`}
cStyle='darkGrey'
disabled={banned ? 'disabled' : ''}
onClick={() => onClickShowBanDialog(user.id, user.displayName, comment.id)
}
raised
>
<Icon name='not_interested' className={styles.banIcon} />
{lang.t('comment.ban_user')}
</Button>
</div>
);
}
const menuOption = menuOptionsMap[option];
const action = {
item_type: type,
item_id: type === 'COMMENTS' ? comment.id : user.id
};
return (
<FabButton
className={`${option} ${styles.actionButton}`}
cStyle={option}
icon={menuOption.icon}
onClick={() => onClickAction(menuOption.status, type === 'COMMENTS' ? comment : user, action)}
/>
);
};
export default ActionButton;
const lang = new I18n(translations);
@@ -35,7 +35,7 @@ const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => (
<Button cStyle="cancel" className={styles.cancel} onClick={() => handleClose()} raised>
{lang.t('bandialog.cancel')}
</Button>
<Button cStyle="black" className={styles.ban} onClick={() => onClickBanUser(user.userId, user.commentId)} raised>
<Button cStyle="black" className={styles.ban} onClick={() => onClickBanUser('BANNED', user.userId, user.commentId)} raised>
{lang.t('bandialog.yes_ban_user')}
</Button>
</div>
+17 -41
View File
@@ -2,18 +2,19 @@ import React from 'react';
import timeago from 'timeago.js';
import Linkify from 'react-linkify';
import styles from './CommentList.css';
import styles from './ModerationList.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations.json';
import Highlighter from 'react-highlight-words';
import {FabButton, Button, Icon} from 'coral-ui';
import {Icon} from 'coral-ui';
import ActionButton from './ActionButton';
const linkify = new Linkify();
// Render a single comment for the list
export default props => {
const Comment = props => {
const {comment, author} = props;
let authorStatus = author.status;
const links = linkify.getMatches(comment.body);
@@ -30,7 +31,18 @@ export default props => {
{links ?
<span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
<div className={`actions ${styles.actions}`}>
{props.modActions.map((action, i) => getActionButton(action, i, props))}
{props.modActions.map(
(action, i) =>
<ActionButton
option={action}
key={i}
type='COMMENTS'
comment={comment}
user={author}
menuOptionsMap={props.menuOptionsMap}
onClickAction={props.onClickAction}
onClickShowBanDialog={props.onClickShowBanDialog}/>
)}
</div>
{authorStatus === 'banned' ?
<span className={styles.banned}><Icon name='error_outline'/> {lang.t('comment.banned_user')}</span> : null}
@@ -49,43 +61,7 @@ export default props => {
);
};
// Get the button of the action performed over a comment if any
const getActionButton = (action, i, props) => {
const {comment, author} = props;
const status = comment.status;
const flagged = comment.flagged;
const banned = (author.status === 'banned');
if (action === 'flag' && (status || flagged === true)) {
return null;
}
if (action === 'ban') {
return (
<div className={styles.ban}>
<Button
className={`ban ${styles.banButton}`}
cStyle='darkGrey'
disabled={banned ? 'disabled' : ''}
onClick={() => props.onClickShowBanDialog(author.id, author.displayName, comment.id)}
key={i}
raised
>
<Icon name='not_interested' className={styles.banIcon} />
{lang.t('comment.ban_user')}
</Button>
</div>
);
}
return (
<FabButton
className={`${action} ${styles.actionButton}`}
cStyle={action}
icon={props.actionsMap[action].icon}
key={i}
onClick={() => props.onClickAction(props.actionsMap[action].status, comment)}
/>
);
};
export default Comment;
const linkStyles = {
backgroundColor: 'rgb(255, 219, 135)',
@@ -1,166 +0,0 @@
import React, {PropTypes} from 'react';
import styles from './CommentList.css';
import key from 'keymaster';
import Hammer from 'hammerjs';
import Comment from 'components/Comment';
// Each action has different meaning and configuration
const modActions = {
'reject': {status: 'REJECTED', icon: 'close', key: 'r'},
'approve': {status: 'ACCEPTED', icon: 'done', key: 't'},
'flag': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'},
'ban': {status: 'BANNED', icon: 'not interested'}
};
// Renders a comment list and allow performing actions
export default class CommentList extends React.Component {
static propTypes = {
isActive: PropTypes.bool,
singleView: PropTypes.bool,
commentIds: PropTypes.arrayOf(PropTypes.string).isRequired,
comments: PropTypes.object.isRequired,
users: PropTypes.object.isRequired,
onClickAction: PropTypes.func,
// list of actions (flags, etc) associated with the comments
modActions: PropTypes.arrayOf(PropTypes.string).isRequired,
loading: PropTypes.bool,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired
}
constructor (props) {
super(props);
this.state = {active: null};
this.onClickAction = this.onClickAction.bind(this);
this.onClickShowBanDialog = this.onClickShowBanDialog.bind(this);
}
// remove key handlers before leaving
componentWillUnmount () {
this.unbindKeyHandlers();
}
// add key handlers and gestures
componentDidMount () {
this.bindKeyHandlers();
// this.bindGestures() // need to check whether we're on a mobile device or this throws an Error
}
// If entering to singleview and no active, active is the first eleement
componentWillReceiveProps (nextProps) {
if (nextProps.singleView && !this.state.active) {
this.setState({active: nextProps.commentIds[0]});
}
}
// Add swipe to approve or reject
bindGestures () {
const {modActions} = this.props;
this._hammer = new Hammer(this.base);
this._hammer.get('swipe').set({direction: Hammer.DIRECTION_HORIZONTAL});
if (modActions.indexOf('reject') !== -1) {
this._hammer.on('swipeleft', () => this.props.singleView && this.actionKeyHandler('Rejected'));
}
if (modActions.indexOf('approve') !== -1) {
this._hammer.on('swiperight', () => this.props.singleView && this.actionKeyHandler('Approved'));
}
}
// Add key handlers. Each action has one and added j/k for moving around
bindKeyHandlers () {
this.props.modActions.filter(action => modActions[action].key).forEach(action => {
key(modActions[action].key, 'commentList', () => this.props.isActive && this.actionKeyHandler(modActions[action].status));
});
key('j', 'commentList', () => this.props.isActive && this.moveKeyHandler('down'));
key('k', 'commentList', () => this.props.isActive && this.moveKeyHandler('up'));
key.setScope('commentList');
}
// Perform an action using the keys only if the comment is active
actionKeyHandler (action) {
if (this.props.isActive && this.state.active) {
this.onClickAction(action, this.state.active);
}
}
// move around with j/k
moveKeyHandler (direction) {
if (!this.props.isActive) {
return;
}
const {commentIds} = this.props;
const {active} = this.state;
// check boundaries
if (active === null || !commentIds.length) {
this.setState({active: commentIds[0]});
} else if (direction === 'up' && active !== commentIds[0]) {
this.setState({active: commentIds[commentIds.indexOf(active) - 1]});
} else if (direction === 'down' && active !== commentIds[commentIds.length - 1]) {
this.setState({active: commentIds[commentIds.indexOf(active) + 1]});
}
// scroll to the position
const index = Math.max(commentIds.indexOf(this.state.active), 0);
this.base.childNodes[index] && this.base.childNodes[index].focus();
}
unbindKeyHandlers () {
key.deleteScope('commentList');
}
// If we are performing an action over a comment (aka removing from the list) we need to select a new active.
// TODO: In the future this can be improved and look at the actual state to
// resolve since the content of the list could change externally. For now it works as expected
onClickAction (action, id, author_id) {
// activate the next comment
if (id === this.state.active) {
const {commentIds} = this.props;
if (commentIds[commentIds.length - 1] === this.state.active) {
this.setState({active: commentIds[commentIds.length - 2]});
} else {
this.setState({active: commentIds[Math.min(commentIds.indexOf(this.state.active) + 1, commentIds.length - 1)]});
}
}
this.props.onClickAction(action, id, author_id);
}
onClickShowBanDialog(userId, userName, commentId) {
this.props.onClickShowBanDialog(userId, userName, commentId);
}
render () {
const {singleView, commentIds, comments, users, hideActive, key, suspectWords} = this.props;
const {active} = this.state;
return (
<ul
className={`${styles.list} ${singleView ? styles.singleView : ''}`} {...key}
id='commentList'
>
{commentIds.map((commentId, index) => {
const comment = comments[commentId];
const author = users[comment.author_id];
return <Comment
suspectWords={suspectWords}
comment={comment}
author={author}
key={index}
index={index}
onClickAction={this.onClickAction}
onClickShowBanDialog={this.onClickShowBanDialog}
modActions={this.props.modActions}
actionsMap={modActions}
isActive={commentId === active}
hideActive={hideActive} />;
})}
</ul>
);
}
}
@@ -19,7 +19,6 @@
.inner {
width: 100vw;
height: 100vh;
background-color: #fff;
box-shadow: 2px 2px 5px 0px rgba(153,153,153,1);
padding: 20px;
@@ -37,7 +36,6 @@
@media (--big-viewport) {
.inner {
width: 600px;
height: 50vh;
border-radius: 4px;
}
}
@@ -69,6 +69,7 @@
justify-content: space-between;
.author {
min-width: 230px;
display: flex;
align-items: center;
}
@@ -112,6 +113,12 @@
padding-top: 15px;
padding-left: 10px;
}
.flagCount{
font-size: 12px;
color: #d32f2f;
}
}
.empty {
@@ -0,0 +1,227 @@
import React, {PropTypes} from 'react';
import styles from './ModerationList.css';
import key from 'keymaster';
import Hammer from 'hammerjs';
import Comment from './Comment';
import UserAction from './UserAction';
import SuspendUserModal from './SuspendUserModal';
// Each action has different meaning and configuration
const menuOptionsMap = {
'reject': {status: 'REJECTED', icon: 'close', key: 'r'},
'approve': {status: 'ACCEPTED', icon: 'done', key: 't'},
'flag': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'},
'ban': {status: 'BANNED', icon: 'not interested'}
};
// Renders a comment list and allow performing actions
export default class ModerationList extends React.Component {
static propTypes = {
isActive: PropTypes.bool,
singleView: PropTypes.bool,
commentIds: PropTypes.arrayOf(PropTypes.string),
actionIds: PropTypes.arrayOf(PropTypes.string),
comments: PropTypes.object,
users: PropTypes.object.isRequired,
actions: PropTypes.object,
userStatusUpdate: PropTypes.func.isRequired,
suspendUser: PropTypes.func.isRequired,
// list of actions (flags, etc) associated with the comments
modActions: PropTypes.arrayOf(PropTypes.string).isRequired,
loading: PropTypes.bool,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired
}
state = {active: null, suspendUserModal: null, email: null};
// remove key handlers before leaving
componentWillUnmount () {
this.unbindKeyHandlers();
}
// add key handlers and gestures
componentDidMount () {
this.bindKeyHandlers();
// this.bindGestures() // need to check whether we're on a mobile device or this throws an Error
}
// If entering to singleview and no active, active is the first eleement
componentWillReceiveProps (nextProps) {
if (nextProps.singleView && !this.state.active) {
this.setState({active: nextProps.commentIds[0]});
}
}
// Add swipe to approve or reject
bindGestures () {
const {modActions} = this.props;
this._hammer = new Hammer(this.base);
this._hammer.get('swipe').set({direction: Hammer.DIRECTION_HORIZONTAL});
if (modActions.indexOf('reject') !== -1) {
this._hammer.on('swipeleft', () => this.props.singleView && this.actionKeyHandler('Rejected'));
}
if (modActions.indexOf('approve') !== -1) {
this._hammer.on('swiperight', () => this.props.singleView && this.actionKeyHandler('Approved'));
}
}
// Add key handlers. Each action has one and added j/k for moving around
bindKeyHandlers () {
const {modActions, isActive} = this.props;
modActions.filter(action => menuOptionsMap[action].key).forEach(action => {
key(menuOptionsMap[action].key, 'moderationList', () => isActive && this.actionKeyHandler(menuOptionsMap[action].status));
});
key('j', 'moderationList', () => isActive && this.moveKeyHandler('down'));
key('k', 'moderationList', () => isActive && this.moveKeyHandler('up'));
key.setScope('moderationList');
}
// Perform an action using the keys only if the comment is active
actionKeyHandler (action) {
if (this.props.isActive && this.state.active) {
this.onClickAction(action, this.state.active);
}
}
// move around with j/k
moveKeyHandler (direction) {
if (!this.props.isActive) {
return;
}
const {commentIds} = this.props;
const {active} = this.state;
// check boundaries
if (active === null || !commentIds.length) {
this.setState({active: commentIds[0]});
} else if (direction === 'up' && active !== commentIds[0]) {
this.setState({active: commentIds[commentIds.indexOf(active) - 1]});
} else if (direction === 'down' && active !== commentIds[commentIds.length - 1]) {
this.setState({active: commentIds[commentIds.indexOf(active) + 1]});
}
// scroll to the position
const index = Math.max(commentIds.indexOf(this.state.active), 0);
this.base.childNodes[index] && this.base.childNodes[index].focus();
}
unbindKeyHandlers () {
key.deleteScope('moderationList');
}
// If we are performing an action over a comment (aka removing from the list) we need to select a new active.
// TODO: In the future this can be improved and look at the actual state to
// resolve since the content of the list could change externally. For now it works as expected
onClickAction = (menuOption, id, action) => {
// activate the next comment
if (id === this.state.active) {
const moderationIds = this.getModerationIds();
if (moderationIds[moderationIds.length - 1] === this.state.active) {
this.setState({active: moderationIds[moderationIds.length - 2]});
} else {
this.setState({active: moderationIds[Math.min(moderationIds.indexOf(this.state.active) + 1, moderationIds.length - 1)]});
}
}
// Update the status right away if this is a comment
if (action.item_type === 'COMMENTS') {
this.props.updateCommentStatus(menuOption, id);
} else if (action.item_type === 'USERS') {
// If a user bio or name is rejected, bring up a dialog before suspending them.
if (menuOption === 'REJECTED') {
this.setState({suspendUserModal: action});
} else if (menuOption === 'ACCEPTED') {
this.props.userStatusUpdate('ACTIVE', action.item_id);
}
}
}
onClickShowBanDialog = (userId, userName, commentId) => {
this.props.onClickShowBanDialog(userId, userName, commentId);
}
mapModItems = (itemId, index) => {
const {comments = {}, users, actions = {}, modActions, suspectWords, hideActive} = this.props;
const {active} = this.state;
// Because ids are unique, the id will either appear as an action or as a comment.
const item = comments[itemId] || actions[itemId];
let modItem;
if (item.body) {
// If the item is a comment...
const author = users[item.author_id];
modItem = <Comment
suspectWords={suspectWords}
comment={item}
author={author}
key={index}
index={index}
onClickAction={this.onClickAction}
onClickShowBanDialog={this.onClickShowBanDialog}
modActions={modActions}
menuOptionsMap={menuOptionsMap}
isActive={itemId === active}
hideActive={hideActive} />;
} else {
// If the item is an action...
const user = users[item.item_id];
modItem = user && <UserAction
suspectWords={suspectWords}
action={item}
user={user}
key={index}
index={index}
onClickAction={this.onClickAction}
onClickShowBanDialog={this.onClickShowBanDialog}
modActions={modActions}
menuOptionsMap={menuOptionsMap}
isActive={itemId === active}
hideActive={hideActive} />;
}
return modItem;
}
getModerationIds = () => {
const {commentIds = [], actionIds = [], comments, actions} = this.props;
if (comments && actions) {
return [ ...commentIds, ...actionIds ].sort((a, b) => {
const itemA = comments[a] || actions[a];
const itemB = comments[b] || actions[b];
return itemB.updated_at - itemA.updated_at;
});
} else {
return comments ? commentIds : actionIds;
}
}
render () {
const {singleView, key, suspendUser} = this.props;
// Combine moderations and actions into a single stream and sort by most recently updated.
const moderationIds = this.getModerationIds();
return (
<ul
className={`${styles.list} ${singleView ? styles.singleView : ''}`} {...key}
id='moderationList'>
{moderationIds.map(this.mapModItems)}
<SuspendUserModal
action = {this.state.suspendUserModal}
onClose={() => this.setState({suspendUserModal:null})}
suspendUser={suspendUser} />
</ul>
);
}
}
@@ -0,0 +1,31 @@
.modalButtons {
display: flex;
justify-content: flex-end;
margin-top: 50px;
}
.emailInput {
border-radius: 3px;
width: 100%;
padding: 10px;
font-size: 14px;
}
.writeContainer {
margin-top: 20px;
}
.emailContainer {
display: flex;
}
.emailMessage {
font-weight: 800;
}
.title {
font-size: 20px;
font-weight: 800;
margin-bottom: 20px;
}
@@ -0,0 +1,106 @@
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations.json';
import React, {Component, PropTypes} from 'react';
import Modal from 'components/Modal';
import styles from './SuspendUserModal.css';
import {Button} from 'coral-ui';
const stages = [
{
title: 'suspenduser.title_0',
description: 'suspenduser.description_0',
options: {
'j': 'suspenduser.no_cancel',
'k': 'suspenduser.yes_suspend'
}
},
{
title: 'suspenduser.title_1',
description: 'suspenduser.description_1',
options: {
'j': 'bandialog.cancel',
'k': 'suspenduser.send'
}
}
];
class SuspendUserModal extends Component {
state = {email: '', stage: 0}
static propTypes = {
stage: PropTypes.number,
actionType: PropTypes.string,
onClose: PropTypes.func.isRequired,
suspendUser: PropTypes.func.isRequired
}
componentDidMount() {
const about = this.props.actionType === 'flag_bio' ? lang.t('suspenduser.bio') : lang.t('suspenduser.username');
this.setState({email: lang.t('suspenduser.email', about)});
}
/*
* When an admin clicks to suspend a user a dialog is shown, this function
* handles the possible actions for that dialog.
*/
onActionClick = (stage, menuOption) => () => {
const {suspendUser, action} = this.props;
const {stage, email} = this.state;
const cancel = this.props.onClose;
const next = () => this.setState({stage: stage + 1});
const suspend = () => suspendUser(action.item_id, lang.t('suspenduser.email_subject'), email)
.then(this.props.onClose);
const suspendModalActions = [
[ cancel, next ],
[ cancel, suspend ]
];
return suspendModalActions[stage][menuOption]();
}
onEmailChange = (e) => this.setState({email: e.target.value})
render () {
const {action, onClose} = this.props;
if (!action) {
return null;
}
const {stage} = this.state;
const actionType = action.actionType;
const about = actionType === 'flag_bio' ? lang.t('suspenduser.bio') : lang.t('suspenduser.username');
return <Modal open={true} onClose={onClose}>
<div className={styles.title}>{lang.t(stages[stage].title, about)}</div>
<div className={styles.container}>
<div className={styles.description}>
{lang.t(stages[stage].description, about)}
</div>
{
stage === 1 &&
<div className={styles.writeContainer}>
<div className={styles.emailMessage}>{lang.t('suspenduser.write_message')}</div>
<div className={styles.emailContainer}>
<textarea
rows={5}
className={styles.emailInput}
value={this.state.email}
onChange={this.onEmailChange}/>
</div>
</div>
}
<div className={styles.modalButtons}>
{Object.keys(stages[stage].options).map((key, i) => (
<Button key={i} onClick={this.onActionClick(stage, i)}>
{lang.t(stages[stage].options[key], about)}
</Button>
))}
</div>
</div>
</Modal>;
}
}
export default SuspendUserModal;
const lang = new I18n(translations);
@@ -0,0 +1,79 @@
import React from 'react';
import Linkify from 'react-linkify';
import styles from './ModerationList.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations.json';
import {Icon} from 'react-mdl';
import Highlighter from 'react-highlight-words';
import ActionButton from './ActionButton';
const linkify = new Linkify();
// Render a single comment for the list
const UserAction = props => {
const {action, user} = props;
let userStatus = user.status;
const links = user.settings.bio ? linkify.getMatches(user.settings.bio) : [];
// Do not display unless the user status is 'pending' or 'banned'.
// This means that they have already been reviewed and approved.
return (userStatus === 'PENDING' || userStatus === 'BANNED') &&
<li tabIndex={props.index} className={`mdl-card mdl-shadow--2dp ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
<div className={styles.itemHeader}>
<div className={styles.author}>
<span>{user.displayName}</span>
</div>
<div className={styles.sideActions}>
{links ?
<span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
<div className={`actions ${styles.actions}`}>
{props.modActions.map(
(action, i) =>
<ActionButton
option={action}
key={i}
type='USERS'
user={user}
menuOptionsMap={props.menuOptionsMap}
onClickAction={props.onClickAction}
onClickShowBanDialog={props.onClickShowBanDialog}/>
)}
</div>
</div>
<div>
{userStatus === 'banned' ?
<span className={styles.banned}><Icon name='error_outline'/> {lang.t('comment.banned_user')}</span> : null}
</div>
</div>
{
user.settings.bio &&
<div>
<div className={styles.itemBody}>
<div>{lang.t('user.user_bio')}:</div>
<span className={styles.body}>
<Linkify component='span' properties={{style: linkStyles}}>
<Highlighter
searchWords={props.suspectWords}
textToHighlight={user.settings.bio} />
</Linkify>
</span>
</div>
</div>
}
<div className={styles.flagCount}>
{`${action.count} ${action.action_type === 'flag_bio' ? lang.t('user.bio_flags') : lang.t('user.username_flags')}`}
</div>
</li>;
};
export default UserAction;
const linkStyles = {
backgroundColor: 'rgb(255, 219, 135)',
padding: '1px 2px'
};
const lang = new I18n(translations);
@@ -1,3 +1,5 @@
export const UPDATE_STATUS_REQUEST = 'UPDATE_STATUS_REQUEST';
export const UPDATE_STATUS_SUCCESS = 'UPDATE_STATUS_SUCCESS';
export const UPDATE_STATUS_FAILURE = 'UPDATE_STATUS_FAILURE';
export const USER_EMAIL_FAILURE = 'USER_EMAIL_FAILURE';
export const USERS_MODERATION_QUEUE_FETCH_SUCCESS = 'USERS_MODERATION_QUEUE_FETCH_SUCCESS';
@@ -3,11 +3,11 @@ import styles from './CommentStream.css';
import {Snackbar} from 'react-mdl';
import {connect} from 'react-redux';
import {createComment, flagComment} from 'actions/comments';
import CommentList from 'components/CommentList';
import ModerationList from 'components/ModerationList';
import CommentBox from 'components/CommentBox';
/**
* Renders a comment stream using a CommentList component
* Renders a comment stream using a ModerationList component
* and adds a box for adding a new comment
*/
@@ -39,12 +39,12 @@ class CommentStream extends React.Component {
}
}
// Render the comment box along with the CommentList
// Render the comment box along with the ModerationList
render ({comments, users}, {snackbar, snackbarMsg}) {
return (
<div className={styles.container}>
<CommentBox onSubmit={this.onSubmit}/>
<CommentList isActive hideActive
<CommentBox onSubmit={this.onSubmit} />
<ModerationList isActive hideActive
singleView={false}
commentIds={comments.ids}
comments={comments.byId}
@@ -32,4 +32,3 @@ export default connect(
mapStateToProps,
mapDispatchToProps
)(LayoutContainer);
@@ -11,7 +11,7 @@ import {
fetchFlaggedQueue,
fetchModerationQueueComments,
} from 'actions/comments';
import {userStatusUpdate} from 'actions/users';
import {userStatusUpdate, sendNotificationEmail} from 'actions/users';
import {fetchSettings} from 'actions/settings';
import ModerationQueue from './ModerationQueue';
@@ -22,7 +22,7 @@ class ModerationContainer extends React.Component {
super(props);
this.state = {
activeTab: 'pending',
activeTab: 'all',
singleView: false,
modalOpen: false
};
@@ -74,7 +74,7 @@ class ModerationContainer extends React.Component {
}
render () {
const {comments} = this.props;
const {comments, actions} = this.props;
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'PREMOD');
const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'REJECTED');
const flaggedIds = comments.ids.filter(id =>
@@ -82,12 +82,14 @@ class ModerationContainer extends React.Component {
comments.byId[id].status !== 'REJECTED' &&
comments.byId[id].status !== 'ACCEPTED'
);
const userActionIds = actions.ids.filter(id => actions.byId[id].item_type === 'USERS');
return (
<ModerationQueue
onTabClick={this.onTabClick}
onClose={this.onClose}
premodIds={premodIds}
userActionIds={userActionIds}
rejectedIds={rejectedIds}
flaggedIds={flaggedIds}
{...this.props}
@@ -100,7 +102,8 @@ class ModerationContainer extends React.Component {
const mapStateToProps = state => ({
comments: state.comments.toJS(),
settings: state.settings.toJS(),
users: state.users.toJS()
users: state.users.toJS(),
actions: state.actions.toJS(),
});
const mapDispatchToProps = dispatch => {
@@ -112,9 +115,13 @@ const mapDispatchToProps = dispatch => {
fetchFlaggedQueue: () => dispatch(fetchFlaggedQueue()),
showBanUserDialog: (userId, userName, commentId) => dispatch(showBanUserDialog(userId, userName, commentId)),
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
banUser: (userId, commentId) => dispatch(userStatusUpdate('BANNED', userId, commentId)).then(() => {
userStatusUpdate: (status, userId, commentId) => dispatch(userStatusUpdate(status, userId, commentId)).then(() => {
dispatch(fetchModerationQueueComments());
}),
suspendUser: (userId, subject, text) => dispatch(userStatusUpdate('suspended', userId))
.then(() => dispatch(sendNotificationEmail(userId, subject, text)))
.then(() => dispatch(fetchModerationQueueComments()))
,
updateStatus: (action, comment) => dispatch(updateStatus(action, comment))
};
};
@@ -2,7 +2,7 @@ import React from 'react';
import styles from './ModerationQueue.css';
import ModerationKeysModal from 'components/ModerationKeysModal';
import CommentList from 'components/CommentList';
import ModerationList from 'components/ModerationList';
import BanUserDialog from 'components/BanUserDialog';
import I18n from 'coral-framework/modules/i18n/i18n';
@@ -10,23 +10,40 @@ import translations from '../../translations.json';
const lang = new I18n(translations);
export default ({onTabClick, ...props}) => (
export default (props) => (
<div>
<div className='mdl-tabs'>
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<a href='#all'
onClick={(e) => {
e.preventDefault();
props.onTabClick('all');
}}
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'all' ? styles.active : ''}`}
>
{lang.t('modqueue.all')}
</a>
<a href='#pending'
onClick={(e) => {
e.preventDefault();
onTabClick('pending');
props.onTabClick('pending');
}}
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'pending' ? styles.active : ''}`}
>
{lang.t('modqueue.pending')}
</a>
<a href='#account'
onClick={(e) => {
e.preventDefault();
props.onTabClick('account');
}}
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'account' ? styles.active : ''}`}>
{lang.t('modqueue.account')}
</a>
<a href='#rejected'
onClick={(e) => {
e.preventDefault();
onTabClick('rejected');
props.onTabClick('rejected');
}}
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'rejected' ? styles.active : ''}`}
>
@@ -35,69 +52,146 @@ export default ({onTabClick, ...props}) => (
<a href='#flagged'
onClick={(e) => {
e.preventDefault();
onTabClick('flagged');
props.onTabClick('flagged');
}}
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'flagged' ? styles.active : ''}`}
>
{lang.t('modqueue.flagged')}
</a>
</div>
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='pending'>
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='all'>
{
props.activeTab === 'pending'
? <div>
<CommentList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'pending'}
singleView={props.singleView}
commentIds={props.premodIds}
comments={props.comments.byId}
users={props.users.byId}
onClickAction={props.updateStatus}
onClickShowBanDialog={props.showBanUserDialog}
modActions={['reject', 'approve', 'ban']}
loading={props.comments.loading} />
<BanUserDialog
open={props.comments.showBanUserDialog}
handleClose={props.hideBanUserDialog}
onClickBanUser={props.banUser}
user={props.comments.banUser} />
</div>
: null
props.activeTab === 'all' &&
<div>
<ModerationList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'all'}
singleView={props.singleView}
commentIds={[...props.premodIds, ...props.flaggedIds]}
comments={props.comments.byId}
users={props.users.byId}
actionIds={props.userActionIds}
actions={props.actions.byId}
userStatusUpdate={props.userStatusUpdate}
suspendUser={props.suspendUser}
updateCommentStatus={props.updateStatus}
onClickShowBanDialog={props.showBanUserDialog}
modActions={['reject', 'approve', 'ban']}
loading={props.comments.loading}/>
<BanUserDialog
open={props.comments.showBanUserDialog}
handleClose={props.hideBanUserDialog}
onClickBanUser={props.userStatusUpdate}
user={props.comments.banUser}
/>
</div>
}
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
{
props.activeTab === 'rejected'
? <CommentList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'rejected'}
singleView={props.singleView}
commentIds={props.rejectedIds}
comments={props.comments.byId}
users={props.users.byId}
onClickAction={props.updateStatus}
modActions={['approve']}
loading={props.comments.loading} />
: null
}
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='pending'>
{
props.activeTab === 'pending' &&
<div>
<ModerationList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'pending'}
singleView={props.singleView}
commentIds={props.premodIds}
comments={props.comments.byId}
users={props.users.byId}
actions={props.actions.byId}
userStatusUpdate={props.userStatusUpdate}
suspendUser={props.suspendUser}
updateCommentStatus={props.updateStatus}
onClickShowBanDialog={props.showBanUserDialog}
modActions={['reject', 'approve', 'ban']}
loading={props.comments.loading}/>
<BanUserDialog
open={props.comments.showBanUserDialog}
handleClose={props.hideBanUserDialog}
onClickBanUser={props.userStatusUpdate}
user={props.comments.banUser}
/>
</div>
}
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='account'>
{
props.activeTab === 'account' &&
<div>
<ModerationList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'account'}
singleView={props.singleView}
users={props.users.byId}
actionIds={props.userActionIds}
actions={props.actions.byId}
userStatusUpdate={props.userStatusUpdate}
suspendUser={props.suspendUser}
updateCommentStatus={props.updateStatus}
onClickShowBanDialog={props.showBanUserDialog}
modActions={['reject', 'approve', 'ban']}
loading={props.comments.loading}/>
<BanUserDialog
open={props.comments.showBanUserDialog}
handleClose={props.hideBanUserDialog}
onClickBanUser={props.userStatusUpdate}
user={props.comments.banUser}
/>
</div>
}
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='flagged'>
{
props.activeTab === 'flagged'
? <CommentList
props.activeTab === 'flagged' &&
<div>
<ModerationList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'flagged'}
singleView={props.singleView}
commentIds={props.flaggedIds}
userStatusUpdate={props.userStatusUpdate}
suspendUser={props.suspendUser}
comments={props.comments.byId}
users={props.users.byId}
onClickAction={props.updateStatus}
updateCommentStatus={props.updateStatus}
modActions={['reject', 'approve']}
loading={props.comments.loading} />
: null
}
loading={props.comments.loading}/>
<BanUserDialog
open={props.comments.showBanUserDialog}
handleClose={props.hideBanUserDialog}
onClickBanUser={props.userStatusUpdate}
user={props.comments.banUser}
/>
</div>
}
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
{
props.activeTab === 'rejected' &&
<div>
<ModerationList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'rejected'}
singleView={props.singleView}
commentIds={props.rejectedIds}
userStatusUpdate={props.userStatusUpdate}
suspendUser={props.suspendUser}
comments={props.comments.byId}
users={props.users.byId}
updateCommentStatus={props.updateStatus}
modActions={['approve']}
loading={props.comments.loading}
/>
<BanUserDialog
open={props.comments.showBanUserDialog}
handleClose={props.hideBanUserDialog}
onClickBanUser={props.userStatusUpdate}
user={props.comments.banUser}
/>
</div>
}
</div>
<ModerationKeysModal open={props.modalOpen} onClose={props.closeModal} />
</div>
</div>
+9 -6
View File
@@ -1,8 +1,9 @@
import {Map, Set} from 'immutable';
import {Map, Set, fromJS} from 'immutable';
import * as types from '../constants/actions';
const initialState = Map({
ids: Set()
ids: Set(),
byId: Map()
});
export default (state = initialState, action) => {
@@ -14,11 +15,13 @@ export default (state = initialState, action) => {
};
const addActions = (state, action) => {
const ids = action.actions.map(action => action.item_id);
// Make ids that are unique by item_id and by action type
const typeId = (action) => `${action.action_type}_${action.item_id}`;
const ids = action.actions.map(action => typeId(action));
const map = action.actions.reduce((memo, action) => {
memo[action.item_id] = action;
memo[typeId(action)] = action;
return memo;
}, {});
const newIds = state.get('ids').concat(ids);
return state.merge(map).set('ids', newIds);
return state.set('byId', fromJS(map)).set('ids', new Set(ids));
};
+1 -1
View File
@@ -1,5 +1,5 @@
import * as actions from '../constants/comments';
import * as userActions from '../constants/user';
import * as userActions from '../constants/users';
import {Map, List, fromJS} from 'immutable';
/**
+26
View File
@@ -16,9 +16,11 @@
"loading": "Loading results"
},
"modqueue": {
"all": "all",
"pending": "pending",
"rejected": "rejected",
"flagged": "flagged",
"account": "account flags",
"shortcuts": "Shortcuts",
"close": "Close",
"actions": "Actions",
@@ -37,6 +39,11 @@
"ban_user": "Ban User",
"banned_user": "Banned User"
},
"user": {
"user_bio": "User Bio",
"bio_flags": "flags for this bio",
"username_flags": "flags for this username"
},
"embedlink": {
"copy": "Copy to Clipboard"
},
@@ -79,6 +86,20 @@
"cancel": "Cancel",
"yes_ban_user": "Yes, Ban User"
},
"suspenduser": {
"title_0": "We noticed you rejected a {0}",
"description_0": "Would you like to temporarily ban this user becuase of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}.",
"title_1": "Notify the user of their temporary suspension",
"description_1": "Suspending this user will temporarily disable their account and hide all of their comments on the site.",
"no_cancel": "No, cancel",
"yes_suspend": "Yes, suspend",
"send": "Send",
"bio": "bio",
"username": "username",
"email_subject": "Your account has been suspended",
"email": "Another member of the community recently flagged your {0} for review. Because of its content your {0} was rejected. This means you can no longer comment, like, or flag content until you rewrite your {0}. Please e-mail moderator@newsorg.com if you have any questions or concerns.",
"write_message": "Write a message"
},
"streams": {
"search": "Search",
"filter-streams": "Filter Streams",
@@ -126,6 +147,11 @@
"ban_user": "Suspender Usuario",
"banned_user": "Usuario Suspendido"
},
"user": {
"user_bio": "",
"bio_flags": "",
"username_flags": ""
},
"configure": {
"enable-pre-moderation": "Habilitar pre-moderación",
"enable-pre-moderation-text": "Los moderadores deben aprobar cada comentario antes de que sea publicado.",
@@ -7,47 +7,50 @@ import translations from '../translations.json';
const lang = new I18n(translations);
export default ({handleChange, handleApply, changed, ...props}) => (
<div className={styles.wrapper}>
<div className={styles.container}>
<h3>{lang.t('configureCommentStream.title')}</h3>
<p>{lang.t('configureCommentStream.description')}</p>
<Button
className={styles.apply}
cStyle={changed ? 'green' : 'darkGrey'}
onClick={handleApply}
>
{lang.t('configureCommentStream.apply')}
</Button>
</div>
<ul>
<li>
<Checkbox
className={styles.checkbox}
cStyle={changed ? 'green' : 'darkGrey'}
name="premod"
<form onSubmit={handleApply}>
<div className={styles.wrapper}>
<div className={styles.container}>
<h3>{lang.t('configureCommentStream.title')}</h3>
<p>{lang.t('configureCommentStream.description')}</p>
<Button
type="submit"
className={styles.apply}
onChange={handleChange}
checked={props.premod}
info={{
title: lang.t('configureCommentStream.enablePremod'),
description: lang.t('configureCommentStream.enablePremodDescription')
}}
/>
<ul>
<li>
<Checkbox
className={styles.checkbox}
cStyle={changed ? 'green' : 'darkGrey'}
name="premodLinks"
onChange={handleChange}
checked={props.premodLinks}
info={{
title: lang.t('configureCommentStream.enablePremodLinks'),
description: lang.t('configureCommentStream.enablePremodDescription')
}}
/>
</li>
</ul>
</li>
</ul>
</div>
cStyle={changed ? 'green' : 'darkGrey'} >
{lang.t('configureCommentStream.apply')}
</Button>
</div>
<ul>
<li>
<Checkbox
className={styles.checkbox}
cStyle={changed ? 'green' : 'darkGrey'}
name="premod"
onChange={handleChange}
defaultChecked={props.premod}
info={{
title: lang.t('configureCommentStream.enablePremod'),
description: lang.t('configureCommentStream.enablePremodDescription')
}} />
{/* To be implimented
<ul>
<li>
<Checkbox
className={styles.checkbox}
cStyle={changed ? 'green' : 'darkGrey'}
name="premodLinks"
onChange={handleChange}
defaultChecked={props.premodLinks}
info={{
title: lang.t('configureCommentStream.enablePremodLinks'),
description: lang.t('configureCommentStream.enablePremodDescription')
}} />
</li>
</ul>
*/}
</li>
</ul>
</div>
</form>
);
@@ -1,8 +1,9 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {compose} from 'react-apollo';
import {I18n} from '../../coral-framework';
import {updateOpenStatus, updateConfiguration} from '../../coral-framework/actions/asset';
import {I18n} from 'coral-framework';
import {updateOpenStatus, updateConfiguration} from 'coral-framework/actions/asset';
import CloseCommentsInfo from '../components/CloseCommentsInfo';
import ConfigureCommentStream from '../components/ConfigureCommentStream';
@@ -13,11 +14,8 @@ class ConfigureStreamContainer extends Component {
constructor (props) {
super(props);
console.log('moderation', props.asset.settings.moderation);
this.state = {
premod: props.asset.settings.moderation === 'PRE',
premodLinks: false
changed: false
};
this.toggleStatus = this.toggleStatus.bind(this);
@@ -25,11 +23,18 @@ class ConfigureStreamContainer extends Component {
this.handleApply = this.handleApply.bind(this);
}
handleApply () {
const {premod, changed} = this.state;
handleApply (e) {
e.preventDefault();
const {elements} = e.target;
const premod = elements.premod.checked;
// const premodLinks = elements.premodLinks.checked;
const {changed} = this.state;
const newConfig = {
moderation: premod ? 'PRE' : 'POST'
};
if (changed) {
this.props.updateConfiguration(newConfig);
setTimeout(() => {
@@ -40,16 +45,16 @@ class ConfigureStreamContainer extends Component {
}
}
handleChange (e) {
const {name, checked} = e.target;
handleChange () {
this.setState({
[name]: checked,
changed: true
});
}
toggleStatus () {
this.props.updateStatus(this.props.asset.closedAt === null ? 'closed' : 'open');
this.props.updateStatus(
this.props.asset.closedAt === null ? 'closed' : 'open'
);
}
getClosedIn () {
@@ -60,13 +65,16 @@ class ConfigureStreamContainer extends Component {
render () {
const status = this.props.asset.closedAt === null ? 'open' : 'closed';
const premod = this.props.asset.settings.moderation === 'PRE';
return (
<div>
<ConfigureCommentStream
handleChange={this.handleChange}
handleApply={this.handleApply}
changed={this.state.changed}
{...this.state}
premodLinks={false}
premod={premod}
/>
<hr />
<h3>{status === 'open' ? 'Close' : 'Open'} Comment Stream</h3>
@@ -89,7 +97,6 @@ const mapDispatchToProps = dispatch => ({
updateConfiguration: newConfig => dispatch(updateConfiguration(newConfig))
});
export default connect(
mapStateToProps,
mapDispatchToProps
export default compose(
connect(mapStateToProps, mapDispatchToProps)
)(ConfigureStreamContainer);
+15 -14
View File
@@ -26,6 +26,11 @@ class Comment extends React.Component {
}
static propTypes = {
reactKey: PropTypes.string.isRequired,
// id of currently opened ReplyBox. tracked in Stream.js
activeReplyBox: PropTypes.string.isRequired,
setActiveReplyBox: PropTypes.func.isRequired,
refetch: PropTypes.func.isRequired,
showSignInDialog: PropTypes.func.isRequired,
postAction: PropTypes.func.isRequired,
@@ -60,15 +65,6 @@ class Comment extends React.Component {
}).isRequired
}
onReplyBoxClick = () => {
if (this.props.currentUser) {
this.setState({replyBoxVisible: !this.state.replyBoxVisible});
} else {
const offset = document.getElementById(`c_${this.props.comment.id}`).getBoundingClientRect().top - 75;
this.props.showSignInDialog(offset);
}
}
render () {
const {
comment,
@@ -81,6 +77,8 @@ class Comment extends React.Component {
addNotification,
showSignInDialog,
postAction,
setActiveReplyBox,
activeReplyBox,
deleteAction
} = this.props;
@@ -89,7 +87,7 @@ class Comment extends React.Component {
return (
<div
className="comment"
className={parentId ? 'reply' : 'comment'}
id={`c_${comment.id}`}
style={{marginLeft: depth * 30}}>
<hr aria-hidden={true} />
@@ -106,7 +104,7 @@ class Comment extends React.Component {
<Content body={comment.body} />
<div className="commentActionsLeft">
<ReplyButton
onClick={this.onReplyBoxClick}
onClick={() => setActiveReplyBox(comment.id)}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
banned={false} />
@@ -130,13 +128,13 @@ class Comment extends React.Component {
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
</div>
{
this.state.replyBoxVisible
activeReplyBox === comment.id
? <ReplyBox
commentPostedHandler={() => {
console.log('replyPostedHandler');
this.setState({replyBoxVisible: false});
setActiveReplyBox('');
refetch();
}}
setActiveReplyBox={setActiveReplyBox}
parentId={parentId || comment.id}
addNotification={addNotification}
authorId={currentUser.id}
@@ -149,6 +147,8 @@ class Comment extends React.Component {
comment.replies.map(reply => {
return <Comment
refetch={refetch}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
parentId={comment.id}
postItem={postItem}
@@ -158,6 +158,7 @@ class Comment extends React.Component {
postAction={postAction}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
reactKey={reply.id}
key={reply.id}
comment={reply} />;
})
+10 -7
View File
@@ -1,7 +1,7 @@
import React, {Component} from 'react';
import {compose} from 'react-apollo';
import {connect} from 'react-redux';
import {isEqual} from 'lodash';
import isEqual from 'lodash/isEqual';
import {TabBar, Tab, TabContent, Spinner} from '../../coral-ui';
@@ -9,8 +9,8 @@ const {logout, showSignInDialog} = authActions;
const {addNotification, clearNotification} = notificationActions;
const {fetchAssetSuccess} = assetActions;
import {queryStream} from './graphql/queries';
import {postComment, postAction, deleteAction} from './graphql/mutations';
import {queryStream} from 'coral-framework/graphql/queries';
import {postComment, postAction, deleteAction} from 'coral-framework/graphql/mutations';
import {Notification, notificationActions, authActions, assetActions, pym} from 'coral-framework';
import Stream from './Stream';
@@ -82,9 +82,12 @@ class Embed extends Component {
render () {
const {activeTab} = this.state;
const {closedAt} = this.props.asset;
const {loading, asset, refetch} = this.props.data;
const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth;
const openStream = closedAt === null;
const expandForLogin = showSignInDialog ? {
minHeight: document.body.scrollHeight + 200
} : {};
@@ -94,14 +97,14 @@ class Embed extends Component {
loading ? <Spinner/>
: <div className="commentStream">
<TabBar onChange={this.changeTab} activeTab={activeTab}>
<Tab><Count count={asset.comments.length}/></Tab>
<Tab><Count count={asset.commentCount}/></Tab>
<Tab>Settings</Tab>
<Tab restricted={!isAdmin}>Configure Stream</Tab>
</TabBar>
{loggedIn && user && <UserBox user={user} logout={this.props.logout} />}
<TabContent show={activeTab === 0}>
{
asset.closedAt === null
openStream
? <div id="commentBox">
<InfoBox
content={asset.settings.infoBoxContent}
@@ -174,10 +177,10 @@ class Embed extends Component {
}
const mapStateToProps = state => ({
items: state.items.toJS(),
notification: state.notification.toJS(),
auth: state.auth.toJS(),
userData: state.user.toJS()
userData: state.user.toJS(),
asset: state.asset.toJS()
});
const mapDispatchToProps = dispatch => ({
+66 -43
View File
@@ -1,49 +1,72 @@
import React, {PropTypes} from 'react';
import Comment from './Comment';
const Stream = ({
comments,
currentUser,
asset,
postItem,
addNotification,
postAction,
deleteAction,
showSignInDialog,
refetch
}) => {
return (
<div>
{
comments.map(comment => {
return <Comment
refetch={refetch}
addNotification={addNotification}
depth={0}
postItem={postItem}
asset={asset}
currentUser={currentUser}
postAction={postAction}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
comment={comment} />;
})
}
</div>
);
};
class Stream extends React.Component {
Stream.propTypes = {
refetch: PropTypes.func.isRequired,
addNotification: PropTypes.func.isRequired,
postItem: PropTypes.func.isRequired,
asset: PropTypes.object.isRequired,
comments: PropTypes.array.isRequired,
currentUser: PropTypes.shape({
displayName: PropTypes.string,
id: PropTypes.string
})
};
static propTypes = {
refetch: PropTypes.func.isRequired,
addNotification: PropTypes.func.isRequired,
postItem: PropTypes.func.isRequired,
asset: PropTypes.object.isRequired,
comments: PropTypes.array.isRequired,
currentUser: PropTypes.shape({
displayName: PropTypes.string,
id: PropTypes.string
})
}
constructor(props) {
super(props);
this.state = {activeReplyBox: ''};
this.setActiveReplyBox = this.setActiveReplyBox.bind(this);
}
setActiveReplyBox (reactKey) {
if (!this.props.currentUser) {
const offset = document.getElementById(`c_${reactKey}`).getBoundingClientRect().top - 75;
this.props.showSignInDialog(offset);
} else {
this.setState({activeReplyBox: reactKey});
}
}
render () {
const {
comments,
currentUser,
asset,
postItem,
addNotification,
postAction,
deleteAction,
showSignInDialog,
refetch
} = this.props;
return (
<div>
{
comments.map(comment => {
return <Comment
refetch={refetch}
setActiveReplyBox={this.setActiveReplyBox}
activeReplyBox={this.state.activeReplyBox}
addNotification={addNotification}
depth={0}
postItem={postItem}
asset={asset}
currentUser={currentUser}
postAction={postAction}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
reactKey={comment.id}
comment={comment} />;
})
}
</div>
);
}
}
export default Stream;
@@ -1,23 +0,0 @@
fragment commentView on Comment {
id
body
status
user {
name: displayName
}
actions {
type: action_type
count
current: current_user {
id
created_at
}
}
}
mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) {
createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) {
...commentView
}
}
+9 -9
View File
@@ -10,30 +10,30 @@ export const fetchAssetRequest = () => ({type: actions.FETCH_ASSET_REQUEST});
export const fetchAssetSuccess = asset => ({type: actions.FETCH_ASSET_SUCCESS, asset});
export const fetchAssetFailure = error => ({type: actions.FETCH_ASSET_FAILURE, error});
const updateConfigRequest = () => ({type: actions.UPDATE_CONFIG_REQUEST});
const updateConfigSuccess = config => ({type: actions.UPDATE_CONFIG_SUCCESS, config});
const updateConfigFailure = () => ({type: actions.UPDATE_CONFIG_FAILURE});
const updateAssetSettingsRequest = () => ({type: actions.UPDATE_ASSET_SETTINGS_REQUEST});
const updateAssetSettingsSuccess = settings => ({type: actions.UPDATE_ASSET_SETTINGS_SUCCESS, settings});
const updateAssetSettingsFailure = () => ({type: actions.UPDATE_ASSET_SETTINGS_FAILURE});
export const updateConfiguration = newConfig => (dispatch, getState) => {
const assetId = getState().asset.toJS().id;
dispatch(updateConfigRequest());
dispatch(updateAssetSettingsRequest());
coralApi(`/assets/${assetId}/settings`, {method: 'PUT', body: newConfig})
.then(() => {
dispatch(addNotification('success', lang.t('successUpdateSettings')));
dispatch(updateConfigSuccess(newConfig));
dispatch(updateAssetSettingsSuccess(newConfig));
})
.catch(error => dispatch(updateConfigFailure(error)));
.catch(error => dispatch(updateAssetSettingsFailure(error)));
};
export const updateOpenStream = closedBody => (dispatch, getState) => {
const assetId = getState().asset.toJS().id;
dispatch(updateConfigRequest());
dispatch(fetchAssetRequest());
coralApi(`/assets/${assetId}/status`, {method: 'PUT', body: closedBody})
.then(() => {
dispatch(addNotification('success', lang.t('successUpdateSettings')));
dispatch(updateConfigSuccess(closedBody));
dispatch(fetchAssetSuccess(closedBody));
})
.catch(error => dispatch(updateConfigFailure(error)));
.catch(error => dispatch(fetchAssetFailure(error)));
};
const openStream = () => ({type: actions.OPEN_COMMENTS});
+4 -6
View File
@@ -3,7 +3,6 @@ import translations from './../translations';
const lang = new I18n(translations);
import * as actions from '../constants/auth';
import coralApi, {base} from '../helpers/response';
import {addItem, updateItem} from './items';
// Dialog Actions
export const showSignInDialog = (offset = 0) => ({type: actions.SHOW_SIGNIN_DIALOG, offset});
@@ -21,7 +20,6 @@ export const createDisplayName = (userId, formData) => dispatch => {
.then(() => {
dispatch(createDisplayNameSuccess());
dispatch(hideCreateDisplayNameDialog());
dispatch(updateItem(userId, 'displayName', formData.displayName, 'users'));
dispatch(updateDisplayName(formData.displayName));
})
.catch(() => {
@@ -53,7 +51,6 @@ export const fetchSignIn = (formData) => (dispatch) => {
const isAdmin = !!user.roles.filter(i => i === 'ADMIN').length;
dispatch(signInSuccess(user, isAdmin));
dispatch(hideSignInDialog());
dispatch(addItem(user, 'users'));
})
.catch(() => dispatch(signInFailure(lang.t('error.emailPasswordError'))));
};
@@ -82,8 +79,6 @@ export const facebookCallback = (err, data) => dispatch => {
const user = JSON.parse(data);
dispatch(signInFacebookSuccess(user));
dispatch(hideSignInDialog());
dispatch(showCreateDisplayNameDialog());
dispatch(addItem(user, 'users'));
} catch (err) {
dispatch(signInFacebookFailure(err));
return;
@@ -159,5 +154,8 @@ export const checkLogin = () => dispatch => {
const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length;
dispatch(checkLoginSuccess(result.user, isAdmin));
})
.catch(error => dispatch(checkLoginFailure(error)));
.catch(error => {
console.error(error);
dispatch(checkLoginFailure(`${error.message}`));
});
};
-267
View File
@@ -1,267 +0,0 @@
import coralApi from '../helpers/response';
import {fromJS} from 'immutable';
import {UPDATE_CONFIG} from '../constants/config';
/**
* Action name constants
*/
export const ADD_ITEM = 'ADD_ITEM';
export const UPDATE_ITEM = 'UPDATE_ITEM';
export const APPEND_ITEM_ARRAY = 'APPEND_ITEM_ARRAY';
/**
* Action creators
*/
/*
* Adds an item to the local store without posting it to the server
* Useful for optimistic posting, etc.
*
* @params
* item - the item to be posted
*
*/
export const addItem = (item, item_type) => {
if (!item.id) {
console.warn('addItem called without an item id.');
}
return {
type: ADD_ITEM,
item,
item_type,
id: item.id
};
};
/*
* Updates an item in the local store without posting it to the server
* Useful for item-level toggles, etc.
*
* @params
* id - the id of the item to be posted
* property - the property to be updated
* value - the value that the property should be set to
* item_type - the type of the item being updated (users, comments, etc)
*
*/
export const updateItem = (id, property, value, item_type) => {
return {
type: UPDATE_ITEM,
id,
property,
value,
item_type
};
};
/*
* Appends data to an array in an item in the local store without posting it to the server
* Useful for adding a recently posted reply to a comment, etc.
*
* @params
* id - the id of the item to be posted
* property - the property to be updated (should be an array)
* value - the value that should be added to the array
* add_to_front - boolean that defines whether value is added at the beginning (unshift) or end (push)
* item_type - the type of the item being updated (users, comments, etc)
*
*/
export const appendItemArray = (id, property, value, add_to_front, item_type) => {
return {
type: APPEND_ITEM_ARRAY,
id,
property,
value,
add_to_front,
item_type
};
};
/*
* Get Items from Query
* Gets a set of items from a predefined query
*
* @params
* Query - a predefiend query for retreiving items
*
* @returns
* A promise resolving to a set of items
*
* @dispatches
* A set of items to the item store
*/
export function getStream (assetUrl) {
return (dispatch) => {
return coralApi(`/stream?asset_url=${encodeURIComponent(assetUrl)}`)
.then((json) => {
/* Add items to the store */
Object.keys(json).forEach(type => {
if (type === 'actions') {
json[type].forEach(action => {
action.id = `${action.action_type}_${action.item_id}`;
dispatch(addItem(action, 'actions'));
});
} else if (type === 'settings') {
dispatch({type: UPDATE_CONFIG, config: fromJS(json[type])});
} else {
json[type].forEach(item => {
dispatch(addItem(item, type));
});
}
});
const assetId = json.assets[0].id;
/* Sort comments by date*/
json.comments.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const rels = json.comments.reduce((h, item) => {
/* Check for root and child comments. */
if (
item.asset_id === assetId &&
!item.parent_id) {
h.rootComments.push(item.id);
} else if (
item.asset_id === assetId
) {
let children = h.childComments[item.parent_id] || [];
h.childComments[item.parent_id] = children.concat(item.id);
}
return h;
}, {rootComments: [], childComments: {}});
dispatch(updateItem(assetId, 'comments', rels.rootComments, 'assets'));
Object.keys(rels.childComments).forEach(key => {
dispatch(updateItem(key, 'children', rels.childComments[key].reverse(), 'comments'));
});
/* Hydrate actions on comments */
json.actions.forEach(action => {
dispatch(updateItem(action.item_id, action.action_type, action.id, 'comments'));
});
return (json);
});
};
}
/*
* Get Items Array
* Gets a set of items from an array of item ids
*
* @params
* Query - a predefiend query for retreiving items
*
* @returns
* A promise resolving to a set of items
*
* @dispatches
* A set of items to the item store
*/
export function getItemsArray (ids) {
return (dispatch) => {
return coralApi(`/item/${ids}`)
.then((json) => {
for (let i = 0; i < json.items.length; i++) {
dispatch(addItem(json.items[i]));
}
return json.items;
});
};
}
/*
* PutItem
* Puts an item
*
* @params
* Item - the item to be put
*
* @returns
* A promise resolving to an item is
*
* @dispatches
* The newly put item to the item store
*/
export function postItem (item, type, id, mutate) {
console.log(
item,
type,
id,
mutate
);
mutate({
variables: {
asset_id: id,
body: item,
parent_id: null
}
}).then(({data}) => {
console.log(data);
});
// return (dispatch) => {
// if (id) {
// item.id = id;
// }
// return coralApi(`/${type}`, {method: 'POST', body: item})
// .then((json) => {
// dispatch(addItem({...item, id:json.id}, type));
// return json;
// });
// };
}
/*
* PostAction
* Posts an action to an item
*
* @params
* id - the id of the item on which the action is taking place
* action - the action object.
* Must include an 'action_type' string.
* May optionally include a `metadata` object with arbitrary action data.
* user - the user performing the action
* host - the coral host
*
* @returns
* A promise resolving to null or an error
*
*/
export function postAction (item_id, item_type, action) {
return (dispatch) => {
return coralApi(`/${item_type}/${item_id}/actions`, {method: 'POST', body: action})
.then((json) => {
dispatch(updateItem(action.item_id, action.action_type, action.id, item_type));
return json;
});
};
}
/*
* DeleteAction
* Deletes an action to an item
*
* @params
* id - the id of the item on which the action is taking place
* action - the name of the action
* user - the user performing the action
* host - the coral host
*
* @returns
* A promise resolving to null or an error
*
*/
export function deleteAction (action_id) {
return () => {
return coralApi(`/actions/${action_id}`, {method: 'DELETE'});
};
}
-35
View File
@@ -1,7 +1,5 @@
import * as actions from '../constants/user';
import * as assetActions from '../constants/assets';
import {addNotification} from '../actions/notification';
import {addItem} from '../actions/items';
import coralApi from '../helpers/response';
import I18n from 'coral-framework/modules/i18n/i18n';
@@ -21,36 +19,3 @@ export const saveBio = (user_id, formData) => dispatch => {
})
.catch(error => dispatch(saveBioFailure(error)));
};
/**
*
* Get a list of comments by a single user
*
* @param {string} user_id
* @returns Promise
*/
export const fetchCommentsByUserId = userId => {
return (dispatch, getState) => {
dispatch({type: actions.COMMENTS_BY_USER_REQUEST});
return coralApi(`/comments?user_id=${userId}`)
.then(({comments, assets}) => {
const state = getState();
comments.forEach(comment => dispatch(addItem(comment, 'comments')));
assets.forEach(asset => {
const prevAsset = state.items.getIn(['assets', asset.id]);
if (prevAsset) {
// Include data such as hydrated comments from assets already in the system.
dispatch(addItem({...prevAsset.toJS(), ...asset}, 'assets'));
} else {
dispatch(addItem(asset, 'assets'));
}
});
dispatch({type: actions.COMMENTS_BY_USER_SUCCESS, comments: comments.map(comment => comment.id)});
dispatch({type: assetActions.MULTIPLE_ASSETS_SUCCESS, assets: assets.map(asset => asset.id)});
})
.catch(error => dispatch({type: actions.COMMENTS_BY_USER_FAILURE, error}));
};
};
+1
View File
@@ -2,6 +2,7 @@ import ApolloClient, {addTypename} from 'apollo-client';
import getNetworkInterface from './transport';
export const client = new ApolloClient({
connectToDevTools: true,
queryTransformer: addTypename,
dataIdFromObject: (result) => {
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
+3 -6
View File
@@ -2,12 +2,9 @@ export const FETCH_ASSET_REQUEST = 'FETCH_ASSET_REQUEST';
export const FETCH_ASSET_FAILURE = 'FETCH_ASSET_FAILURE';
export const FETCH_ASSET_SUCCESS = 'FETCH_ASSET_SUCCESS';
export const UPDATE_CONFIG_REQUEST = 'UPDATE_CONFIG_REQUEST';
export const UPDATE_CONFIG_SUCCESS = 'UPDATE_CONFIG_SUCCESS';
export const UPDATE_CONFIG_FAILURE = 'UPDATE_CONFIG_FAILURE';
export const UPDATE_CONFIG = 'UPDATE_CONFIG';
export const UPDATE_ASSET_SETTINGS_REQUEST = 'UPDATE_ASSET_SETTINGS_REQUEST';
export const UPDATE_ASSET_SETTINGS_SUCCESS = 'UPDATE_ASSET_SETTINGS_SUCCESS';
export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE';
export const OPEN_COMMENTS = 'OPEN_COMMENTS';
export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
export const ADD_ITEM = 'ADD_ITEM';
@@ -0,0 +1,21 @@
fragment commentView on Comment {
id
body
created_at
status
user {
id
name: displayName
settings {
bio
}
}
actions {
type: action_type
count
current: current_user {
id
created_at
}
}
}
@@ -3,7 +3,12 @@ import POST_COMMENT from './postComment.graphql';
import POST_ACTION from './postAction.graphql';
import DELETE_ACTION from './deleteAction.graphql';
import commentView from '../fragments/commentView.graphql';
export const postComment = graphql(POST_COMMENT, {
options: () => ({
fragments: commentView
}),
props: ({mutate}) => ({
postItem: ({asset_id, body, parent_id} /* , type */ ) => {
return mutate({
@@ -0,0 +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) {
...commentView
}
}
@@ -1,5 +1,6 @@
import {graphql} from 'react-apollo';
import STREAM_QUERY from './streamQuery.graphql';
import MY_COMMENT_HISTORY from './myCommentHistory.graphql';
function getQueryVariable(variable) {
let query = window.location.search.substring(1);
@@ -13,5 +14,11 @@ function getQueryVariable(variable) {
}
export const queryStream = graphql(STREAM_QUERY, {
options: {variables: {asset_url: getQueryVariable('asset_url')}}
options: () => ({
variables: {
asset_url: getQueryVariable('asset_url')
}
})
});
export const myCommentHistory = graphql(MY_COMMENT_HISTORY, {});
@@ -0,0 +1,9 @@
query myCommentHistory {
me {
comments {
id
body
created_at
}
}
}
@@ -1,24 +1,4 @@
fragment commentView on Comment {
id
body
created_at
user {
id
name: displayName
settings {
bio
}
}
actions {
type: action_type
count
current: current_user {
id
created_at
}
}
}
#import "../fragments/commentView.graphql"
query AssetQuery($asset_url: String!) {
asset(url: $asset_url) {
@@ -37,6 +17,7 @@ query AssetQuery($asset_url: String!) {
charCount
requireEmailConfirmation
}
commentCount
comments {
...commentView
replies {
+7 -9
View File
@@ -1,19 +1,17 @@
import Notification from './modules/notification/Notification';
import store from './store';
import * as itemActions from './actions/items';
import pym from './PymConnection';
import I18n from './modules/i18n/i18n';
import * as notificationActions from './actions/notification';
import * as authActions from './actions/auth';
import * as assetActions from './actions/asset';
import pym from './PymConnection';
import * as notificationActions from './actions/notification';
import Notification from './modules/notification/Notification';
export {
Notification,
store,
itemActions,
pym,
I18n,
notificationActions,
store,
authActions,
assetActions,
pym
Notification,
notificationActions
};
+3 -14
View File
@@ -13,23 +13,12 @@ const initialState = Map({
export default function asset (state = initialState, action) {
switch (action.type) {
case actions.FETCH_ASSET_SUCCESS :
case actions.FETCH_ASSET_SUCCESS:
return state
.merge(action.asset);
case actions.UPDATE_CONFIG:
case actions.UPDATE_ASSET_SETTINGS_SUCCESS:
return state
.merge(action.config);
case actions.UPDATE_CONFIG_SUCCESS:
return state
.merge(action.config);
case actions.OPEN_COMMENTS:
return state
.set('status', 'open')
.set('closedAt', null);
case actions.CLOSE_COMMENTS:
return state
.set('status', 'closed')
.set('closedAt', Date.now());
.setIn(['settings'], action.settings);
default:
return state;
}
-2
View File
@@ -1,13 +1,11 @@
import auth from './auth';
import user from './user';
import asset from './asset';
import items from './items';
import notification from './notification';
export default {
auth,
user,
asset,
items,
notification
};
-30
View File
@@ -1,30 +0,0 @@
/* Items Reducer */
import {fromJS} from 'immutable';
import * as actions from '../actions/items';
const initialState = fromJS({
comments: {},
users: {},
assets: {},
actions: {}
});
export default (state = initialState, action) => {
switch (action.type) {
case actions.ADD_ITEM:
return state.setIn([action.item_type, action.id], fromJS(action.item));
case actions.UPDATE_ITEM:
return state.setIn([action.item_type, action.id, action.property], fromJS(action.value));
case actions.APPEND_ITEM_ARRAY:
return state.updateIn([action.item_type, action.id, action.property], (prop) => {
if (action.add_to_front) {
return prop ? prop.unshift(fromJS(action.value)) : fromJS([action.value]);
} else {
return prop ? prop.push(fromJS(action.value)) : fromJS([action.value]);
}
});
default:
return state;
}
};
+21 -2
View File
@@ -13,6 +13,7 @@ class CommentBox extends Component {
// comments: PropTypes.array,
commentPostedHandler: PropTypes.func,
postItem: PropTypes.func.isRequired,
cancelButtonClicked: PropTypes.func,
assetId: PropTypes.string.isRequired,
parentId: PropTypes.string,
authorId: PropTypes.string.isRequired,
@@ -74,8 +75,6 @@ class CommentBox extends Component {
} else if (postedComment.status === 'PREMOD') {
addNotification('success', lang.t('comment-post-notif-premod'));
} else {
// appendItemArray(parent_id || id, related, commentId, !parent_id, parent_type);
addNotification('success', 'Your comment has been posted.');
}
@@ -89,7 +88,14 @@ class CommentBox extends Component {
render () {
const {styles, isReply, authorId, charCount} = this.props;
let {cancelButtonClicked} = this.props;
const length = this.state.body.length;
if (isReply && typeof cancelButtonClicked !== 'function') {
console.warn('the CommentBox component should have a cancelButtonClicked callback defined if it lives in a Reply');
cancelButtonClicked = () => {};
}
return <div>
<div
className={`${name}-container`}>
@@ -115,6 +121,19 @@ class CommentBox extends Component {
}
</div>
<div className={`${name}-button-container`}>
{
isReply && (
<Button
cStyle='darkGrey'
className={`${name}-cancel-button`}
onClick={() => {
console.log('cancel button in comment box');
cancelButtonClicked('');
}}>
{lang.t('cancel')}
</Button>
)
}
{ authorId && (
<Button
cStyle={!length || (charCount && length > charCount) ? 'lightGrey' : 'darkGrey'}
@@ -1,6 +1,7 @@
{
"en": {
"post": "Post",
"cancel": "Cancel",
"reply": "Reply",
"comment": "Comment",
"name": "Name",
@@ -11,6 +12,7 @@
},
"es": {
"post": "Publicar",
"cancel": "Cancelar",
"reply": "Respuesta",
"comment": "Comentario",
"name": "Nombre",
@@ -1,5 +1,5 @@
import React from 'react';
const name = 'coral-plugin-replies';
const name = 'coral-plugin-content';
const Content = ({body, styles}) => {
const textbreaks = body.split('\n');
+4 -2
View File
@@ -14,6 +14,7 @@ class FlagButton extends Component {
reason: '',
note: '',
step: 0,
posted: false,
localPost: null,
localDelete: false
}
@@ -38,7 +39,7 @@ class FlagButton extends Component {
onPopupContinue = () => {
const {postAction, id, author_id} = this.props;
const {itemType, reason, step, localPost} = this.state;
const {itemType, reason, step, posted} = this.state;
// Proceed to the next step or close the menu if we've reached the end
if (step + 1 >= this.props.getPopupMenu.length) {
@@ -48,7 +49,8 @@ class FlagButton extends Component {
}
// If itemType and reason are both set, post the action
if (itemType && reason && !localPost) {
if (itemType && reason && !posted) {
this.setState({posted: true});
// Set the text from the "other" field if it exists.
let item_id;
@@ -7,12 +7,11 @@ const CommentHistory = props => {
<div className={`${styles.header} commentHistory`}>
<div className="commentHistory__list">
{props.comments.map((comment, i) => {
const asset = props.assets.find(asset => asset.id === comment.asset_id);
return <Comment
key={i}
comment={comment}
link={props.link}
asset={asset} />;
asset={props.asset} />;
})}
</div>
</div>
@@ -20,8 +19,8 @@ const CommentHistory = props => {
};
CommentHistory.propTypes = {
comments: PropTypes.arrayOf(PropTypes.object).isRequired,
assets: PropTypes.arrayOf(PropTypes.object).isRequired
comments: PropTypes.array.isRequired,
asset: PropTypes.object.isRequired
};
export default CommentHistory;
+3 -1
View File
@@ -3,11 +3,12 @@ import CommentBox from '../coral-plugin-commentbox/CommentBox';
const name = 'coral-plugin-replies';
const ReplyBox = ({styles, postItem, assetId, authorId, addNotification, parentId, commentPostedHandler}) => (
const ReplyBox = ({styles, postItem, assetId, authorId, addNotification, parentId, commentPostedHandler, setActiveReplyBox}) => (
<div className={`${name}-textarea`} style={styles && styles.container}>
<CommentBox
commentPostedHandler={commentPostedHandler}
parentId={parentId}
cancelButtonClicked={setActiveReplyBox}
addNotification={addNotification}
authorId={authorId}
assetId={assetId}
@@ -17,6 +18,7 @@ const ReplyBox = ({styles, postItem, assetId, authorId, addNotification, parentI
);
ReplyBox.propTypes = {
setActiveReplyBox: PropTypes.func.isRequired,
commentPostedHandler: PropTypes.func,
parentId: PropTypes.string,
addNotification: PropTypes.func.isRequired,
+1
View File
@@ -73,6 +73,7 @@ query AssetQuery($asset_id: ID!) {
id
title
url
commentCount
comments {
...commentView
replies {
@@ -7,7 +7,7 @@ export default ({userData}) => (
{
// Hiding display of users ID unless there's a use case for it.
// Hiding display of users ID unless there's a use case for it.
// <h2>{userData.profiles.map(profile => profile.id)}</h2>
}
</div>
@@ -1,17 +1,18 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {saveBio, fetchCommentsByUserId} from 'coral-framework/actions/user';
import {link} from 'coral-framework/PymConnection';
import BioContainer from './BioContainer';
import NotLoggedIn from '../components/NotLoggedIn';
import {TabBar, Tab, TabContent} from '../../coral-ui';
import CommentHistory from 'coral-plugin-history/CommentHistory';
import SettingsHeader from '../components/SettingsHeader';
import RestrictedContent from 'coral-framework/components/RestrictedContent';
import {compose} from 'react-apollo';
import React, {Component} from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import {myCommentHistory} from 'coral-framework/graphql/queries';
import {saveBio} from 'coral-framework/actions/user';
import BioContainer from './BioContainer';
import {link} from 'coral-framework/PymConnection';
import NotLoggedIn from '../components/NotLoggedIn';
import {TabBar, Tab, TabContent, Spinner} from 'coral-ui';
import SettingsHeader from '../components/SettingsHeader';
import CommentHistory from 'coral-plugin-history/CommentHistory';
import translations from '../translations';
const lang = new I18n(translations);
@@ -25,12 +26,6 @@ class SettingsContainer extends Component {
this.handleTabChange = this.handleTabChange.bind(this);
}
componentWillMount () {
// Fetch commentHistory
this.props.fetchCommentsByUserId(this.props.userData.id);
}
handleTabChange(tab) {
this.setState({
activeTab: tab
@@ -38,55 +33,56 @@ class SettingsContainer extends Component {
}
render() {
const {loggedIn, userData, showSignInDialog, items, user} = this.props;
const {loggedIn, userData, asset, showSignInDialog, data, user} = this.props;
const {activeTab} = this.state;
const {me} = this.props.data;
const commentsMostRecentFirst = user
.myComments.map(id => items.comments[id])
.sort(({created_at:a}, {created_at:b}) => {
if (!loggedIn || !me) {
return <NotLoggedIn showSignInDialog={showSignInDialog}/>;
}
// descending order, created_at
// js date strings can be sorted lexigraphically.
const aLessThanB = a < b ? 1 : 0;
return a > b ? -1 : aLessThanB;
});
if (data.loading) {
return <Spinner/>;
}
return (
<RestrictedContent restricted={!loggedIn} restrictedComp={<NotLoggedIn showSignInDialog={showSignInDialog} />}>
<div>
<SettingsHeader {...this.props} />
<TabBar onChange={this.handleTabChange} activeTab={activeTab} cStyle='material'>
<Tab>All Comments ({user.myComments.length})</Tab>
<Tab>Profile Settings</Tab>
<Tab>{lang.t('allComments')} ({user.myComments.length})</Tab>
<Tab>{lang.t('profileSettings')}</Tab>
</TabBar>
<TabContent show={activeTab === 0}>
{
user.myComments.length && user.myAssets.length
? <CommentHistory
comments={commentsMostRecentFirst}
link={link}
assets={user.myAssets.map(id => items.assets[id])} />
: <p>{lang.t('user-no-comment')}</p>
me.comments.length ?
<CommentHistory
comments={me.comments}
asset={asset}
link={link}
/>
:
<p>{lang.t('userNoComment')}</p>
}
</TabContent>
<TabContent show={activeTab === 1}>
<BioContainer bio={userData.settings.bio} handleSave={this.handleSave} {...this.props} />
</TabContent>
</RestrictedContent>
</div>
);
}
}
const mapStateToProps = state => ({
items: state.items.toJS(),
user: state.user.toJS()
user: state.user.toJS(),
asset: state.asset.toJS(),
auth: state.auth.toJS()
});
const mapDispatchToProps = dispatch => ({
saveBio: (user_id, formData) => dispatch(saveBio(user_id, formData)),
fetchCommentsByUserId: userId => dispatch(fetchCommentsByUserId(userId))
saveBio: (user_id, formData) => dispatch(saveBio(user_id, formData))
});
export default connect(
mapStateToProps,
mapDispatchToProps
export default compose(
connect(mapStateToProps, mapDispatchToProps),
myCommentHistory
)(SettingsContainer);
+8 -2
View File
@@ -1,8 +1,14 @@
{
"en":{
"user-no-comment": "This user has not yet left a comment."
"userNoComment": "This user has not yet left a comment.",
"allComments": "All Comments",
"profileSettings": "Profile Settings",
"myCommentHistory": "My comment History"
},
"es":{
"user-no-comment": "Aún no ha escrito ningún comentario."
"userNoComment": "Aún no ha escrito ningún comentario.",
"allComments": "Todos los comentarios",
"profileSettings": "Configuración del perfil",
"myCommentHistory": "Mi historial de comentarios"
}
}
@@ -12,7 +12,7 @@ const SignDialog = ({open, view, handleClose, offset, ...props}) => (
id="signInDialog"
open={open}
style={{
position: 'relative',
position: 'fixed',
top: offset !== 0 && offset
}}>
<span className={styles.close} onClick={handleClose}>×</span>
@@ -20,7 +20,7 @@ import {
invalidForm,
validForm,
checkLogin
} from '../../coral-framework/actions/auth';
} from 'coral-framework/actions/auth';
class SignInContainer extends Component {
initialState = {
+2 -2
View File
@@ -1,9 +1,9 @@
import React from 'react';
import styles from './Checkbox.css';
export default ({name, cStyle = 'base', onChange, label, className, info, checked = 'false'}) => (
export default ({name, cStyle = 'base', onChange, label, className, info, ...props}) => (
<label className={`${styles.label} ${styles[`type--${cStyle}`]} ${className}`} htmlFor={name}>
<input type="checkbox" id={name} name={name} onChange={onChange} checked={checked} />
<input type="checkbox" id={name} name={name} onChange={onChange} {...props} />
<span className={styles.checkbox}></span>
{label && <span>{label}</span>}
{info && (
+8
View File
@@ -821,6 +821,14 @@ definitions:
current_user:
type: object
description: Will include the action performed by the currently logged in user if that user has taken an action on this item. Otherwise will return null.
created_at:
type: string
format: date-time
description: Oldest created_at of actions in this set.
updated_at:
type: string
format: date-time
description: Newest updated_at of actions in this set.
Action:
type: object
description: A single action taken by a user.
+5
View File
@@ -110,10 +110,15 @@ const ErrNotAuthorized = new APIError('not authorized', {
status: 401
});
// ErrSettingsNotInit is returned when the settings are required but not
// initialized.
const ErrSettingsNotInit = new Error('settings not initialized, run `./bin/cli setup` to setup the application first');
module.exports = {
ExtendableError,
APIError,
ErrPasswordTooShort,
ErrSettingsNotInit,
ErrMissingEmail,
ErrMissingPassword,
ErrMissingToken,
+14 -1
View File
@@ -3,6 +3,7 @@ const DataLoader = require('dataloader');
const util = require('./util');
const ActionsService = require('../../services/actions');
const ActionModel = require('../../models/action');
/**
* Looks up actions based on the requested id's all bounded by the user.
@@ -16,6 +17,17 @@ const genActionSummariessByItemID = ({user = {}}, item_ids) => {
.then(util.arrayJoinBy(item_ids, 'item_id'));
};
/**
* Search for actions based on their action_type and item_type and ensures that
* the actions returned have unique item id's.
* @param {String} action_type the action to search by
* @param {String} item_type the item id to search by
* @return {Promise} resolves to distinct items actions
*/
const getItemIdsByActionTypeAndItemType = (_, action_type, item_type) => {
return ActionModel.distinct('item_id', {action_type, item_type});
};
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
@@ -23,6 +35,7 @@ const genActionSummariessByItemID = ({user = {}}, item_ids) => {
*/
module.exports = (context) => ({
Actions: {
getByItemID: new DataLoader((ids) => genActionSummariessByItemID(context, ids)),
getSummariesByItemID: new DataLoader((ids) => genActionSummariessByItemID(context, ids)),
getByTypes: ({action_type, item_type}) => getItemIdsByActionTypeAndItemType(context, action_type, item_type)
}
});
+110 -63
View File
@@ -1,85 +1,134 @@
const DataLoader = require('dataloader');
const util = require('./util');
const ActionModel = require('../../models/action');
const CommentModel = require('../../models/comment');
const CommentsService = require('../../services/comments');
/**
* Retrieves comments by an array of asset id's, results are returned in reverse
* chronological order.
* @param {Array} ids array of ids to lookup
* Returns the comment count for all comments that are public based on their
* asset ids.
* @param {Object} context graph context
* @param {Array<String>} asset_ids the ids of assets for which there are
* comments that we want to get
*/
const genCommentsByAssetID = (context, ids) => {
return CommentModel.find({
asset_id: {
$in: ids
const getCountsByAssetID = (context, asset_ids) => {
return CommentModel.aggregate([
{
$match: {
asset_id: {
$in: asset_ids
},
status: {
$in: [null, 'ACCEPTED']
},
parent_id: null
}
},
parent_id: null,
status: {
$in: [null, 'ACCEPTED']
{
$group: {
_id: '$asset_id',
count: {
$sum: 1
}
}
}
})
.sort({created_at: -1})
.then(util.arrayJoinBy(ids, 'asset_id'));
])
.then(util.singleJoinBy(asset_ids, '_id'))
.then((results) => results.map((result) => result ? result.count : 0));
};
/**
* Retrieves comments by an array of parent ids, results are returned in
* chronological order.
* @param {Array} ids array of ids to lookup
* Returns the comment count for all comments that are public based on their
* parent ids.
* @param {Object} context graph context
* @param {Array<String>} parent_ids the ids of parents for which there are
* comments that we want to get
*/
const genCommentsByParentID = (context, ids) => {
return CommentModel.find({
parent_id: {
$in: ids
const getCountsByParentID = (context, parent_ids) => {
return CommentModel.aggregate([
{
$match: {
parent_id: {
$in: parent_ids
},
status: {
$in: [null, 'ACCEPTED']
}
}
},
status: {
$in: [null, 'ACCEPTED']
{
$group: {
_id: '$parent_id',
count: {
$sum: 1
}
}
}
})
.sort({created_at: 1})
.then(util.arrayJoinBy(ids, 'parent_id'));
])
.then(util.singleJoinBy(parent_ids, '_id'))
.then((results) => results.map((result) => result ? result.count : 0));
};
const getCommentsByStatusAndAssetID = (context, {status = null, asset_id = null}) => {
/**
* Retrieves comments based on the passed in query that is filtered by the
* current used passed in via the context.
* @param {Object} context graph context
* @param {Object} query query terms to apply to the comments query
*/
const getCommentsByQuery = ({user}, {ids, statuses, asset_id, parent_id, limit, cursor, sort}) => {
let comments = CommentModel.find();
// TODO: remove when we move the enum over to the uppercase.
if (status) {
status = status.toLowerCase();
// Only administrators can search for comments with statuses that are not
// `null`, or `'ACCEPTED'`.
if (user != null && user.hasRoles('ADMIN') && statuses) {
comments = comments.where({
status: {
$in: statuses
}
});
} else {
comments = comments.where({
status: {
$in: [null, 'ACCEPTED']
}
});
}
return CommentsService.moderationQueue(status, asset_id);
};
const getCommentsByActionTypeAndAssetID = (context, {action_type, asset_id = null}) => {
return ActionModel.find({
action_type,
item_type: 'COMMENTS'
}).then((actions) => {
let comments = CommentModel.find({
if (ids) {
comments = comments.find({
id: {
$in: actions.map((action) => action.item_id)
$in: ids
}
}).sort({created_at: 1});
});
}
if (asset_id) {
comments = comments.where({asset_id});
if (asset_id) {
comments = comments.where({asset_id});
}
// We perform the undefined check because, null, is a valid state for the
// search to be with, which indicates that it is at depth 0.
if (parent_id !== undefined) {
comments = comments.where({parent_id});
}
if (cursor) {
if (sort === 'REVERSE_CHRONOLOGICAL') {
comments = comments.where({
created_at: {
$lt: cursor
}
});
} else {
comments = comments.where({
created_at: {
$gt: cursor
}
});
}
}
return comments;
});
};
const genCommentsByAuthorID = (context, authorIDs) => {
return CommentModel.find({
author_id: {
$in: authorIDs
}
})
.sort({created_at: -1})
.then(util.arrayJoinBy(authorIDs, 'author_id'));
return comments
.sort({created_at: sort === 'REVERSE_CHRONOLOGICAL' ? -1 : 1})
.limit(limit);
};
/**
@@ -89,10 +138,8 @@ const genCommentsByAuthorID = (context, authorIDs) => {
*/
module.exports = (context) => ({
Comments: {
getByParentID: new DataLoader((ids) => genCommentsByParentID(context, ids)),
getByAssetID: new DataLoader((ids) => genCommentsByAssetID(context, ids)),
getByStatusAndAssetID: (query) => getCommentsByStatusAndAssetID(context, query),
getByActionTypeAndAssetID: (query) => getCommentsByActionTypeAndAssetID(context, query),
getByAuthorID: new DataLoader((authorIDs) => genCommentsByAuthorID(context, authorIDs))
getByQuery: (query) => getCommentsByQuery(context, query),
countByAssetID: new util.SharedCacheDataLoader('Comments.countByAssetID', 3600, (ids) => getCountsByAssetID(context, ids)),
countByParentID: new util.SharedCacheDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids))
}
});
+72 -1
View File
@@ -1,4 +1,6 @@
const _ = require('lodash');
const DataLoader = require('dataloader');
const cache = require('../../services/cache');
/**
* SingletonResolver is a cached loader for a single result.
@@ -34,6 +36,7 @@ class SingletonResolver {
*/
const arrayJoinBy = (ids, key) => (items) => {
const itemsByKey = _.groupBy(items, key);
return ids.map((id) => {
if (id in itemsByKey) {
return itemsByKey[id];
@@ -61,8 +64,76 @@ const singleJoinBy = (ids, key) => (items) => {
});
};
/**
* SharedCacheDataLoader provides a version of the DataLoader that wraps up a
* redis backed cache with the dataloader's request cache.
*/
class SharedCacheDataLoader extends DataLoader {
constructor(prefix, expiry, batchLoadFn, options) {
super(SharedCacheDataLoader.batchLoadFn(prefix, expiry, batchLoadFn), options);
this._prefix = prefix;
this._expiry = expiry;
this._keyFunc = SharedCacheDataLoader.keyFunc(this._prefix);
}
/**
* clear the key from the shared cache and the request cache
*/
clear(key) {
return cache
.invalidate(key, this._keyFunc)
.then(() => super.clear(key));
}
/**
* prime the shared cache and the request cache
*/
prime(key, value) {
return cache
.set(key, value, this._expiry, this._keyFunc)
.then(() => super.prime(key, value));
}
/**
* prime many values in the shared cache and the request cache
*/
primeMany(keys, values) {
return cache
.setMany(keys, values, this._expiry, this._keyFunc)
.then(() => keys.map((key, i) => super.prime(key, values[i])));
}
/**
* wraps up the prefix needed for the redis backed shared cache driver
*/
static keyFunc(prefix) {
return (key) => `cache.sbl[${prefix}][${key}]`;
}
/**
* wraps the dataloader batchLoadFn with the shared cache's wrapper
*/
static batchLoadFn(prefix, expiry, batchLoadFn) {
return (ids) => cache.wrapMany(ids, expiry, (workKeys) => {
return batchLoadFn(workKeys);
}, SharedCacheDataLoader.keyFunc(prefix));
}
}
/**
* Maps an object's paths to a string that can be used as a cache key.
* @param {Array} paths paths on the object to be used to generate the cache
* key
*/
const objectCacheKeyFn = (...paths) => (obj) => {
return paths.map((path) => obj[path]).join(':');
};
module.exports = {
singleJoinBy,
arrayJoinBy,
SingletonResolver
objectCacheKeyFn,
SingletonResolver,
SharedCacheDataLoader
};
+11 -1
View File
@@ -1,8 +1,10 @@
const ActionModel = require('../../models/action');
const ActionsService = require('../../services/actions');
const UsersService = require('../../services/users');
/**
* Creates an action on a item.
* Creates an action on a item. If the item is a user flag, sets the user's status to
* pending.
* @param {Object} user the user performing the request
* @param {String} item_id id of the item to add the action to
* @param {String} item_type type of the item
@@ -16,6 +18,14 @@ const createAction = ({user = {}}, {item_id, item_type, action_type, metadata =
user_id: user.id,
action_type,
metadata
}).then((action) => {
if (item_type === 'USERS' && action_type === 'FLAG') {
return UsersService
.setStatus(item_id, 'PENDING')
.then(() => action);
}
return action;
});
};
+20 -3
View File
@@ -14,13 +14,29 @@ const Wordlist = require('../../services/wordlist');
* @param {String} [status=null] the status of the new comment
* @return {Promise} resolves to the created comment
*/
const createComment = ({user}, {body, asset_id, parent_id = null}, status = null) => {
const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null}, status = null) => {
return CommentsService.publicCreate({
body,
asset_id,
parent_id,
status,
author_id: user.id
})
.then((comment) => {
// TODO: explore using an `INCR` operation to update the counts here
// If the loaders are present, clear the caches for these values because we
// just added a new comment, hence the counts should be updated.
if (Comments && Comments.countByAssetID && Comments.countByParentID) {
if (parent_id != null) {
Comments.countByParentID.clear(parent_id);
} else {
Comments.countByAssetID.clear(asset_id);
}
}
return comment;
});
};
@@ -121,7 +137,7 @@ const createPublicComment = (context, commentInput) => {
if (wordlist != null) {
// TODO: this is kind of fragile, we should refactor this to resolve
// all these const's that we're using like 'comments', 'flag' to be
// all these const's that we're using like 'COMMENTS', 'FLAG' to be
// defined in a checkable schema.
return context.mutators.Action.createAction(null, {
item_id: comment.id,
@@ -131,7 +147,8 @@ const createPublicComment = (context, commentInput) => {
field: 'body',
details: 'Matched suspect word filters.'
}
}).then(() => comment);
})
.then(() => comment);
}
// Finally, we return the comment.
+4 -4
View File
@@ -1,10 +1,10 @@
const Action = {
// This will load the user for the specific action. We'll limit this to the
// admin users only.
user({user_id}, _, {loaders, user}) {
if (user.hasRole('ADMIN')) {
return loaders.Users.getByID.load(user_id);
// admin users only or the current logged in user.
user({user_id}, _, {loaders: {Users}, user}) {
if (user && (user.hasRole('ADMIN') || user_id === user.id)) {
return Users.getByID.load(user_id);
}
}
};
+12 -4
View File
@@ -1,9 +1,17 @@
const Asset = {
comments({id}, _, {loaders}) {
return loaders.Comments.getByAssetID.load(id);
comments({id}, {sort, limit}, {loaders: {Comments}}) {
return Comments.getByQuery({
asset_id: id,
sort,
limit,
parent_id: null
});
},
settings({settings = null}, _, {loaders}) {
return loaders.Settings.load()
commentCount({id}, _, {loaders: {Comments}}) {
return Comments.countByAssetID.load(id);
},
settings({settings = null}, _, {loaders: {Settings}}) {
return Settings.load()
.then((globalSettings) => {
if (settings) {
settings = Object.assign({}, globalSettings.toObject(), settings);
+16 -8
View File
@@ -1,15 +1,23 @@
const Comment = {
user({author_id}, _, {loaders}) {
return loaders.Users.getByID.load(author_id);
user({author_id}, _, {loaders: {Users}}) {
return Users.getByID.load(author_id);
},
replies({id}, _, {loaders}) {
return loaders.Comments.getByParentID.load(id);
replies({id, asset_id}, {sort, limit}, {loaders: {Comments}}) {
return Comments.getByQuery({
asset_id,
parent_id: id,
sort,
limit
});
},
actions({id}, _, {loaders}) {
return loaders.Actions.getByItemID.load(id);
replyCount({id}, _, {loaders: {Comments}}) {
return Comments.countByParentID.load(id);
},
asset({asset_id}, _, {loaders}) {
return loaders.Assets.getByID.load(asset_id);
actions({id}, _, {loaders: {Actions}}) {
return Actions.getSummariesByItemID.load(id);
},
asset({asset_id}, _, {loaders: {Assets}}) {
return Assets.getByID.load(asset_id);
}
};
+27
View File
@@ -0,0 +1,27 @@
const GraphQLScalarType = require('graphql').GraphQLScalarType;
const Kind = require('graphql/language').Kind;
module.exports = new GraphQLScalarType({
name: 'Date',
description: 'Date represented as an ISO8601 string',
serialize(value) {
return value.toISOString();
},
parseValue(value) {
return new Date(value);
},
parseLiteral(ast) {
switch (ast.kind) {
case Kind.STRING:
// This handles an empty string.
if (ast.value && ast.value.length === 0) {
return null;
}
return new Date(ast.value);
default:
return null;
}
}
});
+2
View File
@@ -2,6 +2,7 @@ const Action = require('./action');
const ActionSummary = require('./action_summary');
const Asset = require('./asset');
const Comment = require('./comment');
const Date = require('./date');
const RootMutation = require('./root_mutation');
const RootQuery = require('./root_query');
const Settings = require('./settings');
@@ -12,6 +13,7 @@ module.exports = {
ActionSummary,
Asset,
Comment,
Date,
RootMutation,
RootQuery,
Settings,
+8 -8
View File
@@ -1,15 +1,15 @@
const RootMutation = {
createComment(_, {asset_id, parent_id, body}, {mutators}) {
return mutators.Comment.create({asset_id, parent_id, body});
createComment(_, {asset_id, parent_id, body}, {mutators: {Comment}}) {
return Comment.create({asset_id, parent_id, body});
},
createAction(_, {action}, {mutators}) {
return mutators.Action.create(action);
createAction(_, {action}, {mutators: {Action}}) {
return Action.create(action);
},
deleteAction(_, {id}, {mutators}) {
return mutators.Action.delete({id});
deleteAction(_, {id}, {mutators: {Action}}) {
return Action.delete({id});
},
updateUserSettings(_, {settings}, {mutators}) {
return mutators.User.updateSettings(settings);
updateUserSettings(_, {settings}, {mutators: {User}}) {
return User.updateSettings(settings);
}
};
+23 -15
View File
@@ -1,39 +1,47 @@
const RootQuery = {
assets(_, args, {loaders, user}) {
assets(_, args, {loaders: {Assets}, user}) {
if (user == null || !user.hasRoles('ADMIN')) {
return null;
}
return loaders.Assets.getAll.load();
return Assets.getAll.load();
},
asset(_, query, {loaders}) {
asset(_, query, {loaders: {Assets}}) {
if (query.id) {
// TODO: we may not always have a comment stream here, therefore, when we
// load it, we may also need to create with the url. This may also have to
// move the logic over to the mutators function as an upsert operation
// possibly.
return loaders.Assets.getByID.load(query.id);
return Assets.getByID.load(query.id);
}
return loaders.Assets.getByURL(query.url);
return Assets.getByURL(query.url);
},
settings(_, args, {loaders}) {
return loaders.Settings.load();
settings(_, args, {loaders: {Settings}}) {
return Settings.load();
},
// This endpoint is used for loading moderation queues, so hide it in the
// event that we aren't an admin.
comments(_, {query}, {loaders, user}) {
if (user == null || !user.hasRoles('ADMIN')) {
return null;
comments(_, {query: {action_type, statuses, asset_id, parent_id, limit, cursor, sort}}, {user, loaders: {Comments, Actions}}) {
let query = {statuses, asset_id, parent_id, limit, cursor, sort};
if (user != null && user.hasRoles('ADMIN') && action_type) {
return Actions.getByTypes({action_type, item_type: 'COMMENTS'})
.then((actions) => {
// Map the actions from the items referenced byt this query. The actions
// returned by this query are explicitly going to be distinct by their
// `item_id`'s.
let ids = actions.map((action) => action.item_id);
// Perform the query using the available resolver.
return Comments.getByQuery({ids, statuses, asset_id, parent_id, limit, cursor, sort});
});
}
if (query.action_type) {
return loaders.Comments.getByActionTypeAndAssetID(query);
} else {
return loaders.Comments.getByStatusAndAssetID(query);
}
return Comments.getByQuery(query);
},
// This returns the current user, ensure that if we aren't logged in, we
+15 -6
View File
@@ -1,16 +1,25 @@
const User = {
actions({id}, _, {loaders}) {
return loaders.Actions.getByID.load(id);
actions({id}, _, {loaders: {Actions}}) {
return Actions.getSummariesByItemID.load(id);
},
comments({id}, _, {loaders, user}) {
comments({id}, _, {loaders: {Comments}, user}) {
// If the user is not an admin, only return comment list for the owner of
// the comments.
if (!user.hasRoles('ADMIN') || user.id !== id) {
return null;
if (user && (user.hasRoles('ADMIN') || user.id === id)) {
return Comments.getByAuthorID.load(id);
}
return loaders.Comments.getByAuthorID.load(id);
return null;
},
roles({id, roles}, _, {user}) {
// If the user is not an admin, only return the current user's roles.
if (user && (user.hasRoles('ADMIN') || user.id === id)) {
return roles;
}
return null;
}
};
+68 -15
View File
@@ -1,21 +1,50 @@
interface ActionableItem {
id: ID!
# Establishes the ordering of the content by their created_at time stamp.
enum SORT_ORDER {
# newest to oldest order.
REVERSE_CHRONOLOGICAL
# oldest to newer order.
CHRONOLOGICAL
}
# Date represented as an ISO8601 string.
scalar Date
type UserSettings {
# bio of the user.
bio: String
}
input CommentsInput {
input CommentsQuery {
# current status of a comment.
status: COMMENT_STATUS
statuses: [COMMENT_STATUS]
# asset that a comment is on.
asset_id: ID
# action type to find comments that have an action with.
# the parent of the comment that we want to retrive.
parent_id: ID
# comments returned will only be ones which have at least one action of this
# type.
action_type: ACTION_TYPE
# limit the number of results to be returned.
limit: Int = 10
# skip results from the last created_at timestamp.
cursor: Date
# sort the results by created_at.
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
}
enum USER_ROLES {
# an administrator of the site
ADMIN
# a moderator of the site
MODERATOR
}
# Any person who can author comments, create actions, and view comments on a
@@ -29,11 +58,14 @@ type User {
# actions against a specific user.
actions: [ActionSummary]
# the current roles of the user.
roles: [USER_ROLES]
# settings for a user.
settings: UserSettings
# returns all comments based on a query.
comments(query: CommentsInput): [Comment]
comments(query: CommentsQuery): [Comment]
}
type Comment {
@@ -46,7 +78,10 @@ type Comment {
user: User
# the replies that were made to the comment.
replies(limit: Int = 3): [Comment]
replies(sort: SORT_ORDER = CHRONOLOGICAL, limit: Int = 3): [Comment]
# the count of replies on a comment
replyCount: Int
# the actions made against a comment.
actions: [ActionSummary]
@@ -58,7 +93,7 @@ type Comment {
status: COMMENT_STATUS
# the time when the comment was created
created_at: String!
created_at: Date!
}
enum ITEM_TYPE {
@@ -78,11 +113,10 @@ type Action {
item_id: ID!
item_type: ITEM_TYPE!
item: ActionableItem
user: User!
updated_at: String
created_at: String
updated_at: Date
created_at: Date
}
type ActionSummary {
@@ -109,13 +143,31 @@ type Settings {
}
type Asset {
# The current ID of the asset.
id: ID!
# The scraped title of the asset.
title: String
# The URL that the asset is locaed on.
url: String
comments: [Comment]
# The top level comments that are attached to the asset.
comments(sort: SORT_ORDER = REVERSE_CHRONOLOGICAL, limit: Int = 10): [Comment]
# The count of top level comments on the asset.
commentCount: Int
# The settings (rectified with the global settings) that should be applied to
# this asset.
settings: Settings!
closedAt: String
created_at: String
# The date that the asset was closed at.
closedAt: Date
# The date that the asset was created.
created_at: Date
}
enum COMMENT_STATUS {
@@ -125,6 +177,7 @@ enum COMMENT_STATUS {
}
type RootQuery {
# retrieves site wide settings and defaults.
settings: Settings
@@ -135,7 +188,7 @@ type RootQuery {
asset(id: ID, url: String): Asset
# retrieves comments based on the input query.
comments(query: CommentsInput): [Comment]
comments(query: CommentsQuery!): [Comment]
# retrieves the current logged in user.
me: User
-13
View File
@@ -1,13 +0,0 @@
const SettingsService = require('./services/settings');
module.exports = () => Promise.all([
// Upsert the settings object.
SettingsService.init({
moderation: 'PRE',
wordlist: {
banned: [],
suspect: []
}
})
]);
+20 -12
View File
@@ -1,12 +1,10 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
const WordlistSchema = new Schema({
banned: [String],
suspect: [String]
}, {
_id: false
});
const MODERATION_OPTIONS = [
'PRE',
'POST'
];
/**
* SettingSchema manages application settings that get used on front and backend.
@@ -19,11 +17,8 @@ const SettingSchema = new Schema({
},
moderation: {
type: String,
enum: [
'PRE',
'POST'
],
default: 'PRE'
enum: MODERATION_OPTIONS,
default: 'POST'
},
infoBoxEnable: {
type: Boolean,
@@ -33,6 +28,9 @@ const SettingSchema = new Schema({
type: String,
default: ''
},
organizationName: {
type: String
},
closedTimeout: {
type: Number,
@@ -43,7 +41,16 @@ const SettingSchema = new Schema({
type: String,
default: 'Expired'
},
wordlist: WordlistSchema,
wordlist: {
banned: {
type: Array,
default: []
},
suspect: {
type: Array,
default: []
}
},
charCount: {
type: Number,
default: 5000
@@ -87,3 +94,4 @@ SettingSchema.method('merge', function(src) {
const Setting = mongoose.model('Setting', SettingSchema);
module.exports = Setting;
module.exports.MODERATION_OPTIONS = MODERATION_OPTIONS;
+3 -1
View File
@@ -10,7 +10,9 @@ const USER_ROLES = [
// USER_STATUS is the list of statuses that are permitted for the user status.
const USER_STATUS = [
'ACTIVE',
'BANNED'
'BANNED',
'PENDING',
'SUSPENDED',
];
// ProfileSchema is the mongoose schema defined as the representation of a
+11 -10
View File
@@ -1,16 +1,21 @@
require('babel-core/register');
let E2E_REPORT_PATH = './test/e2e/reports';
if (process.env.E2E_REPORT_PATH && process.env.E2E_REPORT_PATH.length > 0) {
E2E_REPORT_PATH = process.env.E2E_REPORT_PATH;
}
module.exports = {
'src_folders': './tests/e2e/tests',
'output_folder': './tests/e2e/reports',
'page_objects_path': './tests/e2e/pages',
'globals_path': './tests/e2e/globals',
'src_folders': './test/e2e/tests',
'output_folder': E2E_REPORT_PATH,
'page_objects_path': './test/e2e/pages',
'globals_path': './test/e2e/globals',
'custom_commands_path' : '',
'custom_assertions_path' : '',
'selenium': {
'start_process': true,
'server_path': 'node_modules/selenium-standalone/.selenium/selenium-server/2.53.1-server.jar',
'log_path': './tests/e2e/reports',
'log_path': E2E_REPORT_PATH,
'host': '127.0.0.1',
'port': 6666,
'cli_args': {
@@ -36,7 +41,7 @@ module.exports = {
'enabled': true,
'on_failure': true,
'on_error': true,
'path': './tests/e2e/reports'
'path': E2E_REPORT_PATH
},
'exclude': [
'./tests/e2e/tests/EmbedStreamTests.js'
@@ -47,7 +52,3 @@ module.exports = {
}
}
};
// "chromeOptions" : {
// "args" : ["start-fullscreen"]
// }
+4 -2
View File
@@ -14,6 +14,7 @@
"test-cover": "TEST_MODE=unit NODE_ENV=test istanbul cover _mocha --report text --check-coverage -- -R spec",
"pree2e": "NODE_ENV=test scripts/pree2e.sh",
"e2e": "NODE_ENV=test nightwatch",
"poste2e": "NODE_ENV=test scripts/poste2e.sh",
"embed-start": "NODE_ENV=development npm run build && ./bin/cli serve --jobs",
"postinstall": "npm run build"
},
@@ -67,10 +68,12 @@
"graphql-tag": "^1.2.3",
"graphql-tools": "^0.9.0",
"helmet": "^3.1.0",
"inquirer": "^3.0.1",
"jsonwebtoken": "^7.1.9",
"kue": "^0.11.5",
"lodash": "^4.16.6",
"metascraper": "^1.0.6",
"minimist": "^1.2.0",
"mongoose": "^4.6.5",
"morgan": "^1.7.0",
"natural": "^0.4.0",
@@ -79,7 +82,6 @@
"passport": "^0.3.2",
"passport-facebook": "^2.1.1",
"passport-local": "^1.0.0",
"prompt": "^1.0.0",
"react-apollo": "^0.8.1",
"redis": "^2.6.3",
"uuid": "^2.0.3"
@@ -153,7 +155,7 @@
"redux-mock-store": "^1.2.1",
"redux-thunk": "^2.1.0",
"regenerator": "^0.8.46",
"selenium-standalone": "latest",
"selenium-standalone": "^5.11.2",
"style-loader": "^0.13.1",
"supertest": "^2.0.1",
"timeago.js": "^2.0.3",
+20
View File
@@ -78,4 +78,24 @@ router.get('/comments/flagged', authorization.needed('ADMIN'), (req, res, next)
});
});
// Returns back all the users that are in the moderation queue.
router.get('/users/pending', (req, res, next) => {
UsersService.moderationQueue()
.then((users) => {
return Promise.all([
users,
ActionsService.getActionSummaries(users.map((user) => user.id))
]);
})
.then(([users, actions]) => {
res.json({
users,
actions
});
})
.catch(error => {
next(error);
});
});
module.exports = router;
+42 -5
View File
@@ -58,10 +58,37 @@ router.post('/:user_id/status', authorization.needed('ADMIN'), (req, res, next)
});
router.post('/:user_id/displayname', authorization.needed(), (req, res, next) => {
UsersService
.setDisplayName(req.params.user_id, req.body.displayName)
.then((status) => {
res.status(201).json(status);
UsersService.setDisplayName(req.params.user_id, req.body.displayName)
.then(() => {
res.status(204).end();
})
.catch(next);
});
router.post('/:user_id/email', authorization.needed('admin'), (req, res, next) => {
UsersService.findById(req.params.user_id)
.then(user => {
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
if (localProfile) {
const options =
{
app: req.app, // needed to render the templates.
template: 'email/notification', // needed to know which template to render!
locals: { // specifies the template locals.
body: req.body.body
},
subject: req.body.subject,
to: localProfile.id // This only works if the user has registered via e-mail.
// We may want a standard way to access a user's e-mail address in the future
};
return mailer.sendSimple(options);
} else {
res.json({error: 'User does not have an e-mail address.'});
}
})
.then(() => {
res.status(204).end();
})
.catch(next);
});
@@ -131,7 +158,6 @@ router.post('/', (req, res, next) => {
});
router.post('/:user_id/actions', authorization.needed(), (req, res, next) => {
const {
action_type,
metadata
@@ -139,10 +165,21 @@ router.post('/:user_id/actions', authorization.needed(), (req, res, next) => {
UsersService
.addAction(req.params.user_id, req.user.id, action_type, metadata)
.then((action) => {
// Set the user status to "pending" for review by moderators
if (action_type.slice(0, 4) === 'FLAG') {
return UsersService.setStatus(req.user.id, 'PENDING')
.then(() => action);
} else {
return action;
}
})
.then((action) => {
res.status(201).json(action);
})
.catch((err) => {
console.log('Error', err);
next(err);
});
});
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# If there is a PID file from the e2e tests...
if [ -e /tmp/talk-e2e.pid ]
then
# Then kill the running talk server.
kill $(cat /tmp/talk-e2e.pid)
fi
+9 -5
View File
@@ -1,15 +1,19 @@
#!/bin/bash
# install selenium
selenium-standalone install
selenium-standalone install --config=./selenium.config.js
# Init application
./bin/cli setup --defaults
# Creating Admin Test User
./bin/cli-users create --flag_mode --email "admin@test.com" --password "test" --name "Admin Test User" --role "admin"
./bin/cli users create --flag_mode --email "admin@test.com" --password "testtest" --name "AdminTestUser" --role "ADMIN"
# Creating Moderator Test User
./bin/cli-users create --flag_mode --email "moderator@test.com" --password "test" --name "Moderator Test User" --role "moderator"
./bin/cli users create --flag_mode --email "moderator@test.com" --password "testtest" --name "ModeratorTestUser" --role "MODERATOR"
# Creating Commenter Test User
./bin/cli-users create --flag_mode --email "commenter@test.com" --password "test" --name "commenter@test.com"
./bin/cli users create --flag_mode --email "commenter@test.com" --password "testtest" --name "CommenterTestUser"
npm start &
# Start the server and write the PID to a file to be killed later.
./bin/cli --pid /tmp/talk-e2e.pid serve --jobs &
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
drivers: {
chrome: {
version: '2.25',
arch: process.arch,
baseURL: 'https://chromedriver.storage.googleapis.com'
},
},
};
+153 -13
View File
@@ -1,4 +1,5 @@
const redis = require('./redis');
const debug = require('debug')('talk:cache');
const cache = module.exports = {
client: redis.createClient()
@@ -29,31 +30,103 @@ const keyfunc = (key) => {
* resolved as the value to cache.
* @return {Promise} Resolves to the value either retrieved from cache
*/
cache.wrap = (key, expiry, work) => {
cache.wrap = (key, expiry, work, kf = keyfunc) => {
return cache
.get(key)
.get(key, kf)
.then((value) => {
if (value !== null) {
debug('wrap: hit', kf(key));
return value;
}
debug('wrap: miss', kf(key));
return work()
.then((value) => {
return cache
.set(key, value, expiry)
.then(() => value);
process.nextTick(() => {
cache
.set(key, value, expiry, kf)
.then(() => {
debug('wrap: set complete');
})
.catch((err) => {
console.error(err);
});
});
return value;
});
});
};
/**
* [wrapMany description]
* @param {Array<String>} keys Either an array of objects represening
* this work
* @param {Integer} expiry Time in seconds for the cache entry to live for
* @param {Function} work A function that returns a promise that can be
* resolved as the value to cache.
* @param {Function} [kf=keyfunc] optional key function to use to turn the
* provided key into a string for the cache.
* @return {Promise} resovles to the values for the keys
*/
cache.wrapMany = (keys, expiry, work, kf = keyfunc) => {
return cache
.getMany(keys, kf)
.then((values) => {
// find any of the null valued items by collecting the work
let workRefs = values
.map((value, index) => ({value, index, key: keys[index]}))
.filter(({value}) => value === null);
let workKeys = workRefs.map(({key}) => key);
debug(`wrapMany: hit ratio: ${keys.length - workKeys.length}/${keys.length}`);
if (workKeys.length > 0) {
return work(workKeys)
.then((workedValues) => {
// Set the items in the cache that we needed to retrive after the
// next process tick.
process.nextTick(() => {
cache
.setMany(workKeys, workedValues, expiry, kf)
.then(() => {
debug('wrapMany: setMany complete');
})
.catch((err) => {
console.error(err);
});
});
return workedValues;
})
.then((workedValues) => {
// Walk over the worked keys to merge them with the existing values.
for (let i = 0; i < workRefs.length; i++) {
values[workRefs[i].index] = workedValues[i];
}
return values;
});
} else {
return values;
}
});
};
/**
* This returns a promise that returns a promise that resolves with the value
* from the cache or null if it does not exist in the cache.
* @param {Mixed} key Either an array of items composing a key or a string
* @return {Promise}
*/
cache.get = (key) => new Promise((resolve, reject) => {
cache.client.get(keyfunc(key), (err, reply) => {
cache.get = (key, kf = keyfunc) => new Promise((resolve, reject) => {
cache.client.get(kf(key), (err, reply) => {
if (err) {
return reject(err);
}
@@ -76,13 +149,80 @@ cache.get = (key) => new Promise((resolve, reject) => {
});
});
/**
* Returns many replies.
* @param {Array<String>} keys Either an array of objects represening
* this work
* @param {Function} [kf=keyfunc] optional key function to use to turn the
* provided key into a string for the cache.
*/
cache.getMany = (keys, kf = keyfunc) => new Promise((resolve, reject) => {
cache.client.mget(keys.map(kf), (err, replies) => {
if (err) {
return reject(err);
}
// Parse the replies.
for (let i = 0; i < replies.length; i++) {
let value = null;
if (replies[i] != null) {
try {
// Parse the stored cache value from JSON.
value = JSON.parse(replies[i]);
} catch (e) {
return reject(e);
}
}
replies[i] = value;
}
return resolve(replies);
});
});
/**
* Sets many entries in the cache.
* @param {Array<String>} keys array of keys
* @param {Array} values array of values to set
* @param {Function} [kf=keyfunc] optional key function to use to turn the
* provided key into a string for the cache.
*/
cache.setMany = (keys, values, expiry, kf = keyfunc) => {
let multi = cache.client.multi();
keys.forEach((key, index) => {
// Serialize the value as JSON.
let reply = JSON.stringify(values[index]);
// Queue up the set command.
multi.set(kf(key), reply, 'EX', expiry);
});
return new Promise((resolve, reject) => {
multi.exec((err) => {
if (err) {
return reject(err);
}
resolve();
});
});
};
/**
* This invalidates a cached entry in the cache.
* @param {Mixed} key Either an array of items composing a key or a string
* @return {Promise}
*/
cache.invalidate = (key) => new Promise((resolve, reject) => {
cache.client.del(keyfunc(key), (err) => {
cache.invalidate = (key, kf = keyfunc) => new Promise((resolve, reject) => {
debug(`invalidate: ${kf(key)}`);
cache.client.del(kf(key), (err) => {
if (err) {
return reject(err);
}
@@ -94,17 +234,17 @@ cache.invalidate = (key) => new Promise((resolve, reject) => {
/**
* This sets a value on the key with the expiry and then resolves once it is
* done.
* @param {Mixed} key Either an array of items composing a key or a string
* @param {Mixed} value Object to be serialized and set to the cache
* @param {Mixed} key Either an array of items composing a key or a string
* @param {Mixed} value Object to be serialized and set to the cache
* @param {Integer} expiry Time in seconds for the cache entry to live for
* @return {Promise}
*/
cache.set = (key, value, expiry) => new Promise((resolve, reject) => {
cache.set = (key, value, expiry, kf = keyfunc) => new Promise((resolve, reject) => {
// Serialize the value as JSON.
let reply = JSON.stringify(value);
cache.client.set(keyfunc(key), reply, 'EX', expiry, (err) => {
cache.client.set(kf(key), reply, 'EX', expiry, (err) => {
if (err) {
return reject(err);
}
+17 -9
View File
@@ -1,4 +1,5 @@
const SettingModel = require('../models/setting');
const errors = require('../errors');
/**
* The selector used to uniquely identify the settings document.
@@ -15,7 +16,15 @@ module.exports = class SettingsService {
* @return {Promise} settings the whole settings record
*/
static retrieve() {
return SettingModel.findOne(selector);
return SettingModel
.findOne(selector)
.then((settings) => {
if (!settings) {
return Promise.reject(errors.ErrSettingsNotInit);
}
return settings;
});
}
/**
@@ -35,15 +44,14 @@ module.exports = class SettingsService {
/**
* This is run once when the app starts to ensure settings are populated.
* @return {Promise} null initialize the global settings object
*/
static init(defaults) {
static init(defaults = {}) {
return SettingsService
.retrieve()
.catch(() => {
let settings = new SettingModel(defaults);
// Inject the defaults on top of the passed in defaults to ensure that the new
// settings conform to the required selector.
defaults = Object.assign({}, defaults, selector);
// Actually update the settings collection.
return SettingsService.update(defaults);
return settings.save();
});
}
};
+24 -9
View File
@@ -189,6 +189,18 @@ module.exports = class UsersService {
return Wordlist.displayNameCheck(displayName);
}
static isValidPassword(password) {
if (!password) {
return Promise.reject(errors.ErrMissingPassword);
}
if (password.length < 8) {
return Promise.reject(errors.ErrPasswordTooShort);
}
return Promise.resolve(password);
}
/**
* Creates the local user with a given email, password, and name.
* @param {String} email email of the new user
@@ -205,15 +217,10 @@ module.exports = class UsersService {
email = email.toLowerCase().trim();
displayName = displayName.toLowerCase().trim();
if (!password) {
return Promise.reject(errors.ErrMissingPassword);
}
if (password.length < 8) {
return Promise.reject(errors.ErrPasswordTooShort);
}
return UsersService.isValidDisplayName(displayName)
return Promise.all([
UsersService.isValidDisplayName(displayName),
UsersService.isValidPassword(password)
])
.then(() => { // displayName is valid
return new Promise((resolve, reject) => {
bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => {
@@ -614,4 +621,12 @@ module.exports = class UsersService {
});
}
/**
* Returns all users with pending 'ADMIN'ation actions.
* @return {Promise}
*/
static moderationQueue() {
return UserModel.find({status: 'PENDING'});
}
};
+1
View File
@@ -203,6 +203,7 @@ class Wordlist {
*/
static displayNameCheck(displayName) {
const wl = new Wordlist();
return wl.load()
.then(() => {
displayName = displayName.replace(/_/g, '');
@@ -1,198 +0,0 @@
import 'react';
import 'redux';
import {expect} from 'chai';
import fetchMock from 'fetch-mock';
import * as actions from '../../../../client/coral-framework/actions/items';
import {Map} from 'immutable';
import configureStore from 'redux-mock-store';
const mockStore = configureStore();
describe('itemActions', () => {
let store;
beforeEach(() => {
store = mockStore(new Map({}));
fetchMock.restore();
});
describe('getStream', () => {
const assetUrl = 'http://www.test.com';
const response = {
assets: [{
id: '1234', url: assetUrl
}],
comments: [
{body: 'stuff', id: '123'},
{body: 'morestuff', id: '456'}
],
actions: [
{
action_type: 'like',
item_id: '123',
count: 1,
id: 'like_123',
current_user: false
},
{
action_type: 'flag',
item_id: '456',
count: 5,
id: 'flag_456',
current_user: true
}
]
};
it('should get an stream from an asset_url and send the appropriate dispatches', () => {
fetchMock.get('*', JSON.stringify(response));
return actions.getStream(assetUrl)(store.dispatch)
.then((res) => {
expect(fetchMock.calls().matched[0][0]).to.equal('/api/v1/stream?asset_url=http%3A%2F%2Fwww.test.com');
expect(res).to.deep.equal(response);
expect(store.getActions()[1]).to.deep.equal({
type: actions.ADD_ITEM,
item: response.comments[0],
item_type: 'comments',
id: '123'
});
expect(store.getActions()[2]).to.deep.equal({
type: actions.ADD_ITEM,
item: response.comments[1],
item_type: 'comments',
id: '456'
});
});
});
it('should handle an error', () => {
fetchMock.get('*', 404);
return actions.getStream(assetUrl)(store.dispatch)
.catch((err) => {
expect(err).to.be.truthy;
});
});
});
// Disabling tests for this function until is is used again.
xdescribe('getItemsArray', () => {
const response = {items: [{type: 'comment', id: '123'}, {type: 'comment', id: '456'}]};
const ids = [1, 2];
it('should get an item from an array of ids and send the appropriate dispatches', () => {
fetchMock.get('*', JSON.stringify(response));
return actions.getItemsArray(ids)(store.dispatch)
.then((res) => {
expect(res).to.deep.equal(response.items);
expect(store.getActions()[0]).to.deep.equal({
type: actions.ADD_ITEM,
item: {
type: 'comment',
id: '123'
},
id: '123'
});
expect(store.getActions()[1]).to.deep.equal({
type: actions.ADD_ITEM,
item: {
type: 'comment', id: '456'
},
id: '456'
});
});
});
it('should handle an error', () => {
fetchMock.get('*', 404);
return actions.getItemsArray(ids)(store.dispatch)
.catch((err) => {
expect(err).to.be.truthy;
});
});
});
// NEED TO FIGURE OUT HOW TO TEST WITH CSRF TOKEN IN.
xdescribe('postItem', () => {
const item = {
type: 'comments',
data: {body: 'stuff'}
};
it ('should post an item, return an id, then dispatch that item to the store', () => {
fetchMock.post('*', {id: '123'});
return actions.postItem(item.data, item.type, undefined)(store.dispatch, store.getState)
.then((id) => {
expect(fetchMock.calls().matched[0][1]).to.deep.equal(
{
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type':'application/json'
},
credentials: 'same-origin',
body: JSON.stringify(item.data)
}
);
expect(id).to.deep.equal({id: '123'});
expect(store.getActions()[0]).to.deep.equal({
type: actions.ADD_ITEM,
item: {
body: 'stuff',
id: '123'
},
item_type: 'comments',
id: '123'
});
});
});
it('should handle an error', () => {
fetchMock.post('*', 404);
return actions.postItem(item)(store.dispatch, store.getState)
.catch((err) => {
expect(err).to.be.truthy;
});
});
});
xdescribe('postAction', () => {
it ('should post an action', () => {
fetchMock.post('*', {id: '456'});
const action = {
action_type: 'flag',
detail: 'Comment smells funny'
};
return actions.postAction('abc', 'comments', action)(store.dispatch, store.getState)
.then(response => {
expect(fetchMock.calls().matched[0][0]).to.equal('/api/v1/comments/abc/actions');
expect(response).to.deep.equal({id:'456'});
});
});
it('should handle an error', () => {
fetchMock.post('*', 404);
return actions.postAction('abc', 'flag', '123')(store.dispatch, store.getState)
.catch((err) => {
expect(err).to.be.truthy;
});
});
});
describe('deleteAction', () => {
it ('should remove an action', () => {
fetchMock.delete('*', {});
return actions.deleteAction('abc', 'flag', '123', 'comments')(store.dispatch)
.then(response => {
expect(fetchMock.calls().matched[0][0]).to.equal('/api/v1/actions/abc');
expect(response).to.deep.equal({});
});
});
xit('should handle an error', () => {
fetchMock.post('*', 404);
return actions.postAction('abc', 'flag', '123')(store.dispatch, store.getState)
.catch((err) => {
expect(err).to.be.truthy;
});
});
});
});
@@ -1,95 +0,0 @@
import {Map, fromJS} from 'immutable';
import {expect} from 'chai';
import itemsReducer from '../../../../client/coral-framework/reducers/items';
describe ('itemsReducer', () => {
describe('ADD_ITEM', () => {
it('should add an item', () => {
const action = {
type: 'ADD_ITEM',
item: {
body: 'stuff',
id: '123'
},
item_type: 'comments',
id: '123'
};
const store = new Map({});
const result = itemsReducer(store, action);
expect(result.getIn(['comments', '123']).toJS()).to.deep.equal({
body: 'stuff',
id: '123'
});
});
});
describe ('UPDATE_ITEM', () => {
it ('should update an item', () => {
const action = {
type: 'UPDATE_ITEM',
property: 'stuff',
value: 'things',
item_type: 'comments',
id: '123'
};
const store = fromJS({
'comments': {
'123': {
id: '123',
stuff: 'morestuff'
}
}
});
const result = itemsReducer(store, action);
expect(result.getIn(['comments', '123']).toJS()).to.deep.equal({
id: '123',
stuff: 'things'
});
});
});
describe('APPEND_ITEM_ARRAY', () => {
let action;
let store;
beforeEach (() => {
action = {
type: 'APPEND_ITEM_ARRAY',
property: 'stuff',
value: 'things',
id: '123',
item_type: 'comments'
};
store = fromJS({
'comments': {
'123': {
id: '123',
stuff: ['morestuff']
}
}
});
});
it ('should append to an existing array', () => {
const result = itemsReducer(store, action);
expect(result.getIn(['comments', '123']).toJS()).to.deep.equal({
id: '123',
stuff: ['morestuff', 'things']
});
});
it ('should create a new array', () => {
store = fromJS({
'comments': {
'123': {
id: '123'
}
}
});
const result = itemsReducer(store, action);
expect(result.getIn(['comments', '123']).toJS()).to.deep.equal({
id: '123',
stuff: ['things']
});
});
});
});
@@ -6,14 +6,26 @@ import CommentHistory from '../../../client/coral-plugin-history/CommentHistory'
describe('coral-plugin-history/CommentHistory', () => {
let render;
const comments = [{body: 'a comment or something', 'status_history':[{'type':'premod', 'created_at':'2016-12-09T01:40:53.327Z', 'assigned_by':null}, {'created_at':'2016-12-09T22:52:44.888Z', 'type':'accepted', 'assigned_by':'92256159-1164-4f66-9970-c7f23de7e461'}], 'asset_id':'96fddf96-7c83-4008-80ad-50091997d006', 'created_at':'2016-12-09T01:40:53.360Z', 'author_id':'92256159-1164-4f66-9970-c7f23de7e461', 'status':'accepted', '__v':0, 'updated_at':'2016-12-09T22:52:44.893Z', 'id':'3962c2ea-4ec4-42e4-b9bd-c571ff30f56b'}, {'body':'another comment', 'status_history':[{'type':'premod', 'created_at':'2016-12-09T22:53:43.148Z', 'assigned_by':null}], 'asset_id':'96fddf96-7c83-4008-80ad-50091997d006', 'created_at':'2016-12-09T22:53:43.158Z', 'author_id':'92256159-1164-4f66-9970-c7f23de7e461', 'status':'premod', '__v':0, 'updated_at':'2016-12-09T22:53:43.158Z', 'id':'b51e27af-bcfd-4932-91be-e3f01a4802e6'}, {'body':'can I comment?', 'status_history':[{'type':'premod', 'created_at':'2016-12-13T23:23:47.123Z', 'assigned_by':null}, {'created_at':'2016-12-13T23:23:58.487Z', 'type':'accepted', 'assigned_by':'92256159-1164-4f66-9970-c7f23de7e461'}], 'asset_id':'cef81015-1b53-4d70-b9af-6eca680f22fc', 'created_at':'2016-12-13T23:23:47.131Z', 'author_id':'92256159-1164-4f66-9970-c7f23de7e461', 'status':'accepted', '__v':0, 'updated_at':'2016-12-13T23:23:58.493Z', 'id':'dc9d7be1-b911-4dc3-8e1e-400e8b8d110e'}, {'body':'pre-mod comment', 'status_history':[{'type':'premod', 'created_at':'2016-12-08T21:34:56.994Z', 'assigned_by':null}, {'created_at':'2016-12-08T21:38:04.961Z', 'type':'rejected', 'assigned_by':'92256159-1164-4f66-9970-c7f23de7e461'}], 'asset_id':'96fddf96-7c83-4008-80ad-50091997d006', 'created_at':'2016-12-08T21:34:56.997Z', 'author_id':'92256159-1164-4f66-9970-c7f23de7e461', 'status':'rejected', '__v':0, 'updated_at':'2016-12-08T21:38:04.965Z', 'id':'6f02af16-a8f8-4ead-80ea-0d48824eb74d'}, {'body':'a flagged commetn', 'status_history':[{'type':'premod', 'created_at':'2016-12-08T21:38:26.342Z', 'assigned_by':null}, {'created_at':'2016-12-09T23:47:27.009Z', 'type':'accepted', 'assigned_by':'92256159-1164-4f66-9970-c7f23de7e461'}], 'asset_id':'96fddf96-7c83-4008-80ad-50091997d006', 'created_at':'2016-12-08T21:38:26.344Z', 'author_id':'92256159-1164-4f66-9970-c7f23de7e461', 'status':'accepted', '__v':0, 'updated_at':'2016-12-09T23:47:27.018Z', 'id':'784c5f91-36b9-4bda-b4ca-a114cef2c9f0'}, {'body':'a post mod comment', 'status_history':[{'type':'premod', 'created_at':'2016-12-08T22:19:05.870Z', 'assigned_by':null}, {'created_at':'2016-12-09T23:26:41.427Z', 'type':'accepted', 'assigned_by':'92256159-1164-4f66-9970-c7f23de7e461'}], 'asset_id':'96fddf96-7c83-4008-80ad-50091997d006', 'created_at':'2016-12-08T22:19:05.874Z', 'author_id':'92256159-1164-4f66-9970-c7f23de7e461', 'status':'accepted', '__v':0, 'updated_at':'2016-12-09T23:26:41.450Z', 'id':'e8b86039-f850-4e53-bd9d-f8c9186a9637'}, {'body':'an actual post-mod comment here', 'status_history':[], 'asset_id':'96fddf96-7c83-4008-80ad-50091997d006', 'created_at':'2016-12-08T22:20:11.147Z', 'author_id':'92256159-1164-4f66-9970-c7f23de7e461', 'status':null, '__v':0, 'updated_at':'2016-12-08T22:20:11.147Z', 'id':'cff1a318-50c6-431e-9a63-de7a7b7136bf'}];
const assets = [{'settings': null, 'created_at':'2016-12-06T21:36:09.302Z', 'url':'localhost:3000/', 'scraped':null, 'status':'open', 'updated_at':'2016-12-08T02:11:15.943Z', '_id':'58472f499e775a38f23d5da0', 'type':'article', 'closedMessage':null, 'id':'7302e637-f884-47c0-9723-02cc10a18617', 'closedAt':null}, {'settings':null, 'created_at':'2016-12-07T02:25:31.983Z', 'url':'http://localhost:3000/', 'scraped':null, 'status':'open', 'updated_at':'2016-12-13T22:58:36.061Z', '_id':'5847731b9e775a38f23d5da1', 'type':'article', 'closedMessage':null, 'id':'96fddf96-7c83-4008-80ad-50091997d006', 'closedAt':null}, {'settings':null, 'created_at':'2016-12-12T19:04:05.770Z', 'url':'http://localhost:3000/embed/stream', 'scraped':null, 'updated_at':'2016-12-14T20:13:21.934Z', '_id':'584ef4a59e775a38f23d5e86', 'type':'article', 'closedMessage':null, 'id':'cef81015-1b53-4d70-b9af-6eca680f22fc', 'closedAt':null}];
const asset = {
'settings': null,
'created_at':'2016-12-06T21:36:09.302Z',
'url':'localhost:3000/',
'scraped':null,
'status':'open',
'updated_at':'2016-12-08T02:11:15.943Z',
'_id':'58472f499e775a38f23d5da0',
'type':'article',
'closedMessage':null,
'id':'7302e637-f884-47c0-9723-02cc10a18617',
'closedAt':null
};
beforeEach(() => {
render = shallow(<CommentHistory comments={comments} assets={assets} link={()=>{}}/>);
render = shallow(<CommentHistory comments={comments} asset={asset} link={()=>{}}/>);
});
it('should render Comments as children when given comments and assets', () => {
const wrapper = mount(<CommentHistory comments={comments} assets={assets} link={()=>{}}/>);
const wrapper = mount(<CommentHistory comments={comments} asset={asset} link={()=>{}}/>);
expect(wrapper.find('.commentHistory__list').children()).to.have.length(7);
});

Some files were not shown because too many files have changed in this diff Show More