mirror of
https://github.com/wassname/talk.git
synced 2026-07-13 17:45:56 +08:00
Merge branch 'master' into story-134624635
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
//==============================================================================
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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>
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />;
|
||||
})
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
@@ -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}`));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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'});
|
||||
};
|
||||
}
|
||||
@@ -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}));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -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
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-21
@@ -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 {
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Executable
+10
@@ -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
@@ -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 &
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
drivers: {
|
||||
chrome: {
|
||||
version: '2.25',
|
||||
arch: process.arch,
|
||||
baseURL: 'https://chromedriver.storage.googleapis.com'
|
||||
},
|
||||
},
|
||||
};
|
||||
+153
-13
@@ -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
@@ -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
@@ -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'});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user