Merge pull request #2 from coralproject/master

update master
This commit is contained in:
Morten Nissen
2017-12-29 22:18:44 +01:00
committed by GitHub
320 changed files with 7260 additions and 4385 deletions
+3 -2
View File
@@ -1,5 +1,6 @@
{
"exceptions": [
"https://nodesecurity.io/advisories/531"
"https://nodesecurity.io/advisories/531",
"https://nodesecurity.io/advisories/532"
]
}
}
+11 -4
View File
@@ -1,11 +1,18 @@
FROM coralproject/talk:latest
# Setup the build arguments
ONBUILD ARG TALK_THREADING_LEVEL=3
ONBUILD ARG TALK_DEFAULT_STREAM_TAB=all
ONBUILD ARG TALK_DEFAULT_LANG=en
ONBUILD ARG TALK_PLUGINS_JSON
# Bundle app source
ONBUILD COPY . /usr/src/app
# At this stage, we need to install the development dependancies again because
# we need to have webpack available. We then build the new dependancies and
# clear out the development dependancies again. After this we of course need to
# At this stage, we need to install the development dependencies again because
# we need to have webpack available. We then build the new dependencies and
# clear out the development dependencies again. After this we of course need to
# clear out the yarn cache, this saves quite a lot of size.
ONBUILD RUN cli plugins reconcile && \
yarn build
yarn build && \
yarn cache clean
+7 -3
View File
@@ -4,8 +4,12 @@ Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
either express or implied.
See the License for the specific language governing permissions and limitations under the License.
See the License for the specific language governing permissions
and limitations under the License.
+22 -31
View File
@@ -1,46 +1,37 @@
# Talk
# Talk · [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) · [![NSP Status](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8/badge)](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8) · [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md#pull-requests)
[![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk)
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://dashboard.heroku.com/new?template=https%3A%2F%2Fgithub.com%2Fcoralproject%2Ftalk&env[TALK_FACEBOOK_APP_ID]=ignore&env[TALK_FACEBOOK_APP_SECRET]=ignore)
[![NSP Status](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8/badge)](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8)
Online comments are broken. Our open-source Talk tool rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here](https://coralproject.net/products/talk.html).
Online comments are broken. Our open-source commenting platform, Talk, rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here](https://coralproject.net/products/talk.html).
Built with <3 by The Coral Project & Mozilla.
## Getting Started
## Try Talk!
Check out our Docs: https://coralproject.github.io/talk/
You're just one click away from trying Talk - all you need is a Heroku account and a few minutes of your time.
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://dashboard.heroku.com/new?template=https%3A%2F%2Fgithub.com%2Fcoralproject%2Ftalk&env[TALK_FACEBOOK_APP_ID]=ignore&env[TALK_FACEBOOK_APP_SECRET]=ignore)
## Technical Documentation
From getting up and running, to advanced configuration, to how to scale Talk, our [Talk Technical Docs](https://coralproject.github.io/talk/) have everything you need to know.
## Product Guide
Learn more about Talk, including a deep dive into features for commenters and moderators, and FAQs in our [Talk Product Guide](https://coralproject.github.io/talk/how-talk-works).
## Relevant Links
- Blog: https://blog.coralproject.net/
- Community Forums: https://community.coralproject.net/
- Community Guides for Journalism: https://guides.coralproject.net/
- Project: https://coralproject.net/
- Roadmap: https://www.pivotaltracker.com/n/projects/1863625
- [Our Blog](https://blog.coralproject.net/)
- [Community Forums](https://community.coralproject.net/)
- [Community Guides for Journalism](https://guides.coralproject.net/)
- [More About Us](https://coralproject.net/)
- [Talk Roadmap](https://www.pivotaltracker.com/n/projects/1863625)
## End-to-End Testing
Talk uses [Nightwatch](http://nightwatchjs.org/) to write e2e tests. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at Browserstack.
Talk uses [Nightwatch](https://nightwatchjs.org/) as our e2e testing framework. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at [Browserstack](https://browserstack.com).
![](/public/img/browserstack_logo.png)
[![Browserstack](/public/img/browserstack_logo.png)](https://browserstack.com)
## License
Copyright 2017 Mozilla Foundation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
either express or implied.
See the License for the specific language governing permissions
and limitations under the License.
Talk is released under the [Apache License, v2.0](/LICENSE).
+21 -12
View File
@@ -1,19 +1,37 @@
const express = require('express');
const bodyParser = require('body-parser');
const morgan = require('morgan');
const path = require('path');
const uuid = require('uuid');
const merge = require('lodash/merge');
const helmet = require('helmet');
const plugins = require('./services/plugins');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const {HELMET_CONFIGURATION} = require('./config');
const {MOUNT_PATH} = require('./url');
const {applyLocals} = require('./services/locals');
const routes = require('./routes');
const debug = require('debug')('talk:app');
const app = express();
// Request Identity Middleware
app.use((req, res, next) => {
req.id = uuid.v4();
next();
});
//==============================================================================
// PLUGIN PRE APPLICATION MIDDLEWARE
//==============================================================================
// Inject server route plugins.
plugins.get('server', 'app').forEach(({plugin, app: callback}) => {
debug(`added plugin '${plugin.name}'`);
// Pass the app to the plugin to mount it's routes.
callback(app);
});
//==============================================================================
// APPLICATION WIDE MIDDLEWARE
//==============================================================================
@@ -36,12 +54,6 @@ app.use(helmet(merge(HELMET_CONFIGURATION, {
// Compress the responses if appropriate.
app.use(compression());
// Parse the cookies on the request.
app.use(cookieParser());
// Parse the body json if it's there.
app.use(bodyParser.json());
//==============================================================================
// VIEW CONFIGURATION
//==============================================================================
@@ -53,9 +65,6 @@ app.set('view engine', 'ejs');
// ROUTES
//==============================================================================
// Add the locals to the app renderer.
applyLocals(app.locals);
debug(`mounting routes on the ${MOUNT_PATH} path`);
// Actually apply the routes.
+44 -23
View File
@@ -12,7 +12,7 @@ const mongoose = require('../services/mongoose');
const kue = require('../services/kue');
util.onshutdown([
() => mongoose.disconnect()
() => mongoose.disconnect(),
]);
/**
@@ -20,17 +20,17 @@ util.onshutdown([
*/
function processJobs() {
// Start the scraper processor.
scraper.process();
// Start the mail processor.
mailer.process();
// The scraper only needs to shutdown when the scraper has actually been
// started.
util.onshutdown([
() => kue.Task.shutdown()
]);
// Start the scraper processor.
scraper.process();
// Start the mail processor.
mailer.process();
}
/**
@@ -48,22 +48,13 @@ function removeJob(job) {
}));
}
/**
* Removes the jobs passed in and returns a promise.
* @param {Array} jobs array of jobs
* @return {Promise}
*/
function removeJobs(jobs) {
return Promise.all(jobs.map(removeJob));
}
/**
* Get the top n jobs with a specific state.
* @param {String} [state='complete'] state to list jobs by
* @param {Number} limit limit of jobs to load
* @return {Promise}
*/
function rangeJobsByState(state = 'complete', limit) {
function rangeJobsByState(state, limit) {
return new Promise((resolve, reject) => {
kue.Job.rangeByState(state, 0, limit, 'asc', (err, jobs) => {
if (err) {
@@ -75,22 +66,52 @@ function rangeJobsByState(state = 'complete', limit) {
});
}
async function getJobBatch(n, includeStuck) {
let jobs = [];
jobs = await rangeJobsByState('complete', n);
if (includeStuck) {
jobs = jobs.concat(await rangeJobsByState('failed', n));
}
return jobs;
}
/**
* Cleans up the jobs that are in the queue.
*/
async function cleanupJobs(options) {
// The scraper only needs to shutdown when the scraper has actually been
// started.
util.onshutdown([
() => kue.Task.shutdown()
]);
const n = 100;
try {
const joblists = await Promise.all([
rangeJobsByState('complete', n),
options.stuck ? rangeJobsByState('failed', n) : false
]);
await joblists.filter((jobs) => jobs).map(removeJobs);
// Connect to redis by establishing a queue.
kue.Task.connect();
let jobCount = 0;
let jobs = await getJobBatch(n, options.stuck);
while (jobs.length > 0) {
// Remove all the jobs.
await Promise.all(jobs.map((job) => removeJob(job)));
jobCount += jobs.length;
// Get the next batch of jobs.
jobs = await getJobBatch(n, options.stuck);
}
util.shutdown();
console.log('Removed old jobs');
console.log(`Removed ${jobCount} jobs`);
} catch (err) {
console.error(err);
util.shutdown(1);
+5 -1
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
const program = require('./commander');
const util = require('./util');
const serve = require('../serve');
//==============================================================================
@@ -13,5 +14,8 @@ program
.parse(process.argv);
// Start serving.
serve({jobs: program.jobs, websockets: program.websockets});
serve({jobs: program.jobs, websockets: program.websockets}).catch((err) => {
console.error(err);
util.shutdown(1);
});
+70 -15
View File
@@ -8,10 +8,13 @@ const program = require('./commander');
const inquirer = require('inquirer');
const UsersService = require('../services/users');
const UserModel = require('../models/user');
const CommentModel = require('../models/comment');
const ActionModel = require('../models/action');
const USER_ROLES = require('../models/enum/user_roles');
const mongoose = require('../services/mongoose');
const util = require('./util');
const Table = require('cli-table');
const databaseVerifications = require('./verifications/database');
const validateRequired = (msg = 'Field is required', len = 1) => (input) => {
if (input && input.length >= len) {
@@ -122,26 +125,48 @@ async function createUser(options) {
} catch (err) {
console.error(err);
util.shutdown();
util.shutdown(1);
}
}
/**
* Deletes a user.
*/
function deleteUser(userID) {
UserModel
.findOneAndRemove({
id: userID
})
.then(() => {
console.log('Deleted user');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown();
});
async function deleteUser(userID) {
try {
// Find the user we're removing.
const user = await UserModel.findOne({id: userID});
if (!user) {
throw new Error(`user with id ${userID} not found`);
}
// Remove all the user's actions.
await ActionModel
.where({user_id: user.id})
.setOptions({multi: true})
.remove();
// Remove all the user's comments.
await CommentModel
.where({author_id: user.id})
.setOptions({multi: true})
.remove();
// Update the counts that might have changed.
for (const verification of databaseVerifications) {
await verification({fix: true, limit: Infinity, batch: 1000});
}
// Remove the user.
await user.remove();
util.shutdown();
} catch (err) {
console.error(err);
util.shutdown(1);
}
}
/**
@@ -241,13 +266,21 @@ function listUsers() {
});
users.forEach((user) => {
let state = user.disabled ? 'Disabled' : 'Enabled';
const profile = user.profiles.find(({provider}) => provider === 'local');
if (profile && profile.metadata && profile.metadata.confirmed_at) {
state += ', Verified';
} else {
state += ', Unverified';
}
table.push([
user.id,
user.username,
user.profiles.map((p) => p.provider).join(', '),
user.roles.join(', '),
user.status,
user.disabled ? 'Disabled' : 'Enabled'
state
]);
});
@@ -396,6 +429,23 @@ function enableUser(userID) {
});
}
/**
* Verifies an email address for a user.
*
* @param userID the user's id
* @param email the user's email address to be verified
*/
async function verify(userID, email) {
try {
await UsersService.confirmEmail(userID, email);
console.log(`User ${userID} had their email ${email} verified.`);
util.shutdown();
} catch (err) {
console.error(err);
util.shutdown(1);
}
}
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
@@ -467,6 +517,11 @@ program
.description('enable a given user from logging in')
.action(enableUser);
program
.command('verify <userID> <email>')
.description('verifies the given user\'s email address')
.action(verify);
program.parse(process.argv);
// If there is no command listed, output help.
-2
View File
@@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import {Router, Route, IndexRedirect, IndexRoute} from 'react-router';
import Configure from 'routes/Configure';
import Dashboard from 'routes/Dashboard';
import Install from 'routes/Install';
import Stories from 'routes/Stories';
import Community from 'routes/Community/containers/Community';
@@ -18,7 +17,6 @@ const routes = (
<IndexRedirect to='/admin/moderate' />
<Route path='configure' component={Configure} />
<Route path='stories' component={Stories} />
<Route path='dashboard' component={Dashboard} />
{/* Community Routes */}
+20 -13
View File
@@ -1,11 +1,12 @@
import queryString from 'query-string';
import {
FETCH_COMMENTERS_REQUEST,
FETCH_COMMENTERS_SUCCESS,
FETCH_COMMENTERS_FAILURE,
FETCH_USERS_REQUEST,
FETCH_USERS_SUCCESS,
FETCH_USERS_FAILURE,
SORT_UPDATE,
COMMENTERS_NEW_PAGE,
SET_PAGE,
SET_SEARCH_VALUE,
SET_ROLE,
SET_COMMENTER_STATUS,
SHOW_BANUSER_DIALOG,
@@ -16,13 +17,13 @@ import {
import t from 'coral-framework/services/i18n';
export const fetchAccounts = (query = {}) => (dispatch, _, {rest}) => {
dispatch(requestFetchAccounts());
export const fetchUsers = (query = {}) => (dispatch, _, {rest}) => {
dispatch(requestFetchUsers());
rest(`/users?${queryString.stringify(query)}`)
.then(({result, page, count, limit, totalPages}) =>{
dispatch({
type: FETCH_COMMENTERS_SUCCESS,
accounts: result,
type: FETCH_USERS_SUCCESS,
users: result,
page,
count,
limit,
@@ -32,12 +33,12 @@ export const fetchAccounts = (query = {}) => (dispatch, _, {rest}) => {
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: FETCH_COMMENTERS_FAILURE, error: errorMessage});
dispatch({type: FETCH_USERS_FAILURE, error: errorMessage});
});
};
const requestFetchAccounts = () => ({
type: FETCH_COMMENTERS_REQUEST
const requestFetchUsers = () => ({
type: FETCH_USERS_REQUEST
});
export const updateSorting = (sort) => ({
@@ -45,8 +46,14 @@ export const updateSorting = (sort) => ({
sort
});
export const newPage = () => ({
type: COMMENTERS_NEW_PAGE
export const setPage = (page) => ({
type: SET_PAGE,
page,
});
export const setSearchValue = (value) => ({
type: SET_SEARCH_VALUE,
value,
});
export const setRole = (id, role) => (dispatch, _, {rest}) => {
@@ -34,3 +34,8 @@ export const storySearchChange = (value) => ({
export const clearState = () => ({
type: actions.MODERATION_CLEAR_STATE
});
export const selectCommentId = (id) => ({
type: actions.MODERATION_SELECT_COMMENT,
id,
});
@@ -1,12 +1,17 @@
import queryString from 'query-string';
import {
FETCH_ASSETS_REQUEST,
FETCH_ASSETS_SUCCESS,
FETCH_ASSETS_FAILURE,
SET_PAGE,
SET_SEARCH_VALUE,
SET_CRITERIA,
UPDATE_ASSET_STATE_REQUEST,
UPDATE_ASSET_STATE_SUCCESS,
UPDATE_ASSET_STATE_FAILURE,
UPDATE_ASSETS
} from '../constants/assets';
} from '../constants/stories';
import t from 'coral-framework/services/i18n';
@@ -16,13 +21,16 @@ import t from 'coral-framework/services/i18n';
// Fetch a page of assets
// Get comments to fill each of the three lists on the mod queue
export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filter = '') => (dispatch, _, {rest}) => {
export const fetchAssets = (query = {}) => (dispatch, _, {rest}) => {
dispatch({type: FETCH_ASSETS_REQUEST});
return rest(`/assets?skip=${skip}&limit=${limit}&sort=${sort}&search=${search}&filter=${filter}`)
.then(({result, count}) =>
return rest(`/assets?${queryString.stringify(query)}`)
.then(({result, page, count, limit, totalPages}) =>
dispatch({type: FETCH_ASSETS_SUCCESS,
assets: result,
count
page,
count,
limit,
totalPages,
}))
.catch((error) => {
console.error(error);
@@ -47,3 +55,19 @@ export const updateAssetState = (id, closedAt) => (dispatch, _, {rest}) => {
export const updateAssets = (assets) => (dispatch) => {
dispatch({type: UPDATE_ASSETS, assets});
};
export const setPage = (page) => ({
type: SET_PAGE,
page,
});
export const setSearchValue = (value) => ({
type: SET_SEARCH_VALUE,
value,
});
export const setCriteria = (criteria) => ({
type: SET_CRITERIA,
criteria,
});
@@ -20,3 +20,9 @@ export const toggleSelectCommentInUserDetail = (id, active) => {
};
};
export const toggleSelectAllCommentInUserDetail = (ids, active) => {
return {
type: active ? actions.SELECT_ALL_USER_DETAIL_COMMENT : actions.CLEAR_USER_DETAIL_SELECTIONS,
ids
};
};
@@ -32,8 +32,9 @@ class ActionsMenu extends React.Component {
};
render() {
const {className = ''} = this.props;
return (
<div className={styles.root} onBlur={this.syncOpenState} >
<div className={cn(styles.root, className)} onBlur={this.syncOpenState} >
<Button
cStyle='actions'
className={cn(styles.button, {[styles.buttonOpen]: this.state.open})}
@@ -59,6 +60,7 @@ class ActionsMenu extends React.Component {
ActionsMenu.propTypes = {
icon: PropTypes.string,
children: PropTypes.node,
className: PropTypes.string,
};
export default ActionsMenu;
@@ -3,12 +3,14 @@ import cn from 'classnames';
import {MenuItem} from 'react-mdl';
import PropTypes from 'prop-types';
import styles from './ActionsMenu.css';
import camelCase from 'lodash/camelCase';
const ActionsMenuItem = (props) =>
<MenuItem className={cn(styles.menuItem, props.className)} {...props} />;
<MenuItem className={cn(styles.menuItem, props.className, 'action-menu-item')} {...props} id={camelCase(props.children)}/>;
ActionsMenuItem.propTypes = {
className: PropTypes.string,
children: PropTypes.string,
};
export default ActionsMenuItem;
@@ -7,11 +7,11 @@ import {Icon} from 'coral-ui';
import t from 'coral-framework/services/i18n';
const ApproveButton = ({active, minimal, onClick}) => {
const ApproveButton = ({active, minimal, onClick, className}) => {
const text = active ? t('modqueue.approved') : t('modqueue.approve');
return (
<button
className={cn(styles.root, {[styles.minimal]: minimal, [styles.active]: active})}
className={cn(styles.root, {[styles.minimal]: minimal, [styles.active]: active}, className)}
onClick={onClick}
>
<Icon name={'done'} className={styles.icon} />
@@ -21,6 +21,7 @@ const ApproveButton = ({active, minimal, onClick}) => {
};
ApproveButton.propTypes = {
className: PropTypes.string,
active: PropTypes.bool,
minimal: PropTypes.bool,
onClick: PropTypes.func,
@@ -1,4 +1,5 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import {Dialog} from 'coral-ui';
import styles from './BanUserDialog.css';
@@ -8,28 +9,34 @@ import t from 'coral-framework/services/i18n';
const BanUserDialog = ({open, onCancel, onPerform, username, info}) => (
<Dialog
className={styles.dialog}
className={cn(styles.dialog, 'talk-ban-user-dialog')}
id="banUserDialog"
open={open}
onCancel={onCancel}
title={t('bandialog.ban_user')}>
<span className={styles.close} onClick={onCancel}>×</span>
<div>
<div className={styles.header}>
<h2>{t('bandialog.ban_user')}</h2>
</div>
<div className={styles.separator}>
<h3>{t('bandialog.are_you_sure', username)}</h3>
<i>{info}</i>
</div>
<div className={styles.buttons}>
<Button cStyle="cancel" className={styles.cancel} onClick={onCancel} raised>
{t('bandialog.cancel')}
</Button>
<Button cStyle="black" className={styles.ban} onClick={onPerform} raised>
{t('bandialog.yes_ban_user')}
</Button>
</div>
<div className={styles.header}>
<h2>{t('bandialog.ban_user')}</h2>
</div>
<div className={styles.separator}>
<h3>{t('bandialog.are_you_sure', username)}</h3>
<i>{info}</i>
</div>
<div className={styles.buttons}>
<Button
className={cn(styles.cancel, 'talk-ban-user-dialog-button-cancel')}
cStyle="cancel"
onClick={onCancel}
raised >
{t('bandialog.cancel')}
</Button>
<Button
className={cn(styles.ban, 'talk-ban-user-dialog-button-confirm')}
cStyle="black"
onClick={onPerform}
raised >
{t('bandialog.yes_ban_user')}
</Button>
</div>
</Dialog>
);
@@ -15,7 +15,15 @@ function generateRegExp(phrases) {
.join('[\\s"?!.]+')
).join('|');
return new RegExp(`(^|[^\\w])(${inner})(?=[^\\w]|$)`, 'iu');
const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
try {
return new RegExp(pattern, 'iu');
}
catch (_err) {
// IE does not support unicode support, so we'll create one without.
return new RegExp(pattern, 'i');
}
}
// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
@@ -21,10 +21,11 @@ class CommentDetails extends Component {
this.setState((state) => ({
showDetail: !state.showDetail
}));
this.props.clearHeightCache && this.props.clearHeightCache();
}
render() {
const {data, root, comment} = this.props;
const {data, root, comment, clearHeightCache} = this.props;
const {showDetail} = this.state;
const queryData = {
root,
@@ -44,12 +45,14 @@ class CommentDetails extends Component {
<Slot
fill="adminCommentDetailArea"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
more={showDetail}
/>
{showDetail && <Slot
fill="adminCommentMoreDetails"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>}
</div>
@@ -61,6 +64,7 @@ CommentDetails.propTypes = {
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
comment: PropTypes.object.isRequired,
clearHeightCache: PropTypes.func,
};
export default CommentDetails;
@@ -6,10 +6,21 @@ import FlagLabel from 'coral-ui/components/FlagLabel';
import cn from 'classnames';
import styles from './CommentLabels.css';
const staffRoles = ['ADMIN', 'STAFF', 'MODERATOR'];
function isUserFlagged(actions) {
return actions.some((action) => action.__typename === 'FlagAction' && action.user);
}
function getUserFlaggedType(actions) {
return actions
.some((action) =>
action.__typename === 'FlagAction' &&
action.user &&
action.user.roles.some((role) => staffRoles.includes(role))
) ? 'Staff' : 'User';
}
function hasSuspectedWords(actions) {
return actions.some((action) => action.__typename === 'FlagAction' && action.reason === 'Matched suspect word filter');
}
@@ -24,7 +35,7 @@ const CommentLabels = ({comment, comment: {className, status, actions, hasParent
<div className={styles.coreLabels}>
{hasParent && <Label iconName="reply" className={styles.replyLabel}>reply</Label>}
{status === 'PREMOD' && <Label iconName="query_builder" className={styles.premodLabel}>Pre-Mod</Label>}
{isUserFlagged(actions) && <FlagLabel iconName="person">User</FlagLabel>}
{isUserFlagged(actions) && <FlagLabel iconName="person">{getUserFlaggedType(actions)}</FlagLabel>}
{hasSuspectedWords(actions) && <FlagLabel iconName="sms_failed">Suspect</FlagLabel>}
{hasHistoryFlag(actions) && <FlagLabel iconName="sentiment_very_dissatisfied">History</FlagLabel>}
</div>
+10 -13
View File
@@ -5,34 +5,31 @@ import {IndexLink, Link} from 'react-router';
import styles from './Drawer.css';
import t from 'coral-framework/services/i18n';
import {can} from 'coral-framework/services/perms';
import cn from 'classnames';
const CoralDrawer = ({handleLogout, auth = {}}) => (
<Drawer className={styles.drawer}>
<Drawer className={cn('talk-admin-drawer-nav', styles.drawer)}>
{ auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
<div>
<Navigation className={styles.nav}>
<IndexLink
className={styles.navLink}
to="/admin/dashboard"
activeClassName={styles.active}>
{t('configure.dashboard')}
</IndexLink>
{
can(auth.user, 'MODERATE_COMMENTS') && (
<Link
className={styles.navLink}
<IndexLink
className={cn('talk-admin-nav-moderate', styles.navLink)}
to="/admin/moderate"
activeClassName={styles.active}>
{t('configure.moderate')}
</Link>
</IndexLink>
)
}
<Link className={styles.navLink}
<Link
className={cn('talk-admin-nav-stories', styles.navLink)}
to="/admin/stories"
activeClassName={styles.active}>
{t('configure.stories')}
</Link>
<Link className={styles.navLink}
<Link
className={cn('talk-admin-nav-community', styles.navLink)}
to="/admin/community"
activeClassName={styles.active}>
{t('configure.community')}
@@ -41,7 +38,7 @@ const CoralDrawer = ({handleLogout, auth = {}}) => (
can(auth.user, 'UPDATE_CONFIG') &&
(
<Link
className={styles.navLink}
className={cn('talk-admin-nav-configure', styles.navLink)}
to="/admin/configure"
activeClassName={styles.active}>
{t('configure.configure')}
@@ -4,34 +4,38 @@ import Modal from 'components/Modal';
import styles from './ModerationKeysModal.css';
import t from 'coral-framework/services/i18n';
const shortcuts = [
{
title: 'modqueue.navigation',
shortcuts: {
'j': 'modqueue.next_comment',
'k': 'modqueue.prev_comment',
'ctrl+f': 'modqueue.toggle_search',
't': 'modqueue.next_queue',
's': 'modqueue.singleview',
'?': 'modqueue.thismenu'
}
},
{
title: 'modqueue.actions',
shortcuts: {
'd': 'modqueue.approve',
'f': 'modqueue.reject'
}
}
];
export default class ModerationKeysModal extends React.Component {
static propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
hideShortcutsNote: PropTypes.func.isRequired,
shortcutsNoteVisible: PropTypes.string.isRequired
shortcutsNoteVisible: PropTypes.string.isRequired,
queueCount: PropTypes.number.isRequired
}
buildShortcuts = () => {
return [
{
title: 'modqueue.navigation',
shortcuts: {
'j': 'modqueue.next_comment',
'k': 'modqueue.prev_comment',
'ctrl+f': 'modqueue.toggle_search',
't': 'modqueue.next_queue',
[`1...${this.props.queueCount}`]: 'modqueue.jump_to_queue',
's': 'modqueue.singleview',
'?': 'modqueue.thismenu'
}
},
{
title: 'modqueue.actions',
shortcuts: {
'd': 'modqueue.approve',
'f': 'modqueue.reject'
}
}
];
}
render () {
@@ -51,7 +55,7 @@ export default class ModerationKeysModal extends React.Component {
<Modal open={open} onClose={onClose}>
<h3>{t('modqueue.shortcuts')}</h3>
<div className={styles.container}>
{shortcuts.map((shortcut, i) => (
{this.buildShortcuts().map((shortcut, i) => (
<table className={styles.table} key={i}>
<thead>
<tr>
@@ -7,11 +7,11 @@ import {Icon} from 'coral-ui';
import t from 'coral-framework/services/i18n';
const RejectButton = ({active, minimal, onClick}) => {
const RejectButton = ({active, minimal, onClick, className}) => {
const text = active ? t('modqueue.rejected') : t('modqueue.reject');
return (
<button
className={cn(styles.root, {[styles.minimal]: minimal, [styles.active]: active})}
className={cn(styles.root, {[styles.minimal]: minimal, [styles.active]: active}, className)}
onClick={onClick}
>
<Icon name={'close'} className={styles.icon} />
@@ -21,6 +21,7 @@ const RejectButton = ({active, minimal, onClick}) => {
};
RejectButton.propTypes = {
className: PropTypes.string,
active: PropTypes.bool,
minimal: PropTypes.bool,
onClick: PropTypes.func,
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import {Dialog} from 'coral-ui';
import {RadioGroup, Radio} from 'react-mdl';
import styles from './SuspendUserDialog.css';
import cn from 'classnames';
import Button from 'coral-ui/components/Button';
@@ -87,7 +88,7 @@ class SuspendUserDialog extends React.Component {
<Button cStyle="white" className={styles.cancel} onClick={onCancel} raised>
{t('suspenduser.cancel')}
</Button>
<Button cStyle="black" className={styles.perform} onClick={this.goToStep1} raised>
<Button cStyle="black" className={cn(styles.perform, 'talk-admin-suspend-user-dialog-confirm')} onClick={this.goToStep1} raised>
{t('suspenduser.suspend_user')}
</Button>
</div>
@@ -120,7 +121,7 @@ class SuspendUserDialog extends React.Component {
</Button>
<Button
cStyle="black"
className={styles.perform}
className={cn(styles.perform, 'talk-admin-suspend-user-dialog-send')}
onClick={this.handlePerform}
disabled={this.state.message.length === 0}
raised
@@ -137,7 +138,7 @@ class SuspendUserDialog extends React.Component {
const {step} = this.state;
return (
<Dialog
className={styles.dialog}
className={cn(styles.dialog, 'talk-admin-suspend-user-dialog')}
onCancel={onCancel}
open={open}
>
@@ -39,7 +39,8 @@
margin-right: 0px;
}
.statItem, .statReportResult {
.statItem,
.statReportResult {
padding: 3px 5px;
background-color: #D8D8D8;
border-radius: 3px;
@@ -48,7 +49,7 @@
font-size: 0.9em;
line-height: normal;
letter-spacing: 0.4px;
min-width: 60px;
min-width: 60px;
}
.statResult {
@@ -94,12 +95,11 @@
}
.commentStatuses {
padding: 10px 0 0 0;
padding: 0 0 0 10px;
margin: 0;
height: 52px;
align-self: center;
list-style: none;
box-sizing: border-box;
li {
display: inline-block;
margin-right: 10px;
@@ -116,11 +116,11 @@
.bulkActionGroup {
height: 52px;
background-color: #efefef;
padding: 0 0 0 10px;
display: flex;
i {
margin-right: 0;
}
.bulkAction {
display: inline-block;
width: 48px;
@@ -128,17 +128,39 @@
transform: scale(.7);
min-width: 0;
}
.bulkAction:last-child {
margin-left: -10px;
}
}
.loadMore > button {
background-color: #696969;
.selectedCommentsInfo {
align-self: center;
font-weight: 500;
margin-left: 15px;
}
.loadMore>button {
background-color: #696969;
&:hover {
background-color: #404040;
color: white;
}
}
.toggleAll {
padding: 0 10px 0 0;
align-self: center;
}
.commentList {
clear: both;
}
.bulkActionHeader {
display: flex;
justify-content: space-between;
height: 52px;
&.selected {
background-color: #efefef;
}
}
+71 -39
View File
@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import Comment from '../containers/UserDetailComment';
import styles from './UserDetail.css';
import {Icon, Button, Drawer, Spinner} from 'coral-ui';
import {Icon, Drawer, Spinner} from 'coral-ui';
import {Slot} from 'coral-framework/components';
import ButtonCopyToClipboard from './ButtonCopyToClipboard';
import ClickOutside from 'coral-framework/components/ClickOutside';
@@ -10,6 +10,9 @@ import LoadMore from '../components/LoadMore';
import cn from 'classnames';
import capitalize from 'lodash/capitalize';
import {getReliability} from 'coral-framework/utils/user';
import ApproveButton from './ApproveButton';
import RejectButton from './RejectButton';
import {getErrorMessages} from 'coral-framework/utils';
export default class UserDetail extends React.Component {
@@ -23,6 +26,7 @@ export default class UserDetail extends React.Component {
toggleSelect: PropTypes.func.isRequired,
bulkAccept: PropTypes.func.isRequired,
bulkReject: PropTypes.func.isRequired,
toggleSelectAll: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
data: PropTypes.shape({
refetch: PropTypes.func.isRequired,
@@ -30,7 +34,8 @@ export default class UserDetail extends React.Component {
activeTab: PropTypes.string.isRequired,
selectedCommentIds: PropTypes.array.isRequired,
viewUserDetail: PropTypes.any.isRequired,
loadMore: PropTypes.any.isRequired
loadMore: PropTypes.any.isRequired,
notify: PropTypes.func.isRequired
}
rejectThenReload = async (info) => {
@@ -39,8 +44,8 @@ export default class UserDetail extends React.Component {
this.props.data.refetch();
} catch (err) {
// TODO: handle error.
console.error(err);
this.props.notify('error', getErrorMessages(err));
}
}
@@ -50,8 +55,30 @@ export default class UserDetail extends React.Component {
this.props.data.refetch();
} catch (err) {
// TODO: handle error.
console.error(err);
this.props.notify('error', getErrorMessages(err));
}
}
bulkAcceptThenReload = async () => {
try {
await this.props.bulkAccept();
this.props.data.refetch();
} catch (err) {
console.error(err);
this.props.notify('error', getErrorMessages(err));
}
}
bulkRejectThenReload = async () => {
try {
await this.props.bulkReject();
this.props.data.refetch();
} catch (err) {
console.error(err);
this.props.notify('error', getErrorMessages(err));
}
}
@@ -86,11 +113,10 @@ export default class UserDetail extends React.Component {
activeTab,
selectedCommentIds,
toggleSelect,
bulkAccept,
bulkReject,
hideUserDetail,
viewUserDetail,
loadMore,
toggleSelectAll
} = this.props;
let rejectedPercent = (rejectedComments / totalComments) * 100;
@@ -108,14 +134,14 @@ export default class UserDetail extends React.Component {
<div>
<ul className={styles.userDetailList}>
<li>
<Icon name="assignment_ind"/>
<Icon name="assignment_ind" />
<span className={styles.userDetailItem}>Member Since:</span>
{new Date(user.created_at).toLocaleString()}
</li>
{user.profiles.map(({id}) =>
<li key={id}>
<Icon name="email"/>
<Icon name="email" />
<span className={styles.userDetailItem}>Email:</span>
{id} <ButtonCopyToClipboard className={styles.copyButton} icon="content_copy" copyText={id} />
</li>
@@ -147,36 +173,42 @@ export default class UserDetail extends React.Component {
data={this.props.data}
queryData={{root, user}}
/>
<hr/>
{
selectedCommentIds.length === 0
? (
<ul className={styles.commentStatuses}>
<li className={activeTab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
<li className={activeTab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
</ul>
)
: (
<div className={styles.bulkActionGroup}>
<Button
onClick={bulkAccept}
className={styles.bulkAction}
cStyle='approve'
icon='done'>
</Button>
<Button
onClick={bulkReject}
className={styles.bulkAction}
cStyle='reject'
icon='close'>
</Button>
{selectedCommentIds.length} comments selected
</div>
)
}
<div>
<hr />
<div className={(selectedCommentIds.length > 0) ? cn(styles.bulkActionHeader, styles.selected) : styles.bulkActionHeader}>
{
selectedCommentIds.length === 0
? (
<ul className={styles.commentStatuses}>
<li className={activeTab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
<li className={activeTab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
</ul>
)
: (
<div className={styles.bulkActionGroup}>
<ApproveButton
onClick={this.bulkAcceptThenReload}
minimal
/>
<RejectButton
onClick={this.bulkRejectThenReload}
minimal
/>
<span className={styles.selectedCommentsInfo}> {selectedCommentIds.length} comments selected</span>
</div>
)
}
<div className={styles.toggleAll}>
<input
type='checkbox'
id='toogleAll'
checked={selectedCommentIds.length > 0 && selectedCommentIds.length === nodes.length}
onChange={(e) => {
toggleSelectAll(nodes.map((comment) => comment.id), e.target.checked);
}} />
<label htmlFor='toogleAll'>Select all</label>
</div>
</div>
<div className={styles.commentList}>
{
nodes.map((comment) => {
const selected = selectedCommentIds.indexOf(comment.id) !== -1;
@@ -205,7 +237,7 @@ export default class UserDetail extends React.Component {
);
}
render () {
render() {
if (this.props.loading) {
return this.renderLoading();
}
+12 -18
View File
@@ -1,4 +1,5 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import {Navigation, Header, IconButton, MenuItem, Menu} from 'react-mdl';
import {Link, IndexLink} from 'react-router';
@@ -22,28 +23,21 @@ const CoralHeader = ({
{
auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
<Navigation className={styles.nav}>
<IndexLink
id='dashboardNav'
className={styles.navLink}
to="/admin/dashboard"
activeClassName={styles.active}>
{t('configure.dashboard')}
</IndexLink>
{
can(auth.user, 'MODERATE_COMMENTS') && (
<Link
<IndexLink
id='moderateNav'
className={styles.navLink}
className={cn('talk-admin-nav-moderate', styles.navLink)}
to="/admin/moderate"
activeClassName={styles.active}>
{t('configure.moderate')}
{(root.premodCount !== 0 || root.reportedCount !== 0) && <Indicator />}
</Link>
</IndexLink>
)
}
<Link
id='streamsNav'
className={styles.navLink}
id='storiesNav'
className={cn('talk-admin-nav-stories', styles.navLink)}
to="/admin/stories"
activeClassName={styles.active}>
{t('configure.stories')}
@@ -51,7 +45,7 @@ const CoralHeader = ({
<Link
id='communityNav'
className={styles.navLink}
className={cn('talk-admin-nav-community', styles.navLink)}
to="/admin/community"
activeClassName={styles.active}>
{t('configure.community')}
@@ -62,7 +56,7 @@ const CoralHeader = ({
can(auth.user, 'UPDATE_CONFIG') && (
<Link
id='configureNav'
className={styles.navLink}
className={cn('talk-admin-nav-configure', styles.navLink)}
to="/admin/configure"
activeClassName={styles.active}>
{t('configure.configure')}
@@ -75,9 +69,9 @@ const CoralHeader = ({
}
<div className={styles.rightPanel}>
<ul>
<li className={styles.settings}>
<li className={cn(styles.settings, 'talk-admin-header-settings')}>
<div>
<IconButton name="settings" id="menu-settings"/>
<IconButton name="settings" id="menu-settings" className="talk-admin-header-settings-button"/>
<Menu target="menu-settings" align="right">
<MenuItem onClick={() => showShortcuts(true)}>{t('configure.shortcuts')}</MenuItem>
<MenuItem>
@@ -86,11 +80,11 @@ const CoralHeader = ({
</a>
</MenuItem>
<MenuItem>
<a href="https://coralproject.net/contribute.html#other-ideas-and-bug-reports" target="_blank" rel="noopener noreferrer">
<a href="https://support.coralproject.net" target="_blank" rel="noopener noreferrer">
Report a bug or give feedback
</a>
</MenuItem>
<MenuItem onClick={handleLogout}>
<MenuItem onClick={handleLogout} className="talk-admin-header-sign-out">
{t('configure.sign_out')}
</MenuItem>
</Menu>
@@ -1,9 +0,0 @@
export const FETCH_ASSETS_REQUEST = 'FETCH_ASSETS_REQUEST';
export const FETCH_ASSETS_SUCCESS = 'FETCH_ASSETS_SUCCESS';
export const FETCH_ASSETS_FAILURE = 'FETCH_ASSETS_FAILURE';
export const UPDATE_ASSET_STATE_REQUEST = 'UPDATE_ASSET_STATE_REQUEST';
export const UPDATE_ASSET_STATE_SUCCESS = 'UPDATE_ASSET_STATE_SUCCESS';
export const UPDATE_ASSET_STATE_FAILURE = 'UPDATE_ASSET_STATE_FAILURE';
export const UPDATE_ASSETS = 'UPDATE_ASSETS';
+19 -14
View File
@@ -1,17 +1,22 @@
export const FETCH_COMMENTERS_REQUEST = 'FETCH_COMMENTERS_REQUEST';
export const FETCH_COMMENTERS_SUCCESS = 'FETCH_COMMENTERS_SUCCESS';
export const FETCH_COMMENTERS_FAILURE = 'FETCH_COMMENTERS_FAILURE';
export const SORT_UPDATE = 'SORT_UPDATE';
export const COMMENTERS_NEW_PAGE = 'COMMENTERS_NEW_PAGE';
export const SET_ROLE = 'SET_ROLE';
export const SET_COMMENTER_STATUS = 'SET_COMMENTER_STATUS';
const prefix = 'COMMUNITY';
export const FETCH_FLAGGED_COMMENTERS_REQUEST = 'FETCH_FLAGGED_COMMENTERS_REQUEST';
export const FETCH_FLAGGED_COMMENTERS_SUCCESS = 'FETCH_FLAGGED_COMMENTERS_SUCCESS';
export const FETCH_FLAGGED_COMMENTERS_FAILURE = 'FETCH_FLAGGED_COMMENTERS_FAILURE';
export const FETCH_USERS_REQUEST = `${prefix}_FETCH_USERS_REQUEST`;
export const FETCH_USERS_SUCCESS = `${prefix}_FETCH_USERS_SUCCESS`;
export const FETCH_USERS_FAILURE = `${prefix}_FETCH_USERS_FAILURE`;
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
export const SORT_UPDATE = `${prefix}_SORT_UPDATE`;
export const SET_PAGE = `${prefix}_SET_PAGE`;
export const SET_ROLE = `${prefix}_SET_ROLE`;
export const SET_COMMENTER_STATUS = `${prefix}_SET_COMMENTER_STATUS`;
export const SHOW_REJECT_USERNAME_DIALOG = 'SHOW_REJECT_USERNAME_DIALOG';
export const HIDE_REJECT_USERNAME_DIALOG = 'HIDE_REJECT_USERNAME_DIALOG';
export const FETCH_FLAGGED_COMMENTERS_REQUEST = `${prefix}_FETCH_FLAGGED_COMMENTERS_REQUEST`;
export const FETCH_FLAGGED_COMMENTERS_SUCCESS = `${prefix}_FETCH_FLAGGED_COMMENTERS_SUCCESS`;
export const FETCH_FLAGGED_COMMENTERS_FAILURE = `${prefix}_FETCH_FLAGGED_COMMENTERS_FAILURE`;
export const SHOW_BANUSER_DIALOG = `${prefix}_SHOW_BANUSER_DIALOG`;
export const HIDE_BANUSER_DIALOG = `${prefix}_HIDE_BANUSER_DIALOG`;
export const SHOW_REJECT_USERNAME_DIALOG = `${prefix}_SHOW_REJECT_USERNAME_DIALOG`;
export const HIDE_REJECT_USERNAME_DIALOG = `${prefix}_HIDE_REJECT_USERNAME_DIALOG`;
export const SET_SEARCH_VALUE = `${prefix}_SET_SEARCH_VALUE`;
@@ -6,3 +6,4 @@ export const SHOW_STORY_SEARCH = 'SHOW_STORY_SEARCH';
export const HIDE_STORY_SEARCH = 'HIDE_STORY_SEARCH';
export const STORY_SEARCH_CHANGE_VALUE = 'STORY_SEARCH_CHANGE_VALUE';
export const MODERATION_CLEAR_STATE = 'MODERATION_CLEAR_STATE';
export const MODERATION_SELECT_COMMENT = 'MODERATION_SELECT_COMMENT';
@@ -0,0 +1,15 @@
const prefix = 'STORIES';
export const FETCH_ASSETS_REQUEST = `${prefix}_FETCH_ASSETS_REQUEST`;
export const FETCH_ASSETS_SUCCESS = `${prefix}_FETCH_ASSETS_SUCCESS`;
export const FETCH_ASSETS_FAILURE = `${prefix}_FETCH_ASSETS_FAILURE`;
export const UPDATE_ASSET_STATE_REQUEST = `${prefix}_UPDATE_ASSET_STATE_REQUEST`;
export const UPDATE_ASSET_STATE_SUCCESS = `${prefix}_UPDATE_ASSET_STATE_SUCCESS`;
export const UPDATE_ASSET_STATE_FAILURE = `${prefix}_UPDATE_ASSET_STATE_FAILURE`;
export const UPDATE_ASSETS = `${prefix}_UPDATE_ASSETS`;
export const SET_PAGE = `${prefix}_SET_PAGE`;
export const SET_SEARCH_VALUE = `${prefix}_SET_SEARCH_VALUE`;
export const SET_CRITERIA = `${prefix}_SET_CRITERIA`;
@@ -4,4 +4,4 @@ export const CHANGE_USER_DETAIL_STATUSES = 'CHANGE_USER_DETAIL_STATUSES';
export const SELECT_USER_DETAIL_COMMENT = 'SELECT_USER_DETAIL_COMMENT';
export const UNSELECT_USER_DETAIL_COMMENT = 'UNSELECT_USER_DETAIL_COMMENT';
export const CLEAR_USER_DETAIL_SELECTIONS = 'CLEAR_USER_DETAIL_SELECTIONS';
export const SELECT_ALL_USER_DETAIL_COMMENT = 'SELECT_ALL_USER_DETAIL_COMMENT';
@@ -25,6 +25,7 @@ export default withFragments({
}
user {
id
roles
}
}
${getSlotFragmentSpreads(slots, 'comment')}
+1 -1
View File
@@ -19,6 +19,6 @@ export default withQuery(gql`
}
`, {
options: {
pollInterval: 5000
pollInterval: 10000
}
})(Header);
@@ -1,4 +1,5 @@
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import SuspendUserDialog from '../components/SuspendUserDialog';
@@ -44,6 +45,12 @@ class SuspendUserDialogContainer extends Component {
}
}
SuspendUserDialogContainer.propTypes = {
open: PropTypes.bool,
hideSuspendUserDialog: PropTypes.func,
username: PropTypes.string,
};
const withOrganizationName = withQuery(gql`
query CoralAdmin_SuspendUserDialog {
__typename
+14 -14
View File
@@ -11,13 +11,15 @@ import {
changeUserDetailStatuses,
clearUserDetailSelections,
toggleSelectCommentInUserDetail,
toggleSelectAllCommentInUserDetail
} from 'coral-admin/src/actions/userDetail';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import UserDetailComment from './UserDetailComment';
import update from 'immutability-helper';
import {notify} from 'coral-framework/actions/notification';
const commentConnectionFragment = gql`
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
fragment CoralAdmin_UserDetail_CommentConnection on CommentConnection {
nodes {
...${getDefinitionName(UserDetailComment.fragments.comment)}
}
@@ -41,22 +43,16 @@ class UserDetailContainer extends React.Component {
return this.props.setCommentStatus({commentId, status});
});
try {
await Promise.all(changes);
this.props.clearUserDetailSelections(); // un-select everything
} catch (err) {
// TODO: handle error.
console.error(err);
}
await Promise.all(changes);
this.props.clearUserDetailSelections(); // un-select everything
}
bulkReject = () => {
this.bulkSetCommentStatus('REJECTED');
return this.bulkSetCommentStatus('REJECTED');
}
bulkAccept = () => {
this.bulkSetCommentStatus('ACCEPTED');
return this.bulkSetCommentStatus('ACCEPTED');
}
acceptComment = ({commentId}) => {
@@ -120,6 +116,7 @@ class UserDetailContainer extends React.Component {
bulkAccept={this.bulkAccept}
changeStatus={this.props.changeUserDetailStatuses}
toggleSelect={this.props.toggleSelectCommentInUserDetail}
toggleSelectAll={this.props.toggleSelectAllCommentInUserDetail}
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
loading={loading}
@@ -131,7 +128,7 @@ class UserDetailContainer extends React.Component {
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $author_id: ID!, $statuses: [COMMENT_STATUS!]) {
comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) {
...CoralAdmin_Moderation_CommentConnection
...CoralAdmin_UserDetail_CommentConnection
}
}
${commentConnectionFragment}
@@ -158,7 +155,7 @@ export const withUserDetailQuery = withQuery(gql`
author_id: $author_id,
statuses: $statuses
}) {
...CoralAdmin_Moderation_CommentConnection
...CoralAdmin_UserDetail_CommentConnection
}
...${getDefinitionName(UserDetailComment.fragments.root)}
${getSlotFragmentSpreads(slots, 'root')}
@@ -168,7 +165,8 @@ export const withUserDetailQuery = withQuery(gql`
`, {
options: ({userId, statuses}) => {
return {
variables: {author_id: userId, statuses}
variables: {author_id: userId, statuses},
fetchPolicy: 'network-only',
};
},
skip: (ownProps) => !ownProps.userId,
@@ -188,6 +186,8 @@ const mapDispatchToProps = (dispatch) => ({
toggleSelectCommentInUserDetail,
viewUserDetail,
hideUserDetail,
toggleSelectAllCommentInUserDetail,
notify
}, dispatch)
});
+1 -11
View File
@@ -1,15 +1,5 @@
import update from 'immutability-helper';
import mapValues from 'lodash/mapValues';
// Map nested object leaves. Array objects are considered leaves.
function mapLeaves(o, mapper) {
return mapValues(o, (val) => {
if (typeof val === 'object' && !Array.isArray(val)) {
return mapLeaves(val, mapper);
}
return mapper(val);
});
}
import {mapLeaves} from 'coral-framework/utils';
export default {
mutations: {
-40
View File
@@ -1,40 +0,0 @@
import * as actions from '../constants/assets';
import update from 'immutability-helper';
const initialState = {
byId: {},
ids: [],
assets: []
};
export default function assets (state = initialState, action) {
switch (action.type) {
case actions.FETCH_ASSETS_SUCCESS: {
const assets = action.assets.reduce((prev, curr) => {
prev[curr.id] = curr;
return prev;
}, {});
return update(state, {
byId: {$set: assets},
count: {$set: action.count},
ids: {$set: Object.keys(assets)},
});
}
case actions.UPDATE_ASSET_STATE_REQUEST:
return update(state, {
byId: {
[action.id]: {
closedAt: {$set: action.closedAt},
},
},
});
case actions.UPDATE_ASSETS:
return update(state, {
assets: {$set: action.assets},
});
default:
return state;
}
}
+26 -13
View File
@@ -1,8 +1,10 @@
import {
FETCH_COMMENTERS_REQUEST,
FETCH_COMMENTERS_FAILURE,
FETCH_COMMENTERS_SUCCESS,
FETCH_USERS_REQUEST,
FETCH_USERS_SUCCESS,
FETCH_USERS_FAILURE,
SORT_UPDATE,
SET_PAGE,
SET_SEARCH_VALUE,
SET_ROLE,
SET_COMMENTER_STATUS,
SHOW_BANUSER_DIALOG,
@@ -15,7 +17,8 @@ const initialState = {
community: {},
isFetchingPeople: false,
errorPeople: '',
accounts: [],
users: [],
searchValue: '',
fieldPeople: 'created_at',
ascPeople: false,
totalPagesPeople: 0,
@@ -27,19 +30,19 @@ const initialState = {
export default function community (state = initialState, action) {
switch (action.type) {
case FETCH_COMMENTERS_REQUEST :
case FETCH_USERS_REQUEST :
return {
...state,
isFetchingPeople: true,
};
case FETCH_COMMENTERS_FAILURE :
case FETCH_USERS_FAILURE :
return {
...state,
isFetchingPeople: false,
errorPeople: action.error,
};
case FETCH_COMMENTERS_SUCCESS : {
const {accounts, type, page, count, limit, totalPages, ...rest} = action; // eslint-disable-line
case FETCH_USERS_SUCCESS : {
const {users, type, page, count, limit, totalPages, ...rest} = action; // eslint-disable-line
return {
...state,
isFetchingPeople: false,
@@ -49,27 +52,32 @@ export default function community (state = initialState, action) {
limitPeople: limit,
totalPagesPeople: totalPages,
...rest,
accounts, // Sets to normal array
users, // Sets to normal array
};
}
case SET_PAGE:
return {
...state,
pagePeople: action.page,
};
case SET_ROLE : {
const commenters = state.accounts;
const commenters = state.users;
const idx = commenters.findIndex((el) => el.id === action.id);
commenters[idx].roles[0] = action.role;
return {
...state,
accounts: commenters.map((id) => id),
users: commenters.map((id) => id),
};
}
case SET_COMMENTER_STATUS: {
const commenters = state.accounts;
const commenters = state.users;
const idx = commenters.findIndex((el) => el.id === action.id);
commenters[idx].status = action.status;
return {
...state,
accounts: commenters.map((id) => id),
users: commenters.map((id) => id),
};
}
@@ -101,6 +109,11 @@ export default function community (state = initialState, action) {
user: action.user,
rejectUsernameDialog: true
};
case SET_SEARCH_VALUE:
return {
...state,
searchValue: action.value,
};
default :
return state;
}
@@ -1,15 +0,0 @@
// this is initialized here because
// currently you have to reload the dashboard to get new stats
// cleaner updates are planned in the future.
const DASHBOARD_WINDOW_MINUTES = 5;
let then = new Date();
then.setMinutes(then.getMinutes() - DASHBOARD_WINDOW_MINUTES);
const initialState = {
windowStart: then.toISOString(),
windowEnd: new Date().toISOString(),
};
export default function dashboard (state = initialState, _action) {
return state;
}
+2 -4
View File
@@ -1,6 +1,5 @@
import auth from './auth';
import assets from './assets';
import dashboard from './dashboard';
import stories from './stories';
import configure from './configure';
import community from './community';
import moderation from './moderation';
@@ -13,11 +12,10 @@ import userDetail from './userDetail';
export default {
auth,
banUserDialog,
dashboard,
configure,
suspendUserDialog,
userDetail,
assets,
stories,
community,
moderation,
install,
@@ -7,6 +7,7 @@ const initialState = {
storySearchString: '',
shortcutsNoteVisible: 'show',
sortOrder: 'DESC',
selectedCommentId: '',
};
export default function moderation (state = initialState, action) {
@@ -51,6 +52,11 @@ export default function moderation (state = initialState, action) {
...state,
sortOrder: action.order,
};
case actions.MODERATION_SELECT_COMMENT:
return {
...state,
selectedCommentId: action.id,
};
default:
return state;
}
@@ -0,0 +1,73 @@
import * as actions from '../constants/stories';
import update from 'immutability-helper';
const initialState = {
assets: {
byId: {},
ids: [],
assets: []
},
searchValue: '',
criteria: {
asc: 'false',
filter: 'all',
},
};
export default function assets (state = initialState, action) {
switch (action.type) {
case actions.FETCH_ASSETS_SUCCESS: {
const assets = action.assets.reduce((prev, curr) => {
prev[curr.id] = curr;
return prev;
}, {});
return update(state, {
assets: {
totalPages: {$set: action.totalPages},
page: {$set: action.page},
byId: {$set: assets},
count: {$set: action.count},
ids: {$set: Object.keys(assets)},
},
});
}
case actions.UPDATE_ASSET_STATE_REQUEST:
return update(state, {
assets: {
byId: {
[action.id]: {
closedAt: {$set: action.closedAt},
},
},
},
});
case actions.UPDATE_ASSETS:
return update(state, {
assets: {
assets: {$set: action.assets},
},
});
case actions.SET_PAGE:
return {
...state,
page: action.page,
};
case actions.SET_SEARCH_VALUE:
return {
...state,
searchValue: action.value,
};
case actions.SET_CRITERIA:
return {
...state,
criteria: {
...state.criteria,
...action.criteria,
},
};
default:
return state;
}
}
@@ -41,6 +41,11 @@ export default function banUserDialog(state = initialState, action) {
...state,
selectedCommentIds: state.selectedCommentIds.filter((id) => id !== action.id),
};
case actions.SELECT_ALL_USER_DETAIL_COMMENT:
return {
...state,
selectedCommentIds: action.ids
};
default:
return state;
}
@@ -0,0 +1,4 @@
.container {
max-width: 1280px;
margin: 0 auto;
}
@@ -1,78 +1,18 @@
import React, {Component} from 'react';
import CommunityMenu from './CommunityMenu';
import People from './People';
import FlaggedAccounts from '../containers/FlaggedAccounts';
import RejectUsernameDialog from './RejectUsernameDialog';
import PropTypes from 'prop-types';
import styles from './Community.css';
import People from '../containers/People';
import CommunityMenu from './CommunityMenu';
import RejectUsernameDialog from './RejectUsernameDialog';
import FlaggedAccounts from '../containers/FlaggedAccounts';
export default class Community extends Component {
state = {
searchValue: '',
timer: null
};
onKeyDownHandler = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.search();
}
}
onSearchChange = (e) => {
const value = e.target.value;
this.setState((prevState) => {
prevState.searchValue = value;
clearTimeout(prevState.timer);
const fetchAccounts = this.props.fetchAccounts;
prevState.timer = setTimeout(() => {
fetchAccounts({value});
}, 350);
return prevState;
});
}
onHeaderClickHandler = (sort) => {
this.props.updateSorting(sort);
this.search();
}
onNewPageHandler = (page) => {
this.props.newPage(page);
this.search({page});
}
search(query = {}) {
const {community} = this.props;
this.props.fetchAccounts({
value: this.state.searchValue,
field: community.fieldPeople,
asc: community.ascPeople,
...query
});
}
getTabContent(searchValue, props) {
const {community} = props;
const activeTab = props.route.path === ':id' ? 'flagged' : props.route.path;
class Community extends Component {
renderTab() {
const {route, community, ...props} = this.props;
const activeTab = route.path === ':id' ? 'flagged' : route.path;
if (activeTab === 'people') {
return (
<People
isFetching={community.isFetchingPeople}
commenters={community.accounts}
searchValue={searchValue}
onSearchChange={this.onSearchChange}
error={community.errorPeople}
totalPages={community.totalPagesPeople}
page={community.pagePeople}
onKeyDown={this.onKeyDownHandler}
onHeaderClickHandler={this.onHeaderClickHandler}
onNewPageHandler={this.onNewPageHandler}
/>
);
return <People community={community} />;
}
return (
@@ -82,37 +22,36 @@ export default class Community extends Component {
root={this.props.root}
/>
<RejectUsernameDialog
open={community.rejectUsernameDialog}
handleClose={props.hideRejectUsernameDialog}
user={community.user}
open={community.rejectUsernameDialog}
rejectUsername={props.rejectUsername}
handleClose={props.hideRejectUsernameDialog}
/>
</div>
);
}
render() {
const {searchValue} = this.state;
const tab = this.getTabContent(searchValue, this.props);
const {root: {flaggedUsernamesCount}} = this.props;
return (
<div>
<div className="talk-admin-community">
<CommunityMenu flaggedUsernamesCount={flaggedUsernamesCount} />
<div>{tab}</div>
<div className={styles.container}>
{this.renderTab()}
</div>
</div>
);
}
}
Community.propTypes = {
community: PropTypes.object,
fetchAccounts: PropTypes.func,
hideRejectUsernameDialog: PropTypes.func,
updateSorting: PropTypes.func,
newPage: PropTypes.func,
route: PropTypes.object,
rejectUsername: PropTypes.func,
community: PropTypes.object,
rejectUsername: PropTypes.func.isRequired,
hideRejectUsernameDialog: PropTypes.func.isRequired,
data: PropTypes.object,
root: PropTypes.object
root: PropTypes.object,
};
export default Community;
@@ -13,11 +13,11 @@ const CommunityMenu = ({flaggedUsernamesCount = 0}) => {
<div className='mdl-tabs'>
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<div>
<Link to={flaggedPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
<Link to={flaggedPath} className={`mdl-tabs__tab ${styles.tab} talk-admin-nav-flagged-accounts`} activeClassName={styles.active}>
{t('community.flaggedaccounts')}
<CountBadge count={flaggedUsernamesCount} />
</Link>
<Link to={peoplePath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
<Link to={peoplePath} className={`mdl-tabs__tab ${styles.tab} talk-admin-nav-people`} activeClassName={styles.active}>
{t('community.people')}
</Link>
</div>
@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
import EmptyCard from 'coral-admin/src/components/EmptyCard';
import LoadMore from '../../../components/LoadMore';
@@ -23,7 +24,7 @@ class FlaggedAccounts extends React.Component {
const hasResults = users.nodes && !!users.nodes.length;
return (
<div className={styles.container}>
<div className={cn('talk-adnin-community-flagged-accounts', styles.container)}>
<div className={styles.mainFlaggedContent}>
{
hasResults
@@ -70,4 +71,15 @@ class FlaggedAccounts extends React.Component {
}
}
FlaggedAccounts.propTypes = {
users: PropTypes.object,
loadMore: PropTypes.func,
showBanUserDialog: PropTypes.func,
showSuspendUserDialog: PropTypes.func,
showRejectUsernameDialog: PropTypes.func,
approveUser: PropTypes.func,
me: PropTypes.object,
viewUserDetail: PropTypes.func,
};
export default FlaggedAccounts;
@@ -1,18 +1,14 @@
import React from 'react';
import styles from './FlaggedUser.css';
// TODO: Should not rely on plugin.
import PropTypes from 'prop-types';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
import {username} from 'talk-plugin-flags/helpers/flagReasons';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import ApproveButton from 'coral-admin/src/components/ApproveButton';
import RejectButton from 'coral-admin/src/components/RejectButton';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
// TODO: Should work with custom flags too.
const shortReasons = {
[username.other]: t('community.other'),
[username.spam]: t('community.spam_ads'),
@@ -21,7 +17,6 @@ const shortReasons = {
[username.impersonating]: t('community.impersonating'),
};
// Render a single user for the list
class User extends React.Component {
showSuspenUserDialog = () => this.props.showSuspendUserDialog({
@@ -50,12 +45,10 @@ class User extends React.Component {
} = this.props;
return (
<li
tabIndex={0}
className={cn(className, styles.root, {[styles.rootSelected]: selected})}
>
<div className={styles.container}>
<div className={styles.header}>
<li tabIndex={0}
className={cn(className, styles.root, {[styles.rootSelected]: selected})} >
<div className={cn('talk-admin-community-flagged-user', styles.container)}>
<div className={cn('talk-admin-community-flagged-user-header', styles.header)}>
<div className={styles.author}>
<button
onClick={this.viewAuthorDetail}
@@ -78,8 +71,7 @@ class User extends React.Component {
}
</div>
</div>
<div className={styles.body}>
<div className={cn('talk-admin-community-flagged-user-body', styles.body)}>
<div className={styles.flagged}>
<div className={styles.flaggedByCount}>
<i className={cn('material-icons', styles.flagIcon)}>flag</i>
@@ -127,9 +119,11 @@ class User extends React.Component {
<div className={styles.sideActions}>
<div className={styles.actions}>
<ApproveButton
className="talk-admin-flagged-user-approve-button"
onClick={this.approveUser}
/>
<RejectButton
className="talk-admin-flagged-user-reject-button"
onClick={this.showRejectUsernameDialog}
/>
</div>
@@ -141,4 +135,16 @@ class User extends React.Component {
}
}
User.propTypes = {
showSuspendUserDialog: PropTypes.func,
showBanUserDialog: PropTypes.func,
viewUserDetail: PropTypes.func,
showRejectUsernameDialog: PropTypes.func,
approveUser: PropTypes.func,
user: PropTypes.object,
className: PropTypes.string,
selected: PropTypes.bool,
me: PropTypes.object,
};
export default User;
@@ -1,35 +1,31 @@
import React from 'react';
import cn from 'classnames';
import styles from './styles.css';
import Table from '../containers/Table';
import {Pager, Icon} from 'coral-ui';
import Table from './Table';
import {Icon} from 'coral-ui';
import EmptyCard from '../../../components/EmptyCard';
import t from 'coral-framework/services/i18n';
import PropTypes from 'prop-types';
const tableHeaders = [
{
title: t('community.username_and_email'),
field: 'username'
},
{
title: t('community.account_creation_date'),
field: 'created_at'
},
{
title: t('community.status'),
field: 'status'
},
{
title: t('community.newsroom_role'),
field: 'role'
}
];
const People = (props) => {
const {
users = [],
searchValue,
onSearchChange,
onHeaderClickHandler,
onPageChange,
totalPages,
page,
setRole,
setCommenterStatus,
viewUserDetail,
} = props;
const hasResults = !!users.length;
const People = ({commenters, searchValue, onSearchChange, ...props}) => {
const hasResults = !!commenters.length;
return (
<div className={styles.container}>
<div className={cn(styles.container, 'talk-admin-community-people-container')}>
<div className={styles.leftColumn}>
<div className={styles.searchBox}>
<Icon name='search' className={styles.searchIcon}/>
@@ -47,28 +43,33 @@ const People = ({commenters, searchValue, onSearchChange, ...props}) => {
{
hasResults
? <Table
headers={tableHeaders}
commenters={commenters}
onHeaderClickHandler={props.onHeaderClickHandler}
users={users}
setRole={setRole}
viewUserDetail={viewUserDetail}
setCommenterStatus={setCommenterStatus}
onHeaderClickHandler={onHeaderClickHandler}
pageCount={totalPages}
onPageChange={onPageChange}
page={page}
/>
: <EmptyCard>{t('community.no_results')}</EmptyCard>
}
<Pager
totalPages={props.totalPages}
page={props.page}
onNewPageHandler={props.onNewPageHandler}
/>
</div>
</div>
);
};
People.propTypes = {
commenters: PropTypes.array,
onHeaderClickHandler: PropTypes.func,
users: PropTypes.array,
page: PropTypes.number.isRequired,
searchValue: PropTypes.string,
onSearchChange: PropTypes.func,
totalPages: PropTypes.number,
onNewPageHandler: PropTypes.func,
onPageChange: PropTypes.func,
setCommenterStatus: PropTypes.func.isRequired,
setRole: PropTypes.func.isRequired,
viewUserDetail: PropTypes.func.isRequired,
};
export default People;
@@ -1,6 +1,6 @@
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import {Dialog, Button} from 'coral-ui';
import styles from './RejectUsernameDialog.css';
@@ -29,12 +29,6 @@ class RejectUsernameDialog extends Component {
state = {email: '', stage: 0}
static propTypes = {
stage: PropTypes.number,
handleClose: PropTypes.func.isRequired,
rejectUsername: PropTypes.func.isRequired
}
componentDidMount() {
this.setState({email: t('reject_username.email_message_reject'), about: t('reject_username.username')});
}
@@ -76,7 +70,7 @@ class RejectUsernameDialog extends Component {
const {stage} = this.state;
return <Dialog
className={styles.suspendDialog}
className={cn(styles.suspendDialog, 'talk-reject-username-dialog')}
id="rejectUsernameDialog"
open={open}
onClose={handleClose}
@@ -96,15 +90,18 @@ class RejectUsernameDialog extends Component {
<div className={styles.emailContainer}>
<textarea
rows={5}
className={styles.emailInput}
className={cn(styles.emailInput, 'talk-reject-username-dialog-suspension-message')}
value={this.state.email}
onChange={this.onEmailChange}/>
</div>
</div>
}
<div className={styles.modalButtons}>
<div className={cn(styles.modalButtons, 'talk-reject-username-dialog-buttons')}>
{Object.keys(stages[stage].options).map((key, i) => (
<Button key={i} onClick={this.onActionClick(stage, i)}>
<Button
key={i}
className={cn('talk-reject-username-dialog-button', `talk-reject-username-dialog-button-${key}`)}
onClick={this.onActionClick(stage, i)} >
{t(stages[stage].options[key], t('reject_username.username'))}
</Button>
))}
@@ -114,4 +111,12 @@ class RejectUsernameDialog extends Component {
}
}
RejectUsernameDialog.propTypes = {
stage: PropTypes.number,
handleClose: PropTypes.func.isRequired,
rejectUsername: PropTypes.func.isRequired,
user: PropTypes.object,
open: PropTypes.bool,
};
export default RejectUsernameDialog;
@@ -2,14 +2,18 @@
width: 100%;
border-left: none;
border-right: none;
}
th {
font-size: 1.1em;
}
th.header {
font-size: 1.1em;
}
th:nth-child(2), th:nth-child(3) {
width: 100px;
}
th.header:hover {
cursor: pointer;
}
th.header:nth-child(2), th.header:nth-child(3) {
width: 100px;
}
.button {
@@ -1,68 +1,98 @@
import React from 'react';
import styles from '../components/Table.css';
import styles from './Table.css';
import t from 'coral-framework/services/i18n';
import PropTypes from 'prop-types';
import {Dropdown, Option} from 'coral-ui';
import {Paginate, Dropdown, Option} from 'coral-ui';
import cn from 'classnames';
const Table = ({headers, commenters, onHeaderClickHandler, onRoleChange, onCommenterStatusChange, viewUserDetail}) => (
<table className={`mdl-data-table ${styles.dataTable}`}>
<thead>
<tr>
{headers.map((header, i) =>(
<th
key={i}
className="mdl-data-table__cell--non-numeric"
scope="col"
onClick={() => onHeaderClickHandler({field: header.field})}>
{header.title}
</th>
))}
</tr>
</thead>
<tbody>
{commenters.map((row, i)=> (
<tr key={i}>
<td className="mdl-data-table__cell--non-numeric">
<button onClick={() => {viewUserDetail(row.id);}} className={cn(styles.username, styles.button)}>{row.username}</button>
<span className={styles.email}>{row.profiles.map(({id}) => id)}</span>
</td>
<td className="mdl-data-table__cell--non-numeric">
{row.created_at}
</td>
<td className="mdl-data-table__cell--non-numeric">
<Dropdown
value={row.status}
placeholder={t('community.status')}
onChange={(status) => onCommenterStatusChange(row.id, status)}>
<Option value={'ACTIVE'} label={t('community.active')} />
<Option value={'BANNED'} label={t('community.banned')} />
</Dropdown>
</td>
<td className="mdl-data-table__cell--non-numeric">
<Dropdown
value={row.roles[0] || ''}
placeholder={t('community.role')}
onChange={(role) => onRoleChange(row.id, role)}>
<Option value={''} label={t('community.none')} />
<Option value={'STAFF'} label={t('community.staff')} />
<Option value={'MODERATOR'} label={t('community.moderator')} />
<Option value={'ADMIN'} label={t('community.admin')} />
</Dropdown>
</td>
const headers = [
{
title: t('community.username_and_email'),
field: 'username'
},
{
title: t('community.account_creation_date'),
field: 'created_at'
},
{
title: t('community.status'),
field: 'status'
},
{
title: t('community.newsroom_role'),
field: 'role'
}
];
const Table = ({users, setRole, onHeaderClickHandler, setCommenterStatus, viewUserDetail, pageCount, page, onPageChange}) => (
<div>
<table className={`mdl-data-table ${styles.dataTable}`}>
<thead>
<tr>
{headers.map((header, i) =>(
<th
key={i}
className={cn('mdl-data-table__cell--non-numeric', styles.header)}
scope="col"
onClick={() => onHeaderClickHandler({field: header.field})}>
{header.title}
</th>
))}
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{users.map((row, i)=> (
<tr key={i} className="talk-admin-community-people-row">
<td className="mdl-data-table__cell--non-numeric">
<button onClick={() => {viewUserDetail(row.id);}} className={cn(styles.username, styles.button)}>{row.username}</button>
<span className={styles.email}>{row.profiles.map(({id}) => id)}</span>
</td>
<td className="mdl-data-table__cell--non-numeric">
{row.created_at}
</td>
<td className="mdl-data-table__cell--non-numeric">
<Dropdown
containerClassName="talk-admin-community-people-dd-status"
value={row.status}
placeholder={t('community.status')}
onChange={(status) => setCommenterStatus(row.id, status)}>
<Option value={'ACTIVE'} label={t('community.active')} />
<Option value={'BANNED'} label={t('community.banned')} />
</Dropdown>
</td>
<td className="mdl-data-table__cell--non-numeric">
<Dropdown
containerClassName="talk-admin-community-people-dd-role"
value={row.roles[0] || ''}
placeholder={t('community.role')}
onChange={(role) => setRole(row.id, role)}>
<Option value={''} label={t('community.none')} />
<Option value={'STAFF'} label={t('community.staff')} />
<Option value={'MODERATOR'} label={t('community.moderator')} />
<Option value={'ADMIN'} label={t('community.admin')} />
</Dropdown>
</td>
</tr>
))}
</tbody>
</table>
<Paginate
pageCount={pageCount}
page={page - 1}
onPageChange={onPageChange}
/>
</div>
);
Table.propTypes = {
headers: PropTypes.array,
commenters: PropTypes.array,
onHeaderClickHandler: PropTypes.func,
onRoleChange: PropTypes.func,
onCommenterStatusChange: PropTypes.func,
viewUserDetail: PropTypes.func,
users: PropTypes.array,
onHeaderClickHandler: PropTypes.func.isRequired,
setRole: PropTypes.func.isRequired,
setCommenterStatus: PropTypes.func.isRequired,
viewUserDetail: PropTypes.func.isRequired,
pageCount: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
};
export default Table;
@@ -1,65 +1,21 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import PropTypes from 'prop-types';
import {compose, gql} from 'react-apollo';
import withQuery from 'coral-framework/hocs/withQuery';
import {getDefinitionName} from 'coral-framework/utils';
import {withSetUserStatus, withRejectUsername} from 'coral-framework/graphql/mutations';
import FlaggedAccounts from '../containers/FlaggedAccounts';
import FlaggedUser from '../containers/FlaggedUser';
import {
fetchAccounts,
updateSorting,
newPage,
hideRejectUsernameDialog
} from '../../../actions/community';
import {hideRejectUsernameDialog} from '../../../actions/community';
import Community from '../components/Community';
class CommunityContainer extends Component {
componentWillMount() {
this.props.fetchAccounts({});
}
render() {
return <Community
fetchAccounts={this.props.fetchAccounts}
community={this.props.community}
hideRejectUsernameDialog={this.props.hideRejectUsernameDialog}
updateSorting={this.props.updateSorting}
newPage={this.props.newPage}
route={this.props.route}
rejectUsername={this.props.rejectUsername}
data={this.props.data}
root={this.props.root}
/>;
}
}
const mapStateToProps = (state) => ({
community: state.community,
});
CommunityContainer.propTypes = {
community: PropTypes.object,
fetchAccounts: PropTypes.func,
hideRejectUsernameDialog: PropTypes.func,
updateSorting: PropTypes.func,
newPage: PropTypes.func,
route: PropTypes.object,
rejectUsername: PropTypes.func,
data: PropTypes.object,
root: PropTypes.object
};
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
fetchAccounts,
hideRejectUsernameDialog,
updateSorting,
newPage,
}, dispatch);
const withData = withQuery(gql`
@@ -89,4 +45,4 @@ export default compose(
withSetUserStatus,
withRejectUsername,
withData
)(CommunityContainer);
)(Community);
@@ -0,0 +1,104 @@
import React from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import People from '../components/People';
import PropTypes from 'prop-types';
import {viewUserDetail} from '../../../actions/userDetail';
import {
fetchUsers,
updateSorting,
setPage,
hideRejectUsernameDialog,
setCommenterStatus,
setRole,
setSearchValue,
} from '../../../actions/community';
class PeopleContainer extends React.Component {
timer=null;
fetchUsers = (query = {}) => {
const {community} = this.props;
this.props.fetchUsers({
value: community.searchValue,
field: community.fieldPeople,
asc: community.ascPeople,
...query
});
}
componentWillMount() {
this.fetchUsers();
}
onKeyDownHandler = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.fetchUsers();
}
}
onSearchChange = (e) => {
const value = e.target.value;
this.props.setSearchValue(value);
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.fetchUsers({value});
}, 350);
}
onHeaderClickHandler = (sort) => {
this.props.updateSorting(sort);
this.fetchUsers();
}
onPageChange = ({selected}) => {
const page = selected + 1;
this.props.setPage(page);
this.fetchUsers({page});
}
render() {
return <People
users={this.props.community.users}
searchValue={this.props.community.searchValue}
onSearchChange={this.onSearchChange}
onHeaderClickHandler={this.onHeaderClickHandler}
onPageChange={this.onPageChange}
totalPages={this.props.community.totalPagesPeople}
setCommenterStatus={this.props.setCommenterStatus}
setRole={this.props.setRole}
page={this.props.community.pagePeople}
viewUserDetail={this.props.viewUserDetail}
/>;
}
}
PeopleContainer.propTypes = {
setPage: PropTypes.func,
fetchUsers: PropTypes.func,
updateSorting: PropTypes.func,
setRole: PropTypes.func.isRequired,
setCommenterStatus: PropTypes.func.isRequired,
setSearchValue: PropTypes.func.isRequired,
viewUserDetail: PropTypes.func.isRequired,
community: PropTypes.object,
};
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
setPage,
fetchUsers,
updateSorting,
hideRejectUsernameDialog,
setCommenterStatus,
setRole,
viewUserDetail,
setSearchValue,
}, dispatch);
export default connect(null, mapDispatchToProps)(PeopleContainer);
@@ -1,43 +0,0 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {setRole, setCommenterStatus} from '../../../actions/community';
import Table from '../components/Table';
import {viewUserDetail} from '../../../actions/userDetail';
import PropTypes from 'prop-types';
class TableContainer extends Component {
constructor (props) {
super(props);
}
render () {
return <Table
{...this.props}
onRoleChange={this.props.setRole}
onCommenterStatusChange={this.props.setCommenterStatus}
commenters={this.props.commenters}
/>;
}
}
TableContainer.propTypes = {
setRole: PropTypes.func,
setCommenterStatus: PropTypes.func,
commenters: PropTypes.array,
};
const mapStateToProps = (state) => ({
commenters: state.community.accounts,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
setCommenterStatus,
setRole,
viewUserDetail,
}, dispatch);
export default connect(mapStateToProps, mapDispatchToProps)(TableContainer);
@@ -39,7 +39,7 @@ class ModerationSettings extends React.Component {
};
render() {
const {settings, data, root} = this.props;
const {settings, data, root, updatePending, errors} = this.props;
return (
<ConfigurePage
@@ -74,6 +74,8 @@ class ModerationSettings extends React.Component {
fill="adminModerationSettings"
data={data}
queryData={{root, settings}}
updatePending={updatePending}
errors={errors}
/>
</ConfigurePage>
);
@@ -82,6 +84,7 @@ class ModerationSettings extends React.Component {
ModerationSettings.propTypes = {
updatePending: PropTypes.func.isRequired,
errors: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
@@ -53,3 +53,6 @@
.autoCloseWrapper {
display: flex;
}
@@ -100,7 +100,7 @@ class StreamSettings extends React.Component {
};
render() {
const {settings, data, root, errors} = this.props;
const {settings, data, root, errors, updatePending} = this.props;
return (
<ConfigurePage
@@ -180,22 +180,24 @@ class StreamSettings extends React.Component {
onCheckbox={this.updateAutoClose}
title={t('configure.close_after')}
>
<Textfield
type='number'
pattern='[0-9]+'
style={{width: 50}}
onChange={this.updateClosedTimeout}
value={getTimeoutAmount(settings.closedTimeout)}
label={t('configure.closed_comments_label')} />
<div className={styles.configTimeoutSelect}>
<SelectField
label="comments closed time window"
value={getTimeoutMeasure(settings.closedTimeout)}
onChange={this.updateClosedTimeoutMeasure}>
<Option value={'hours'}>{t('configure.hours')}</Option>
<Option value={'days'}>{t('configure.days')}</Option>
<Option value={'weeks'}>{t('configure.weeks')}</Option>
</SelectField>
<div className={styles.autoCloseWrapper}>
<Textfield
type='number'
pattern='[0-9]+'
style={{width: 50}}
onChange={this.updateClosedTimeout}
value={getTimeoutAmount(settings.closedTimeout)}
label={t('configure.closed_comments_label')} />
<div className={styles.configTimeoutSelect}>
<SelectField
label="comments closed time window"
value={getTimeoutMeasure(settings.closedTimeout)}
onChange={this.updateClosedTimeoutMeasure}>
<Option value={'hours'}>{t('configure.hours')}</Option>
<Option value={'days'}>{t('configure.days')}</Option>
<Option value={'weeks'}>{t('configure.weeks')}</Option>
</SelectField>
</div>
</div>
</ConfigureCard>
{/* the above card should be the last one if at all possible because of z-index issues with the selects */}
@@ -203,6 +205,8 @@ class StreamSettings extends React.Component {
fill="adminStreamSettings"
data={data}
queryData={{root, settings}}
updatePending={updatePending}
errors={errors}
/>
</ConfigurePage>
);
@@ -31,7 +31,7 @@ class TechSettings extends React.Component {
};
render() {
const {settings, data, root} = this.props;
const {settings, data, root, errors, updatePending} = this.props;
return (
<ConfigurePage
title={t('configure.tech_settings')}
@@ -51,6 +51,8 @@ class TechSettings extends React.Component {
fill="adminTechSettings"
data={data}
queryData={{root, settings}}
updatePending={updatePending}
errors={errors}
/>
</ConfigurePage>
);
@@ -59,6 +61,7 @@ class TechSettings extends React.Component {
TechSettings.propTypes = {
updatePending: PropTypes.func.isRequired,
errors: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
@@ -2,38 +2,20 @@ import React, {Component} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose, gql} from 'react-apollo';
import withQuery from 'coral-framework/hocs/withQuery';
import {withQuery, withMergedSettings} from 'coral-framework/hocs';
import {Spinner} from 'coral-ui';
import {notify} from 'coral-framework/actions/notification';
import PropTypes from 'prop-types';
import assignWith from 'lodash/assignWith';
import {withUpdateSettings} from 'coral-framework/graphql/mutations';
import {getErrorMessages, getDefinitionName} from 'coral-framework/utils';
import StreamSettings from './StreamSettings';
import TechSettings from './TechSettings';
import ModerationSettings from './ModerationSettings';
import {clearPending, setActiveSection} from '../../../actions/configure';
import Configure from '../components/Configure';
// Like lodash merge but does not recurse into arrays.
const mergeExcludingArrays = (objValue, srcValue) => {
if (typeof srcValue === 'object' && !Array.isArray(srcValue)) {
return assignWith({}, objValue, srcValue, mergeExcludingArrays);
}
return srcValue;
};
class ConfigureContainer extends Component {
// Merge current settings with pending settings.
getMergedSettings = (props = this.props) => {
return assignWith({}, props.root.settings, props.pending, mergeExcludingArrays);
}
// Cached merged settings.
mergedSettings = this.getMergedSettings();
savePending = async () => {
try {
await this.props.updateSettings(this.props.pending);
@@ -44,14 +26,6 @@ class ConfigureContainer extends Component {
}
};
componentWillReceiveProps(nextProps) {
// Recalculate merged settings when necessary.
if (this.props.root.settings !== nextProps.root.settings || this.props.pending !== nextProps.pending) {
this.mergedSettings = this.getMergedSettings(nextProps);
}
}
render () {
if(this.props.data.loading) {
return <Spinner/>;
@@ -62,7 +36,7 @@ class ConfigureContainer extends Component {
auth={this.props.auth}
data={this.props.data}
root={this.props.root}
settings={this.mergedSettings}
settings={this.props.mergedSettings}
canSave={this.props.canSave}
savePending={this.savePending}
setActiveSection={this.props.setActiveSection}
@@ -112,6 +86,7 @@ export default compose(
withUpdateSettings,
withConfigureQuery,
connect(mapStateToProps, mapDispatchToProps),
withMergedSettings('root.settings', 'pending', 'mergedSettings'),
)(ConfigureContainer);
ConfigureContainer.propTypes = {
@@ -124,5 +99,6 @@ ConfigureContainer.propTypes = {
root: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
pending: PropTypes.object.isRequired,
mergedSettings: PropTypes.object.isRequired,
activeSection: PropTypes.string.isRequired,
};
@@ -10,6 +10,10 @@ const slots = [
'adminModerationSettings',
];
const mapStateToProps = (state) => ({
errors: state.configure.errors,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
updatePending,
@@ -36,5 +40,5 @@ export default compose(
}
`
}),
connect(null, mapDispatchToProps),
connect(mapStateToProps, mapDispatchToProps),
)(ModerationSettings);
@@ -10,6 +10,10 @@ const slots = [
'adminTechSettings',
];
const mapStateToProps = (state) => ({
errors: state.configure.errors,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
updatePending,
@@ -33,5 +37,5 @@ export default compose(
}
`
}),
connect(null, mapDispatchToProps),
connect(mapStateToProps, mapDispatchToProps),
)(TechSettings);
@@ -1,47 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Link} from 'react-router';
import styles from './Widget.css';
import t from 'coral-framework/services/i18n';
const ActivityWidget = ({assets}) => {
return (
<div className={styles.widget}>
<h2 className={styles.heading}>{t('dashboard.most_conversations')}</h2>
<div className={styles.widgetHead}>
<p>{t('streams.article')}</p>
<p>{t('dashboard.comment_count')}</p>
</div>
<div className={styles.widgetTable}>
{
assets.length
? assets.map((asset) => {
return (
<div className={styles.rowLinkify} key={asset.id}>
<Link className={styles.linkToModerate} to={`/admin/moderate/${asset.id}`}>Moderate</Link>
<p className={styles.widgetCount}>{asset.commentCount}</p>
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
<p className={styles.assetTitle}>{asset.title}</p>
</a>
<p className={styles.lede}>{asset.author} Published: {new Date(asset.created_at).toLocaleDateString()}</p>
</div>
);
})
: <div className={styles.rowLinkify}>{t('dashboard.no_activity')}</div>
}
</div>
</div>
);
};
ActivityWidget.propTypes = {
assets: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
url: PropTypes.string,
commentCount: PropTypes.number,
author: PropTypes.string,
created_at: PropTypes.string
})).isRequired
};
export default ActivityWidget;
@@ -1,93 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './Dashboard.css';
import {Icon} from 'coral-ui';
import t from 'coral-framework/services/i18n';
const refreshIntervalSeconds = 60 * 5;
// TODO: refactor out storage code into redux.
class CountdownTimer extends React.Component {
static contextTypes = {
storage: PropTypes.object,
};
static propTypes = {
handleTimeout: PropTypes.func.isRequired
}
constructor (props, context) {
super(props, context);
const {storage} = context;
try {
if (storage && storage.getItem('coral:dashboardNote') === null) {
storage.setItem('coral:dashboardNote', 'show');
}
} catch (e) {
// above will fail in Private Mode in some browsers.
}
this.state = {
secondsUntilRefresh: refreshIntervalSeconds,
dashboardNote: (storage && storage.getItem('coral:dashboardNote')) || 'show'
};
}
componentWillMount () {
this.interval = setInterval(() => { // the countdown timer
let nextCount = this.state.secondsUntilRefresh - 1;
if (nextCount < 0) {
nextCount = refreshIntervalSeconds;
return this.props.handleTimeout();
}
this.setState({secondsUntilRefresh: nextCount});
}, 1000);
}
componentWillUnmount () {
window.clearInterval(this.interval);
}
formatTime = () => {
const minutes = Math.floor(this.state.secondsUntilRefresh / 60);
let seconds = (this.state.secondsUntilRefresh % 60).toString();
if (seconds.length < 2) {
seconds = `0${seconds}`;
}
return `${minutes}:${seconds}`;
}
dismissNote = () => {
const {storage} = this.context;
try {
if (storage) {
storage.setItem('coral:dashboardNote', 'hide');
}
} catch (e) {
// when setItem fails in Safari Private mode
this.setState({dashboardNote: 'hide'});
}
}
render () {
const {storage} = this.context;
const hideReloadNote = (storage && storage.getItem('coral:dashboardNote') === 'hide') ||
this.state.dashboardNote === 'hide'; // for Safari Incognito
return (
<p
style={{display: hideReloadNote ? 'none' : 'block'}}
className={styles.autoUpdate}
onClick={this.dismissNote}>
<b>×</b>
<Icon name='timer' /> <strong>{t('dashboard.next_update', this.formatTime())}</strong> {t('dashboard.auto_update')}
</p>
);
}
}
export default CountdownTimer;
@@ -1,40 +0,0 @@
/**
* @TODO: deprecated as this file contains styles from multiple components. Please refactor.
*/
.Dashboard {
display: flex;
max-width: 1280px;
margin: 0 auto;
}
.heading {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
}
.autoUpdate {
background-color: #d5d5d5;
padding: 3px 10px 10px 10px;
margin-bottom: 0;
i {
position: relative;
top: 7px;
}
b {
float: right;
border-radius: 20px;
cursor: pointer;
background-color: #c0c0c0;
width: 30px;
height: 30px;
text-align: center;
top: 4px;
position: relative;
line-height: 1.7em;
font-size: 1.3em;
}
}
@@ -1,15 +0,0 @@
import React from 'react';
import FlagWidget from './FlagWidget';
import ActivityWidget from './ActivityWidget';
import CountdownTimer from './CountdownTimer';
import styles from './Dashboard.css';
export default ({root: {assetsByActivity, assetsByFlag}, reloadData}) => (
<div>
<CountdownTimer handleTimeout={reloadData} />
<div className={styles.Dashboard}>
<FlagWidget assets={assetsByFlag} />
<ActivityWidget assets={assetsByActivity} />
</div>
</div>
);
@@ -1,54 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Link} from 'react-router';
import styles from './Widget.css';
import t from 'coral-framework/services/i18n';
const FlagWidget = ({assets}) => {
return (
<div className={styles.widget}>
<h2 className={styles.heading}>{t('dashboard.most_flags')}</h2>
<div className={styles.widgetHead}>
<p>{t('streams.article')}</p>
<p>{t('dashboard.flags')}</p>
</div>
<div className={styles.widgetTable}>
{
assets.length
? assets.map((asset) => {
let flagSummary = null;
if (asset.action_summaries) {
flagSummary = asset.action_summaries.find((s) => s.__typename === 'FlagAssetActionSummary');
}
return (
<div className={styles.rowLinkify} key={asset.id}>
<Link className={styles.linkToModerate} to={`/admin/moderate/reported/${asset.id}`}>Moderate</Link>
<p className={styles.widgetCount}>{flagSummary ? flagSummary.actionCount : 0}</p>
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
<p className={styles.assetTitle}>{asset.title}</p>
</a>
<p className={styles.lede}>{asset.author} Published: {new Date(asset.created_at).toLocaleDateString()}</p>
</div>
);
})
: <div className={styles.rowLinkify}>{t('dashboard.no_flags')}</div>
}
</div>
</div>
);
};
FlagWidget.propTypes = {
assets: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
url: PropTypes.string,
action_summaries: PropTypes.array,
author: PropTypes.string,
created_at: PropTypes.string
})).isRequired
};
export default FlagWidget;
@@ -1,49 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Link} from 'react-router';
import styles from './Widget.css';
import t from 'coral-framework/services/i18n';
const LikeWidget = ({assets}) => {
return (
<div className={styles.widget}>
<h2 className={styles.heading}>Articles with the most likes</h2>
<div className={styles.widgetHead}>
<p>{t('streams.article')}</p>
<p>{t('modqueue.likes')}</p>
</div>
<div className={styles.widgetTable}>
{
assets.length
? assets.map((asset) => {
const likeSummary = asset.action_summaries.find((s) => s.type === 'LikeAssetActionSummary');
return (
<div className={styles.rowLinkify} key={asset.id}>
<Link className={styles.linkToModerate} to={`/admin/moderate/${asset.id}`}>Moderate</Link>
<p className={styles.widgetCount}>{likeSummary ? likeSummary.actionCount : 0}</p>
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
<p className={styles.assetTitle}>{asset.title}</p>
</a>
<p className={styles.lede}>{asset.author} Published: {new Date(asset.created_at).toLocaleDateString()}</p>
</div>
);
})
: <div className={styles.rowLinkify}>{t('dashboard.no_likes')}</div>
}
</div>
</div>
);
};
LikeWidget.propTypes = {
assets: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
url: PropTypes.string,
action_summaries: PropTypes.array,
author: PropTypes.string,
created_at: PropTypes.string
})).isRequired
};
export default LikeWidget;
@@ -1,38 +0,0 @@
import React from 'react';
import ModerationQueue from 'coral-admin/src/containers/ModerationQueue/ModerationQueue';
import styles from './Widget.css';
import BanUserDialog from 'coral-admin/src/components/BanUserDialog';
import t from 'coral-framework/services/i18n';
const MostLikedCommentsWidget = (props) => {
const {
comments,
moderation,
settings,
handleBanUser,
showBanUserDialog,
hideBanUserDialog,
acceptComment,
rejectComment
} = props;
return (
<div className={styles.widget}>
<h2 className={styles.heading}>{t('most_liked_comments')}</h2>
<ModerationQueue
comments={comments}
suspectWords={settings.wordlist.suspect}
showBanUserDialog={showBanUserDialog}
acceptComment={acceptComment}
rejectComment={rejectComment} />
<BanUserDialog
open={moderation.banDialog}
user={moderation.user}
handleClose={hideBanUserDialog}
handleBanUser={handleBanUser} />
</div>
);
};
export default MostLikedCommentsWidget;
@@ -1,110 +0,0 @@
/**
* @TODO: deprecated as this file contains styles from multiple components. Please refactor.
*/
:root {
--row-height: 60px;
}
.widget {
margin: 10px 5px 5px 5px;
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2);
flex: 1;
background-color: white;
box-sizing: border-box;
}
.widget * {
box-sizing: border-box;
}
.heading {
margin: 0;
padding-left: 10px;
font-size: 1.3rem;
font-weight: 600;
color: #2c2c2c;
}
.widgetTable {
height: calc(var(--row-height) * 10);
}
.widgetTable + div:after {
content: '';
clear: both;
display: block;
}
.widgetHead p {
color: #2c2c2c;
font-weight: 500;
padding: 10px;
text-align: left;
text-transform: capitalize;
display: inline-block;
box-sizing: border-box;
margin-bottom: 0;
}
.widgetHead p:last-child {
float: right;
margin-right: 100px;
}
.rowLinkify {
border-bottom: 1px solid lightgrey;
color: #555;
height: var(--row-height);
padding: 10px;
transition: background-color 200ms;
}
.rowLinkify:last-child {
border-bottom: none;
}
.rowLinkify:hover {
background-color: #f8f8f8;
pointer: default;
}
.linkToAsset {
display: inline-block;
text-decoration: none;
}
.linkToModerate {
background-color: #BDBDBD;
padding: 10px 14px;
text-decoration: none;
color: black;
float: right;
margin-left: 15px;
transition: background-color 200ms;
}
.linkToModerate:hover {
background-color: #9E9E9E;
}
.lede {
font-size: 0.9em;
color: #aaa;
}
.assetTitle {
color: #555;
text-decoration: none;
font-size: 1.2em;
font-weight: 500;
margin: 0;
}
.widgetCount {
color: #555;
font-size: 1.3em;
font-weight: 400;
float: right;
margin-top: 7px;
}
@@ -1,65 +0,0 @@
import React from 'react';
import {connect} from 'react-redux';
import Dashboard from '../components/Dashboard';
import {compose, gql} from 'react-apollo';
import withQuery from 'coral-framework/hocs/withQuery';
import {Spinner} from 'coral-ui';
class DashboardContainer extends React.Component {
reloadData = () => {
this.props.data.refetch();
}
render () {
if (this.props.data.loading) {
return <Spinner />;
}
return <Dashboard {...this.props} reloadData={this.reloadData} />;
}
}
export const witDashboardQuery = withQuery(gql`
query CoralAdmin_Dashboard($from: Date!, $to: Date!) {
assetsByFlag: assetMetrics(from: $from, to: $to, sortBy: FLAG) {
...CoralAdmin_Metrics
}
assetsByActivity: assetMetrics(from: $from, to: $to, sortBy: ACTIVITY) {
...CoralAdmin_Metrics
}
}
fragment CoralAdmin_Metrics on Asset {
id
title
url
author
created_at
commentCount
action_summaries {
actionCount
actionableItemCount
}
}
`, {
options: ({windowStart, windowEnd}) => {
return {
variables: {
from: windowStart,
to: windowEnd,
}
};
}
});
const mapStateToProps = (state) => {
return {
windowStart: state.dashboard.windowStart,
windowEnd: state.dashboard.windowEnd,
};
};
export default compose(
connect(mapStateToProps),
witDashboardQuery,
)(DashboardContainer);
@@ -1 +0,0 @@
export {default} from './containers/Dashboard.js';
@@ -0,0 +1,25 @@
import React from 'react';
import {Spinner} from 'coral-ui';
import PropTypes from 'prop-types';
/**
* AutoLoadMore with call `loadMore` the moment it is rendered and shows a Spinner.
*/
class AutoLoadMore extends React.Component {
componentDidMount() {
if(!this.props.loading) {
this.props.loadMore();
}
}
render() {
return <Spinner />;
}
}
AutoLoadMore.propTypes = {
loading: PropTypes.bool.isRequired,
loadMore: PropTypes.func.isRequired,
};
export default AutoLoadMore;
@@ -10,7 +10,9 @@
position: relative;
transition: all 200ms;
padding: 10px 0;
margin-top: 13px;
min-height: 0;
outline: 0;
/*
Fix rendering issues in Safari by promoting this
@@ -25,6 +27,10 @@
}
}
.dangling {
background-color: #efefef;
}
.container {
padding: 0 14px;
}
@@ -20,6 +20,16 @@ import t, {timeago} from 'coral-framework/services/i18n';
class Comment extends React.Component {
ref = null;
handleRef = (ref) => this.ref = ref;
handleFocusOrClick = () => {
if (!this.props.selected) {
this.props.selectComment();
}
};
showSuspendUserDialog = () => {
const {comment, showSuspendUserDialog} = this.props;
return showSuspendUserDialog({
@@ -55,6 +65,12 @@ class Comment extends React.Component {
: this.props.rejectComment({commentId: this.props.comment.id})
);
componentDidUpdate(prev) {
if (!prev.selected && this.props.selected) {
this.ref.focus();
}
}
render() {
const {
comment,
@@ -65,6 +81,8 @@ class Comment extends React.Component {
root: {settings},
currentUserId,
currentAsset,
clearHeightCache,
dangling,
} = this.props;
const selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
@@ -73,8 +91,11 @@ class Comment extends React.Component {
return (
<li
tabIndex={0}
className={cn(className, 'mdl-card', selectionStateCSS, styles.root, {[styles.selected]: selected})}
className={cn(className, 'mdl-card', selectionStateCSS, styles.root, {[styles.selected]: selected, [styles.dangling]: dangling}, 'talk-admin-moderate-comment')}
id={`comment_${comment.id}`}
onClick={this.handleFocusOrClick}
ref={this.handleRef}
onFocus={this.handleFocusOrClick}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
@@ -95,7 +116,7 @@ class Comment extends React.Component {
: null
}
{currentUserId !== comment.user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenu icon="not_interested" className="talk-admin-moderate-comment-actions-menu">
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={this.showSuspendUserDialog}>
@@ -114,6 +135,7 @@ class Comment extends React.Component {
<Slot
fill="adminCommentInfoBar"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
</div>
@@ -145,6 +167,7 @@ class Comment extends React.Component {
<Slot
fill="adminCommentContent"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
<div className={styles.sideActions}>
@@ -166,6 +189,7 @@ class Comment extends React.Component {
<Slot
fill="adminSideActions"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
</div>
@@ -176,6 +200,7 @@ class Comment extends React.Component {
data={data}
root={root}
comment={comment}
clearHeightCache={clearHeightCache}
/>
</li>
);
@@ -185,12 +210,16 @@ class Comment extends React.Component {
Comment.propTypes = {
viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
selectComment: PropTypes.func,
rejectComment: PropTypes.func.isRequired,
onClick: PropTypes.func,
className: PropTypes.string,
dangling: PropTypes.bool,
currentAsset: PropTypes.object,
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
currentUserId: PropTypes.string.isRequired,
clearHeightCache: PropTypes.func,
comment: PropTypes.shape({
id: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
@@ -12,15 +12,10 @@ import Slot from 'coral-framework/components/Slot';
import ViewOptions from './ViewOptions';
class Moderation extends Component {
constructor(props) {
super(props);
const comments = this.getComments(props);
this.state = {
selectedCommentId: comments[0] ? comments[0].id : null,
};
}
state = {
isLoadingMore: false,
};
componentWillMount() {
const {toggleModal, singleView} = this.props;
@@ -30,10 +25,10 @@ class Moderation extends Component {
key('esc', () => toggleModal(false));
key('ctrl+f', () => this.openSearch());
key('t', () => this.nextQueue());
key('j', () => this.select(true));
key('k', () => this.select(false));
key('f', () => this.moderate(false));
key('d', () => this.moderate(true));
this.getMenuItems()
.forEach((menuItem, idx) => key(`${idx + 1}`, () => this.selectQueue(menuItem)));
}
onClose = () => {
@@ -41,20 +36,23 @@ class Moderation extends Component {
}
nextQueue = () => {
const queueConfig = this.props.queueConfig;
const activeTab = this.props.activeTab;
const assetId = this.props.data.variables.asset_id;
const menuItems = Object.keys(queueConfig).map((queue) => ({
key: queue
}));
const menuItems = this.getMenuItems();
const activeTabIndex = menuItems.findIndex((item) => item.key === activeTab);
const activeTabIndex = menuItems.findIndex((item) => item === activeTab);
const nextQueueIndex = (activeTabIndex === menuItems.length - 1) ? 0 : activeTabIndex + 1;
this.props.router.push(this.props.getModPath(menuItems[nextQueueIndex].key, assetId));
this.selectQueue(menuItems[nextQueueIndex]);
}
selectQueue = (key) => {
const assetId = this.props.data.variables.asset_id;
this.props.router.push(this.props.getModPath(key, assetId));
}
getMenuItems = () => Object.keys(this.props.queueConfig);
closeSearch = () => {
const {toggleStorySearch} = this.props;
toggleStorySearch(false);
@@ -69,8 +67,7 @@ class Moderation extends Component {
}
moderate = (accept) => {
const {acceptComment, rejectComment} = this.props;
const {selectedCommentId} = this.state;
const {acceptComment, rejectComment, moderation: {selectedCommentId}} = this.props;
// Accept or reject only if there's a selected comment
if(selectedCommentId != null){
@@ -91,74 +88,16 @@ class Moderation extends Component {
return root[activeTab].nodes;
}
scrollTo = (toId, smooth = true) =>
document.querySelector(`#comment_${toId}`).scrollIntoView(smooth ? {behavior: 'smooth'} : {});
select = async (next, props = this.props, selectedCommentId = this.state.selectedCommentId) => {
const comments = this.getComments(props);
// No comments to be selected.
if (comments.length === 0){
return;
}
// Find current index if we have a selected comment.
const index = selectedCommentId
? comments.findIndex((comment) => comment.id === selectedCommentId)
: null;
if (next) {
// Grab first one if we don't have a selected comment yet.
if (!selectedCommentId) {
this.setState({selectedCommentId: comments[0].id}, () => this.scrollTo(comments[0].id));
return;
}
// Select next one when we still have more comments left.
if (index < comments.length - 1) {
this.setState({selectedCommentId: comments[index + 1].id}, () => this.scrollTo(comments[index + 1].id));
return;
} else {
// We hit the end of the list, load more comments if we have.
if (comments.length < this.getActiveTabCount()) {
const res = await this.loadMore();
// If `loadMore` was already in progress, res would be false.
if (res) {
// Select next comment after loading has completed.
this.select(true);
}
}
return;
}
} else {
// We have no selected comment, so just skip it.
if (!selectedCommentId) {
return;
}
// If we still have previous comments take the one before.
if (index > 0) {
this.setState({selectedCommentId: comments[index - 1].id}, () => this.scrollTo(comments[index - 1].id));
return;
}
}
}
loadMore = async () => {
if (!this.isLoadingMore) {
this.isLoadingMore = true;
if (!this.state.isLoadingMore) {
this.setState({isLoadingMore: true});
try {
const result = await this.props.loadMore(this.props.activeTab);
this.isLoadingMore = false;
this.setState({isLoadingMore: false});
return result;
}
catch (e) {
this.isLoadingMore = false;
this.setState({isLoadingMore: false});
throw e;
}
}
@@ -171,52 +110,10 @@ class Moderation extends Component {
key.unbind('esc');
key.unbind('ctrl+f');
key.unbind('t');
key.unbind('j');
key.unbind('k');
key.unbind('f');
key.unbind('d');
}
componentWillReceiveProps(nextProps) {
if (this.props.activeTab !== nextProps.activeTab) {
// Reset selection when changing tabs.
this.select(true, nextProps, null);
} else {
// Detect if comment has left the queue and find next or prev selected comment to set it
// as the new selectedCommentId.
const prevComments = this.getComments(this.props);
const nextComments = this.getComments(nextProps);
if (nextComments.length < prevComments.length) {
// Comments have changed, now check if our selected comment has left the queue.
if (
this.state.selectedCommentId &&
!nextComments.some((comment) => comment.id === this.state.selectedCommentId)
) {
// Determine a comment to select.
const prevIndex = prevComments.findIndex((comment) => comment.id === this.state.selectedCommentId);
if (prevIndex !== prevComments.length - 1) {
this.setState({selectedCommentId: prevComments[prevIndex + 1].id});
} else if(prevIndex > 0) {
this.setState({selectedCommentId: prevComments[prevIndex - 1].id});
} else {
this.setState({selectedCommentId: null});
}
}
}
}
}
componentDidUpdate(prevProps) {
// Scroll to comment when changing from single wiew to normal view.
if (prevProps.moderation.singleView !== this.props.moderation.singleView && this.state.selectedCommentId) {
this.scrollTo(this.state.selectedCommentId, false);
}
this.getMenuItems()
.forEach((menuItem, idx) => key.unbind(`${idx + 1}`));
}
render () {
@@ -259,23 +156,29 @@ class Moderation extends Component {
root={this.props.root}
currentAsset={asset}
comments={comments.nodes}
hasNextPage={comments.hasNextPage}
activeTab={activeTab}
singleView={moderation.singleView}
selectedCommentId={this.state.selectedCommentId}
selectedCommentId={moderation.selectedCommentId}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
loadMore={this.loadMore}
commentBelongToQueue={this.props.commentBelongToQueue}
isLoadingMore={this.state.isLoadingMore}
commentCount={activeTabCount}
currentUserId={this.props.auth.user.id}
viewUserDetail={viewUserDetail}
selectCommentId={props.selectCommentId}
cleanUpQueue={props.cleanUpQueue}
/>
<ModerationKeysModal
hideShortcutsNote={props.hideShortcutsNote}
shortcutsNoteVisible={moderation.shortcutsNoteVisible}
open={moderation.modalOpen}
onClose={this.onClose}
queueCount={this.getMenuItems().length}
/>
</div>
<StorySearch
@@ -299,12 +202,15 @@ class Moderation extends Component {
Moderation.propTypes = {
viewUserDetail: PropTypes.func.isRequired,
toggleModal: PropTypes.func.isRequired,
selectedCommentId: PropTypes.string,
toggleStorySearch: PropTypes.func.isRequired,
getModPath: PropTypes.func.isRequired,
cleanUpQueue: PropTypes.func.isRequired,
storySearchChange: PropTypes.func.isRequired,
moderation: PropTypes.object.isRequired,
auth: PropTypes.object.isRequired,
queueConfig: PropTypes.object.isRequired,
commentBelongToQueue: PropTypes.func.isRequired,
handleCommentChange: PropTypes.func.isRequired,
setSortOrder: PropTypes.func.isRequired,
showBanUserDialog: PropTypes.func.isRequired,
@@ -0,0 +1,3 @@
.root {
height: 100%;
}
@@ -2,29 +2,15 @@
padding: 8px 0;
list-style: none;
display: block;
min-height: 650px;
margin-top: 16px;
}
:global(html) {
height: inherit;
}
.list {
padding: 0;
margin: 0;
}
.commentLeave {
opacity: 1.0;
}
.commentLeaveActive {
opacity: 0;
transition: opacity 800ms;
}
.commentEnter {
opacity: 0;
}
.commentEnterActive {
opacity: 1.0;
transition: opacity 800ms;
outline: none;
}
@@ -4,26 +4,29 @@ import PropTypes from 'prop-types';
import Comment from '../containers/Comment';
import styles from './ModerationQueue.css';
import EmptyCard from '../../../components/EmptyCard';
import LoadMore from '../../../components/LoadMore';
import AutoLoadMore from './AutoLoadMore';
import ViewMore from './ViewMore';
import t from 'coral-framework/services/i18n';
import {CSSTransitionGroup} from 'react-transition-group';
import {WindowScroller, CellMeasurer, CellMeasurerCache, List} from 'react-virtualized';
import throttle from 'lodash/throttle';
import key from 'keymaster';
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
// resetCursors will return the id cursors of the first and second comment of
// the current comment list. The cursors are used to dertermine which
// comments to show. The spare cursor functions as a backup in case one
// of the comments gets deleted.
// the current comment The spare cursor functions as a backup in case one
// of the comments gets deleted. Additionally the new view based on the new
// cursors are also returned.
function resetCursors(state, props) {
let idCursors = [];
if (props.comments && props.comments.length) {
const idCursors = [props.comments[0].id];
idCursors.push(props.comments[0].id);
if (props.comments[1]) {
idCursors.push(props.comments[1].id);
}
return {idCursors};
}
return {idCursors: []};
const view = getVisibleComments(props.comments, idCursors[0]);
return {idCursors, view};
}
// invalidateCursor is called whenever a comment is removed which is referenced
@@ -40,91 +43,321 @@ function invalidateCursor(invalidated, state, props) {
idCursors.push(nextInLine.id);
}
}
return {idCursors};
return idCursors;
}
// getVisibileComments returns a list containing comments
// which comes after the `idCursor`.
function getVisibleComments(comments, idCursor) {
if (!comments) {
return [];
}
const view = [];
let pastCursor = false;
comments.forEach((comment) => {
if (comment.id === idCursor) {
pastCursor = true;
}
if (pastCursor) {
view.push(comment);
}
});
return view;
}
// Current keymapper to use for the CellMeasurer Cache.
let keyMapper = null;
// CellMeasurerCache is used to measure the size of the elements
// of the virtual list. We use a global one with a keyMapper that
// should resolve to a comment id, which is then used to cache the height.
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 250,
keyMapper: (index) => keyMapper(index),
});
class ModerationQueue extends React.Component {
isLoadingMore = false;
listRef = null;
callbackCaches = {
clearHeightCache: {},
selectCommentId: {},
};
constructor(props) {
super(props);
this.state = {
...resetCursors(this.state, props),
};
// Set keyMapper to map to comment ids.
keyMapper = (index) => {
const view = this.state.view;
if (index < view.length) {
return view[index].id;
}
else if (index === view.length) {
return 'loadMore';
}
throw new Error(`unknown index ${index}`);
};
// Select first comment.
if (this.state.view.length) {
props.selectCommentId(this.state.view[0].id);
}
}
componentDidUpdate (prev) {
const {comments, commentCount} = this.props;
componentDidMount() {
key('j', () => this.selectDown());
key('k', () => this.selectUp());
// if the user just moderated the last (visible) comment
// AND there are more comments available on the server,
// go ahead and load more comments
if (prev.comments.length > 0 && comments.length === 0 && commentCount > 0) {
this.props.loadMore();
}
// TODO: Workaround for issue https://github.com/bvaughn/react-virtualized/issues/866
this.reflowList();
}
componentWillUnmount() {
key.unbind('j');
key.unbind('k');
// When switching queues, clean it up first.
// Removes dangling comments and reduce overly large
// lists and restore chronological order.
this.props.cleanUpQueue(this.props.activeTab);
}
componentWillReceiveProps(next) {
const {comments: prevComments} = this.props;
const {comments: nextComments} = next;
if (!prevComments && nextComments) {
this.setState(resetCursors);
// New comments where added and our cursor list is incomplete.
if (this.state.idCursors.length < 2 && nextComments.length > this.state.idCursors.length) {
this.setState(resetCursors(this.state, next));
return;
}
let idCursors = this.state.idCursors;
// Comments have been removed.
if (
prevComments && nextComments &&
nextComments.length < prevComments.length
nextComments.length < prevComments.length
) {
// Invalidate first cursor if referenced comment was removed.
if (this.state.idCursors[0] && !hasComment(nextComments, this.state.idCursors[0])) {
this.setState(invalidateCursor(0, this.state, next));
idCursors = invalidateCursor(0, this.state, next);
}
// Invalidate second cursor if referenced comment was removed.
if (this.state.idCursors[1] && !hasComment(nextComments, this.state.idCursors[1])) {
this.setState(invalidateCursor(1, this.state, next));
idCursors = invalidateCursor(1, this.state, next);
}
// Selected comment was removed, determine and set next selected comment.
if (
this.props.selectedCommentId &&
!hasComment(nextComments, this.props.selectedCommentId)
) {
const view = this.state.view;
let nextSelectedCommentId = null;
// Determine a comment to select.
const prevIndex = view.findIndex((comment) => comment.id === this.props.selectedCommentId);
if (prevIndex !== view.length - 1) {
nextSelectedCommentId = view[prevIndex + 1].id;
} else if(prevIndex > 0) {
nextSelectedCommentId = view[prevIndex - 1].id;
}
this.props.selectCommentId(nextSelectedCommentId);
}
}
// Comments changed.
if (prevComments !== nextComments) {
const nextView = getVisibleComments(nextComments, idCursors[0]);
this.setState({idCursors, view: nextView});
// TODO: removing or adding a comment from the list seems to render incorrect, is this a bug?
// Find first changed comment and perform a reflow.
const index = this.state.view.findIndex((comment, i) => !nextView[i] || nextView[i].id !== comment.id);
this.reflowList(index);
}
}
viewNewComments = () => {
this.setState(resetCursors);
componentDidUpdate (prev) {
const {commentCount, selectedCommentId} = this.props;
const switchedToMultiMode = prev.singleView && !this.props.singleView;
const switchedMode = prev.singleView !== this.props.singleView;
const selectedDifferentComment = prev.selectedCommentId !== selectedCommentId && selectedCommentId;
const moderatedLastComment = prev.comments.length > 0 && this.getCommentCountWithoutDagling() === 0;
const hasMoreComment = commentCount > 0;
if (switchedToMultiMode) {
// Reflow virtual list.
this.reflowList();
}
if (switchedMode || selectedDifferentComment) {
this.scrollToSelectedComment();
}
if (moderatedLastComment && hasMoreComment) {
this.props.loadMore();
}
}
// Returns comment counts without dangling comments.
getCommentCountWithoutDagling(props = this.props) {
return props.comments.filter((comment) => props.commentBelongToQueue(props.activeTab, comment)).length;
}
async selectDown() {
const view = this.state.view;
const index = view.findIndex(({id}) => id === this.props.selectedCommentId);
if (index === view.length - 1 && this.getCommentCountWithoutDagling() !== this.props.commentCount) {
await this.props.loadMore();
this.selectDown();
return;
}
if (index < view.length - 1) {
this.props.selectCommentId(view[index + 1].id);
}
}
selectUp() {
const view = this.state.view;
const index = view.findIndex(({id}) => id === this.props.selectedCommentId);
if (index === 0 && view.length < this.props.comments.length) {
this.viewNewComments(() => this.selectUp());
return;
}
if (index > 0) {
this.props.selectCommentId(view[index - 1].id);
}
}
handleListRef = (list) => {
this.listRef = list;
};
// getVisibileComments returns a list containing comments
// which comes after the `idCursor`.
getVisibleComments() {
const {comments} = this.props;
const idCursor = this.state.idCursors[0];
scrollToSelectedComment = (props = this.props, state = this.state) => {
if (props.singleMode) {
document.querySelector(`#comment_${props.selectedCommentId}`).scrollIntoView();
}
else if(this.listRef) {
const view = state.view;
const index = view.findIndex(({id}) => id === props.selectedCommentId);
this.listRef.scrollToRow(index);
}
}
if (!comments) {
return [];
viewNewComments = (callback) => {
this.setState(resetCursors, () => {
this.reflowList();
callback && callback();
});
};
reflowList = throttle((index) => {
if (index >= 0) {
cache.clear(index);
this.listRef && this.listRef.recomputeRowHeights(index);
}
else {
cache.clearAll();
this.listRef && this.listRef.recomputeRowHeights();
}
}, 500);
rowRenderer = ({
index, // Index of row within collection
parent,
style // Style object to be applied to row (to position it)
}) => {
const view = this.state.view;
const rowCount = view.length + 1;
let child = null;
let key = null;
// Last element of list is our AutoLoadMore component and contains an
// id indicating that this is the last element in list.
if (index === rowCount - 1) {
key = 'end-of-comment-list';
child = (
<div
style={style}
id={'end-of-comment-list'}
>
{this.props.hasNextPage && <AutoLoadMore
loadMore={this.props.loadMore}
loading={this.props.isLoadingMore}
/>}
</div>
);
}
else {
const comment = view[index];
// Use callback cache so not to change the identity of these arrow functions.
// Otherwise shallow compare will fail to optimize.
if (!this.callbackCaches.clearHeightCache[index]) {
this.callbackCaches.clearHeightCache[index] = () => this.reflowList(index);
}
if (!this.callbackCaches.selectCommentId[comment.id]) {
this.callbackCaches.selectCommentId[comment.id] = () => this.props.selectCommentId(comment.id);
}
key = comment.id;
child = (
<div
style={style}
>
<Comment
data={this.props.data}
root={this.props.root}
comment={comment}
dangling={!this.props.commentBelongToQueue(this.props.activeTab, comment)}
selected={comment.id === this.props.selectedCommentId}
viewUserDetail={this.props.viewUserDetail}
showBanUserDialog={this.props.showBanUserDialog}
showSuspendUserDialog={this.props.showSuspendUserDialog}
acceptComment={this.props.acceptComment}
rejectComment={this.props.rejectComment}
currentAsset={this.props.currentAsset}
currentUserId={this.props.currentUserId}
clearHeightCache={this.callbackCaches.clearHeightCache[index]}
selectComment={this.callbackCaches.selectCommentId[comment.id]}
/>
</div>
);
}
const view = [];
let pastCursor = false;
comments.forEach((comment) => {
if (comment.id === idCursor) {
pastCursor = true;
}
if (pastCursor) {
view.push(comment);
}
});
return view;
}
return (
<CellMeasurer
cache={cache}
columnIndex={0}
key={key}
parent={parent}
rowIndex={index}
>
{child}
</CellMeasurer>
);
};
render () {
const {
comments,
selectedCommentId,
commentCount,
singleView,
viewUserDetail,
activeTab,
...props
} = this.props;
@@ -154,59 +387,41 @@ class ModerationQueue extends React.Component {
rejectComment={props.rejectComment}
currentAsset={props.currentAsset}
currentUserId={this.props.currentUserId}
dangling={!this.props.commentBelongToQueue(this.props.activeTab, comment)}
/>;
</div>
);
}
const view = this.getVisibleComments();
const view = this.state.view;
return (
<div className={styles.root}>
<ViewMore
viewMore={this.viewNewComments}
viewMore={() => this.viewNewComments()}
count={comments.length - view.length}
/>
<CSSTransitionGroup
key={activeTab}
component={'ul'}
className={styles.list}
transitionName={{
enter: styles.commentEnter,
enterActive: styles.commentEnterActive,
leave: styles.commentLeave,
leaveActive: styles.commentLeaveActive,
}}
transitionEnter={true}
transitionLeave={true}
transitionEnterTimeout={1000}
transitionLeaveTimeout={1000}
>
{
view
.map((comment) => {
return <Comment
data={this.props.data}
root={this.props.root}
key={comment.id}
comment={comment}
selected={comment.id === selectedCommentId}
viewUserDetail={viewUserDetail}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
currentAsset={props.currentAsset}
currentUserId={this.props.currentUserId}
/>;
})
}
</CSSTransitionGroup>
<LoadMore
loadMore={this.props.loadMore}
showLoadMore={comments.length < commentCount}
/>
<WindowScroller onResize={this.reflowList}>
{({height, isScrolling, onChildScroll, scrollTop}) => (
<List
ref={this.handleListRef}
autoHeight
className={styles.list}
style={{
width: '100%',
}}
height={height}
width={1280}
scrollTop={scrollTop}
isScrolling={isScrolling}
onScroll={onChildScroll}
rowCount={view.length + 1}
deferredMeasurementCache={cache}
rowRenderer={this.rowRenderer}
rowHeight={cache.rowHeight}
/>
)}
</WindowScroller>
</div>
);
}
@@ -216,14 +431,19 @@ ModerationQueue.propTypes = {
viewUserDetail: PropTypes.func.isRequired,
currentAsset: PropTypes.object,
showBanUserDialog: PropTypes.func.isRequired,
selectCommentId: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
comments: PropTypes.array.isRequired,
commentBelongToQueue: PropTypes.func.isRequired,
cleanUpQueue: PropTypes.func.isRequired,
commentCount: PropTypes.number.isRequired,
loadMore: PropTypes.func.isRequired,
selectedCommentId: PropTypes.string,
singleView: PropTypes.bool,
isLoadingMore: PropTypes.bool,
hasNextPage: PropTypes.bool,
comments: PropTypes.array,
activeTab: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
@@ -13,6 +13,9 @@
background-color: #2376D8;
cursor: pointer;
text-transform: capitalize;
margin: 0;
margin-top: -18px;
margin-bottom: -4px;
}
.viewMore:hover {
@@ -8,6 +8,7 @@
overflow: visible;
height: 144px;
min-height: auto;
margin-top: 16px;
z-index: 10;
@media (--tablet) {
@@ -53,7 +54,7 @@
list-style: none;
}
.dropdow {
.dropdownContainer {
position: relative;
margin-top: 5px;
@@ -23,6 +23,7 @@ class ViewOptions extends React.Component {
<li className={styles.viewOptionsItem}>
Sort Comments
<Dropdown
containerClassName={styles.dropdownContainer}
toggleClassName={styles.dropdownToggle}
toggleOpenClassName={styles.dropdownToggleOpen}
placeholder={t('modqueue.sort')}
@@ -47,6 +47,9 @@ export default withFragments({
editing {
edited
}
status_history {
type
}
hasParent
${getSlotFragmentSpreads(slots, 'comment')}
...${getDefinitionName(CommentLabels.fragments.comment)}
@@ -11,7 +11,7 @@ import NotFoundAsset from '../components/NotFoundAsset';
import {isPremod, getModPath} from '../../../utils';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {handleCommentChange} from '../graphql';
import {handleCommentChange, commentBelongToQueue, cleanUpQueue} from '../graphql';
import {showBanUserDialog} from 'actions/banUserDialog';
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
@@ -23,7 +23,8 @@ import {
toggleStorySearch,
setSortOrder,
storySearchChange,
clearState
clearState,
selectCommentId,
} from 'actions/moderation';
import withQueueConfig from '../hoc/withQueueConfig';
import {notify} from 'coral-framework/actions/notification';
@@ -71,10 +72,10 @@ class ModerationContainer extends Component {
const id = getAssetId(this.props);
const tab = getTab(this.props);
// Grab premod from asset or from settings
const premod = !id ? settings.moderation : asset.settings.moderation;
// Grab premod from asset or from settings if it's defined.
const setting = id && asset && asset.settings ? asset.settings.moderation : settings.moderation;
const queue = isPremod(premod) ? 'premod' : 'new';
const queue = isPremod(setting) ? 'premod' : 'new';
const activeTab = tab ? tab : queue;
return activeTab;
@@ -111,26 +112,34 @@ class ModerationContainer extends Component {
return this.handleCommentChange(prev, comment, notifyText);
},
},
{
document: COMMENT_RESET_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentReset: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_reset', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notifyText);
},
},
{
document: COMMENT_EDITED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => {
const notifyText = t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notifyText);
return this.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_FLAGGED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => {
const user = comment.actions[comment.actions.length - 1].user;
const notifyText = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notifyText);
return this.handleCommentChange(prev, comment);
},
},
];
this.subscriptions = parameters.map((param) => this.props.data.subscribeToMore(param));
this.subscriptions = parameters.map((param) => this.props.data.subscribeToMoreThrottled(param));
}
unsubscribe() {
@@ -160,6 +169,14 @@ class ModerationContainer extends Component {
}
}
cleanUpQueue = (queue) => {
if (!this.props.data.loading) {
this.props.data.updateQuery((query) => {
return cleanUpQueue(query, queue, this.props.moderation.sortOrder, this.props.queueConfig);
});
}
}
acceptComment = ({commentId}) => {
return this.props.setCommentStatus({commentId, status: 'ACCEPTED'});
}
@@ -168,9 +185,13 @@ class ModerationContainer extends Component {
return this.props.setCommentStatus({commentId, status: 'REJECTED'});
}
commentBelongToQueue = (queue, comment) => {
return commentBelongToQueue(queue, comment, this.props.queueConfig);
}
loadMore = (tab) => {
const variables = {
limit: 10,
limit: 20,
cursor: this.props.root[tab].endCursor,
sortOrder: this.props.data.variables.sortOrder,
asset_id: this.props.data.variables.asset_id,
@@ -186,7 +207,6 @@ class ModerationContainer extends Component {
[tab]: {
nodes: {$push: comments.nodes},
hasNextPage: {$set: comments.hasNextPage},
startCursor: {$set: comments.startCursor},
endCursor: {$set: comments.endCursor},
},
});
@@ -238,6 +258,9 @@ class ModerationContainer extends Component {
activeTab={this.activeTab}
queueConfig={currentQueueConfig}
handleCommentChange={this.handleCommentChange}
selectedCommentId={this.props.selectedCommentId}
commentBelongToQueue={this.commentBelongToQueue}
cleanUpQueue={this.cleanUpQueue}
/>;
}
}
@@ -302,6 +325,23 @@ const COMMENT_REJECTED_SUBSCRIPTION = gql`
${Comment.fragments.comment}
`;
const COMMENT_RESET_SUBSCRIPTION = gql`
subscription CommentReset($asset_id: ID){
commentReset(asset_id: $asset_id){
...${getDefinitionName(Comment.fragments.comment)}
status_history {
type
created_at
assigned_by {
id
username
}
}
}
}
${Comment.fragments.comment}
`;
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $sortOrder: SORT_ORDER, $asset_id: ID, $tags:[String!], $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sortOrder: $sortOrder, action_type: $action_type, tags: $tags}) {
@@ -336,7 +376,8 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
asset_id: $asset_id,
sortOrder: $sortOrder
sortOrder: $sortOrder,
limit: 20,
}) {
...CoralAdmin_Moderation_CommentConnection
}
@@ -361,6 +402,9 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
organizationName
moderation
}
me {
id
}
...${getDefinitionName(Comment.fragments.root)}
}
${Comment.fragments.root}
@@ -398,6 +442,7 @@ const mapDispatchToProps = (dispatch) => ({
storySearchChange,
clearState,
notify,
selectCommentId,
}, dispatch),
});
@@ -16,16 +16,21 @@ function queueHasComment(root, queue, id) {
return root[queue].nodes.find((c) => c.id === id);
}
function removeCommentFromQueue(root, queue, id) {
function removeCommentFromQueue(root, queue, id, dangling = false) {
if (!queueHasComment(root, queue, id)) {
return root;
}
return update(root, {
const changes = {
[`${queue}Count`]: {$set: root[`${queue}Count`] - 1},
[queue]: {
};
if (!dangling) {
changes[queue] = {
nodes: {$apply: (nodes) => nodes.filter((c) => c.id !== id)},
},
});
};
}
return update(root, changes);
}
function shouldCommentBeAdded(root, queue, comment, sortOrder) {
@@ -40,26 +45,46 @@ function shouldCommentBeAdded(root, queue, comment, sortOrder) {
: new Date(comment.created_at) >= cursor;
}
function addCommentToQueue(root, queue, comment, sortOrder) {
function addCommentToQueue(root, queue, comment, sortOrder, cleanup) {
if (queueHasComment(root, queue, comment.id)) {
return root;
}
const sortAlgo = sortOrder === 'ASC' ? ascending : descending;
const changes = {
[`${queue}Count`]: {$set: root[`${queue}Count`] + 1},
};
if (shouldCommentBeAdded(root, queue, comment, sortOrder)) {
const nodes = root[queue].nodes.concat(comment).sort(sortAlgo);
changes[queue] = {
nodes: {$set: nodes},
startCursor: {$set: nodes[0].created_at},
endCursor: {$set: nodes[nodes.length - 1].created_at},
};
if (!shouldCommentBeAdded(root, queue, comment, sortOrder)) {
return update(root, changes);
}
return update(root, changes);
const cursor = new Date(root[queue].startCursor);
const date = new Date(comment.created_at);
let append = sortOrder === 'ASC'
? date >= cursor
: date <= cursor;
const nodes = append
? root[queue].nodes.concat(comment)
: [comment].concat(...root[queue].nodes);
changes[queue] = {
nodes: {$set: nodes},
};
const next = update(root, changes);
if (!cleanup) {
return next;
}
return cleanUpQueue(next, queue, sortOrder);
}
function sortComments(nodes, sortOrder) {
const sortAlgo = sortOrder === 'ASC' ? ascending : descending;
return nodes.sort(sortAlgo);
}
/**
@@ -68,24 +93,92 @@ function addCommentToQueue(root, queue, comment, sortOrder) {
function getCommentQueues(comment, queueConfig) {
const queues = [];
Object.keys(queueConfig).forEach((key) => {
const {action_type, statuses, tags} = queueConfig[key];
let addToQueues = true;
if (statuses && statuses.indexOf(comment.status) === -1) {
addToQueues = false;
}
if (tags && (!comment.tags || !comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0))) {
addToQueues = false;
}
if (action_type && (!comment.actions || !comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type.toLowerCase()}action`))) {
addToQueues = false;
}
if (addToQueues) {
if (commentBelongToQueue(key, comment, queueConfig)) {
queues.push(key);
}
});
return queues;
}
/**
* Return whether or not the comment belongs to the queue.
*/
export function commentBelongToQueue(queue, comment, queueConfig) {
const {action_type, statuses, tags} = queueConfig[queue];
let belong = true;
if (statuses && statuses.indexOf(comment.status) === -1) {
belong = false;
}
if (tags && (!comment.tags || !comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0))) {
belong = false;
}
if (action_type && (!comment.actions || !comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type.toLowerCase()}action`))) {
belong = false;
}
return belong;
}
function isVisible(id) {
return !!document.getElementById(`comment_${id}`);
}
function isEndOfListVisible(root, queue) {
return root[queue].nodes.length === 0 || !!document.getElementById('end-of-comment-list');
}
function applyCommentChanges(root, comment, queueConfig) {
const queues = Object.keys(queueConfig);
for (let i = 0; i < queues.length; i++) {
const queue = queues[i];
const index = root[queue].nodes.findIndex(({id}) => id === comment.id);
if (index > -1) {
return update(root, {
[queue]: {
nodes: {
[index]: {$merge: comment},
},
},
});
}
}
return root;
}
/**
* Remove dangling comments, sort and resize queues.
* If queueConfig is omitted, dangling comments are not removed.
*/
export function cleanUpQueue(root, queue, sortOrder, queueConfig) {
let nodes = root[queue].nodes;
let hasNextPage = root[queue].hasNextPage;
if (!nodes.length) {
return root;
}
if (queueConfig) {
nodes = root[queue].nodes.filter((comment) => commentBelongToQueue(queue, comment, queueConfig));
}
nodes = sortComments(
nodes,
sortOrder,
);
if (nodes.length > 100) {
nodes = nodes.slice(0, 100);
hasNextPage = true;
}
return update(root, {
[queue]: {
nodes: {$set: nodes},
endCursor: {$set: nodes[nodes.length - 1].created_at},
hasNextPage: {$set: hasNextPage},
},
});
}
/**
* Assimilate comment changes into current store.
* @param {Object} root current state of the store
@@ -113,25 +206,27 @@ export function handleCommentChange(root, comment, sortOrder, notify, queueConfi
Object.keys(queueConfig).forEach((queue) => {
if (nextQueues.indexOf(queue) >= 0) {
if (!queueHasComment(next, queue, comment.id)) {
next = addCommentToQueue(next, queue, comment, sortOrder);
if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sortOrder)) {
next = addCommentToQueue(next, queue, comment, sortOrder, activeQueue !== queue);
if (notify && activeQueue === queue && isEndOfListVisible(root, queue)) {
showNotificationOnce();
}
}
} else if(queueHasComment(next, queue, comment.id)){
next = removeCommentFromQueue(next, queue, comment.id);
if (notify && activeQueue === queue) {
const dangling = activeQueue === queue && comment.status_history[comment.status_history.length - 1].assigned_by.id !== root.me.id;
next = removeCommentFromQueue(next, queue, comment.id, dangling);
if (notify && isVisible(comment.id)) {
showNotificationOnce();
}
}
if (
notify
&& queueHasComment(next, queue, comment.id)
&& activeQueue === queue
) {
if (notify && isVisible(comment.id)) {
showNotificationOnce();
}
// We need to apply every comment change, because we use
// batched subscription handler which bypasses apollo that would
// have done that for us.
next = applyCommentChanges(next, comment, queueConfig);
});
return next;
}
@@ -1,65 +1,20 @@
import React, {Component} from 'react';
import cn from 'classnames';
import {Link} from 'react-router';
import PropTypes from 'prop-types';
import sortBy from 'lodash/sortBy';
import {Dropdown, Option, Pager, Icon} from 'coral-ui';
import {Dropdown, Option, Paginate, Icon} from 'coral-ui';
import {DataTable, TableHeader, RadioGroup, Radio} from 'react-mdl';
import t from 'coral-framework/services/i18n';
import styles from './Stories.css';
import EmptyCard from 'coral-admin/src/components/EmptyCard';
const limit = 25;
class Stories extends Component {
state = {
search: '',
sort: 'desc',
filter: 'all',
statusMenus: {},
timer: null,
page: 0
}
componentDidMount () {
this.props.fetchAssets(0, limit, '', this.state.sortBy);
}
onSettingChange = (setting) => (e) => {
let options = this.state;
this.setState({[setting]: e.target.value});
options[setting] = e.target.value;
this.props.fetchAssets(0, limit, options.search, options.sort, options.filter);
}
onSearchChange = (e) => {
const search = e.target.value;
this.setState((prevState) => {
prevState.search = search;
clearTimeout(prevState.timer);
const fetchAssets = this.props.fetchAssets;
prevState.timer = setTimeout(() => {
fetchAssets(0, limit, search, this.state.sort, this.state.filter);
}, 350);
return prevState;
});
}
renderDate = (date) => {
const d = new Date(date);
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
}
onStatusChange = async (closeStream, id) => {
try {
this.props.updateAssetState(id, closeStream ? Date.now() : null);
const {search, sort, filter, page} = this.state;
this.props.fetchAssets(page, limit, search, sort, filter);
} catch(err) {
console.error(err);
}
}
renderTitle = (title, {id}) => <Link to={`/admin/moderate/${id}`}>{title}</Link>
renderStatus = (closedAt, {id}) => {
@@ -67,48 +22,36 @@ class Stories extends Component {
return (
<Dropdown
value={closed}
onChange={(value) => this.onStatusChange(value, id)}>
onChange={(value) => this.props.onStatusChange(value, id)}>
<Option value={false} label={t('streams.open')} />
<Option value={true} label={t('streams.closed')} />
</Dropdown>
);
}
onPageClick = (page) => {
this.setState({page});
const {search, sort, filter} = this.state;
this.props.fetchAssets((page - 1) * limit, limit, search, sort, filter);
}
render () {
const {search, sort, filter} = this.state;
const {assets} = this.props;
const assetsIds = sortBy(assets.ids.map((id) => assets.byId[id]), 'publication_date');
if (this.state.sort === 'desc') {
assetsIds.reverse();
}
const {assets, searchValue, filter, onSearchChange, onSettingChange, onPageChange, asc} = this.props;
const rows = assets.ids.map((id) => assets.byId[id]);
return (
<div className={styles.container}>
<div className={cn('talk-admin-stories', styles.container)}>
<div className={styles.leftColumn}>
<div className={styles.searchBox}>
<Icon name='search' className={styles.searchIcon}/>
<input
type='text'
value={search}
value={searchValue}
className={styles.searchBoxInput}
onChange={this.onSearchChange}
onChange={onSearchChange}
placeholder={t('streams.search')}/>
</div>
<div className={styles.optionHeader}>{t('streams.filter_streams')}</div>
<div className={styles.optionDetail}>{t('streams.stream_status')}</div>
<RadioGroup
name='status filter'
name='statusFilter'
value={filter}
childContainer='div'
onChange={this.onSettingChange('filter')}
onChange={onSettingChange('filter')}
className={styles.radioGroup}
>
<Radio value='all'>{t('streams.all')}</Radio>
@@ -117,20 +60,20 @@ class Stories extends Component {
</RadioGroup>
<div className={styles.optionHeader}>{t('streams.sort_by')}</div>
<RadioGroup
name='sort by'
value={sort}
name='sortBy'
value={asc}
childContainer='div'
onChange={this.onSettingChange('sort')}
onChange={onSettingChange('asc')}
className={styles.radioGroup}
>
<Radio value='desc'>{t('streams.newest')}</Radio>
<Radio value='asc'>{t('streams.oldest')}</Radio>
<Radio value='false'>{t('streams.newest')}</Radio>
<Radio value='true'>{t('streams.oldest')}</Radio>
</RadioGroup>
</div>
{
assetsIds.length
rows.length
? <div className={styles.mainContent}>
<DataTable className={styles.streamsTable} rows={assetsIds} onClick={this.goToModeration}>
<DataTable className={styles.streamsTable} rows={rows}>
<TableHeader name="title" cellFormatter={this.renderTitle}>{t('streams.article')}</TableHeader>
<TableHeader name="publication_date" cellFormatter={this.renderDate}>
{t('streams.pubdate')}
@@ -139,10 +82,10 @@ class Stories extends Component {
{t('streams.status')}
</TableHeader>
</DataTable>
<Pager
totalPages={Math.ceil((assets.count || 0) / limit)}
page={this.state.page}
onNewPageHandler={this.onPageClick} />
<Paginate
pageCount={assets.totalPages}
page={assets.page - 1}
onPageChange={onPageChange} />
</div>
: <EmptyCard>{t('streams.empty_result')}</EmptyCard>
}
@@ -153,8 +96,13 @@ class Stories extends Component {
Stories.propTypes = {
assets: PropTypes.object,
fetchAssets: PropTypes.func,
updateAssetState: PropTypes.func,
searchValue: PropTypes.string,
asc: PropTypes.string,
filter: PropTypes.string,
onStatusChange: PropTypes.func.isRequired,
onSearchChange: PropTypes.func.isRequired,
onPageChange: PropTypes.func.isRequired,
onSettingChange: PropTypes.func.isRequired,
};
export default Stories;
@@ -1,27 +1,106 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import PropTypes from 'prop-types';
import {bindActionCreators} from 'redux';
import {compose} from 'react-apollo';
import {fetchAssets, updateAssetState} from 'coral-admin/src/actions/assets';
import {fetchAssets, updateAssetState, setPage, setSearchValue, setCriteria} from 'coral-admin/src/actions/stories';
import Stories from '../components/Stories';
class StoriesContainer extends Component {
timer=null;
componentDidMount () {
this.fetchAssets();
}
onSearchChange = (e) => {
const {value} = e.target;
this.props.setSearchValue(value);
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.fetchAssets();
}, 350);
}
onSettingChange = (setting) => (e) => {
const criteria = {[setting]: e.target.value};
this.props.setCriteria(criteria);
this.fetchAssets(criteria);
}
fetchAssets = (query) => {
const {searchValue, asc, filter, limit} = this.props;
this.props.fetchAssets({
value: searchValue,
asc,
filter,
limit,
...query
});
};
onStatusChange = async (closeStream, id) => {
const {updateAssetState} = this.props;
try {
updateAssetState(id, closeStream ? Date.now() : null);
this.fetchAssets();
} catch(err) {
console.error(err);
}
}
onPageChange = ({selected}) => {
const page = selected + 1;
this.props.setPage(page);
this.fetchAssets({page});
}
render () {
return <Stories {...this.props} />;
return <Stories
assets={this.props.assets}
searchValue={this.props.searchValue}
asc={this.props.asc}
filter={this.props.filter}
limit={this.props.limit}
onPageChange={this.onPageChange}
onStatusChange={this.onStatusChange}
onSettingChange={this.onSettingChange}
onSearchChange={this.onSearchChange}
/>;
}
}
const mapStateToProps = (state) => ({
assets: state.assets
const mapStateToProps = ({stories}) => ({
assets: stories.assets,
searchValue: stories.searchValue,
asc: stories.criteria.asc,
filter: stories.criteria.filter,
limit: stories.criteria.limit,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
setPage,
setCriteria,
setSearchValue,
fetchAssets,
updateAssetState,
}, dispatch);
export default compose(
connect(mapStateToProps, mapDispatchToProps),
)(StoriesContainer);
StoriesContainer.propTypes = {
assets: PropTypes.object,
searchValue: PropTypes.string,
asc: PropTypes.string,
filter: PropTypes.string,
limit: PropTypes.number,
setPage: PropTypes.func.isRequired,
setCriteria: PropTypes.func.isRequired,
setSearchValue: PropTypes.func.isRequired,
fetchAssets: PropTypes.func.isRequired,
updateAssetState: PropTypes.func.isRequired,
};
export default connect(mapStateToProps, mapDispatchToProps)(StoriesContainer);
@@ -1,29 +0,0 @@
import React from 'react';
import {Button} from 'coral-ui';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
const CloseCommentsInfo = ({status, onClick}) => (
status === 'open' ? (
<div className="close-comments-intro-wrapper">
<p>
{t('configure.open_stream_configuration')}
</p>
<Button onClick={onClick}>{t('configure.close_stream')}</Button>
</div>
) : (
<div className="close-comments-intro-wrapper">
<p>
{t('configure.close_stream_configuration')}
</p>
<Button onClick={onClick}>{t('configure.open_stream')}</Button>
</div>
)
);
CloseCommentsInfo.propTypes = {
status: PropTypes.string,
onClick: PropTypes.func,
};
export default CloseCommentsInfo;
@@ -1,71 +0,0 @@
import React from 'react';
import {Button, Checkbox} from 'coral-ui';
import QuestionBoxBuilder from './QuestionBoxBuilder';
import cn from 'classnames';
import styles from './ConfigureCommentStream.css';
import t from 'coral-framework/services/i18n';
export default ({handleChange, handleApply, changed, ...props}) => (
<form onSubmit={handleApply}>
<div className={styles.wrapper}>
<div className={styles.container}>
<h3>{t('configure.title')}</h3>
<Button
type="submit"
className={cn(styles.apply, 'talk-embed-stream-configuration-submit-button')}
onChange={handleChange}
cStyle={changed ? 'green' : 'darkGrey'} >
{t('configure.apply')}
</Button>
<p>{t('configure.description')}</p>
</div>
<ul>
<li>
<Checkbox
className={styles.checkbox}
cStyle={changed ? 'green' : 'darkGrey'}
name="premod"
onChange={handleChange}
defaultChecked={props.premod}
info={{
title: t('configure.enable_premod'),
description: t('configure.enable_premod_description')
}} />
</li>
<li>
<Checkbox
className={styles.checkbox}
cStyle={changed ? 'green' : 'darkGrey'}
name="plinksenable"
onChange={handleChange}
defaultChecked={props.premodLinksEnable}
info={{
title: t('configure.enable_premod_links'),
description: t('configure.enable_premod_links_description')
}} />
</li>
<li>
<Checkbox
className={styles.checkbox}
cStyle={changed ? 'green' : 'darkGrey'}
name="qboxenable"
onChange={handleChange}
defaultChecked={props.questionBoxEnable}
info={{
title: t('configure.enable_questionbox'),
description: t('configure.enable_questionbox_description')
}} />
{
props.questionBoxEnable && <QuestionBoxBuilder
questionBoxIcon={props.questionBoxIcon}
questionBoxContent={props.questionBoxContent}
handleChange={handleChange}
/>
}
</li>
</ul>
</div>
</form>
);
@@ -1,24 +0,0 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import marked from 'marked';
const renderer = new marked.Renderer();
// Set link target to `_parent` to work properly in an embed.
renderer.link = (href, title, text) =>
`<a target="_parent" href="${href}" ${title ? `title="${title}"` : ''}>${text}</a>`;
marked.setOptions({renderer});
export default class Markdown extends PureComponent {
render() {
const {content, ...rest} = this.props;
const __html = marked(content);
return <div {...rest} dangerouslySetInnerHTML={{__html}} />;
}
}
Markdown.propTypes = {
content: PropTypes.string,
};
@@ -1,45 +0,0 @@
.qbBuilder {
margin-left: 50px;
}
.qbItemIconList {
padding: 0;
margin: 10px 0;
}
.qbItemIcon {
background: #F0F0F0;
width: 45px;
height: 45px;
font-size: 24px;
text-align: center;
line-height: 45px;
color: #252525;
border-radius: 3px;
display: inline-block;
overflow: hidden;
margin-right: 10px;
position: relative;
border: solid 2px #F0F0F0;
transition: border 0.3s cubic-bezier(.4,0,.2,1);
}
.qbItemIcon:hover {
cursor: pointer;
}
.qbItemIconActive {
border: solid 2px #00796B;
}
.defaultIcon {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.qb {
margin: 10px 0;
}
@@ -1,98 +0,0 @@
import React from 'react';
import QuestionBox from 'talk-plugin-questionbox/QuestionBox';
import {Icon, Spinner} from 'coral-ui';
import DefaultIcon from './DefaultIcon';
import cn from 'classnames';
import styles from './QuestionBoxBuilder.css';
class QuestionBoxBuilder extends React.Component {
constructor() {
super();
this.state = {
loading: true
};
}
componentWillMount() {
this.loadEditor();
}
async loadEditor() {
const {default: MarkdownEditor} = await import('coral-framework/components/MarkdownEditor');
return this.setState({
loading : false,
MarkdownEditor
});
}
render() {
const {handleChange, questionBoxIcon, questionBoxContent} = this.props;
const {loading, MarkdownEditor} = this.state;
if (loading) {
return <Spinner/>;
}
return (
<div className={styles.qbBuilder}>
<h4>Include an Icon</h4>
<ul className={styles.qbItemIconList}>
<li className={cn(
styles.qbItemIcon,
{[styles.qbItemIconActive]: questionBoxIcon === 'default'}
)}
id="qboxicon"
onClick={handleChange}
data-icon="default" >
<DefaultIcon className={styles.defaultIcon} />
</li>
<li className={cn(
styles.qbItemIcon,
{[styles.qbItemIconActive]: questionBoxIcon === 'forum'}
)}
id="qboxicon"
onClick={handleChange}
data-icon="forum" >
<Icon name="forum" />
</li>
<li className={cn(
styles.qbItemIcon,
{[styles.qbItemIconActive]: questionBoxIcon === 'build'}
)}
id="qboxicon"
onClick={handleChange}
data-icon="build" >
<Icon name="build" />
</li>
<li className={cn(
styles.qbItemIcon,
{[styles.qbItemIconActive]: questionBoxIcon === 'format_quote'}
)}
id="qboxicon"
onClick={handleChange}
data-icon="format_quote" >
<Icon name="format_quote" />
</li>
</ul>
<QuestionBox
className={styles.qb}
enable={true}
icon={questionBoxIcon}
content={questionBoxContent}
/>
<MarkdownEditor
value={questionBoxContent}
onChange={(value) => handleChange({}, {questionBoxContent: value})}
/>
</div>
);
}
}
export default QuestionBoxBuilder;
@@ -1,145 +0,0 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {compose} from 'react-apollo';
import PropTypes from 'prop-types';
import {updateOpenStatus, updateConfiguration} from 'coral-embed-stream/src/actions/asset';
import CloseCommentsInfo from '../components/CloseCommentsInfo';
import ConfigureCommentStream from '../components/ConfigureCommentStream';
import t, {timeago} from 'coral-framework/services/i18n';
class ConfigureStreamContainer extends Component {
constructor (props) {
super(props);
this.state = {
changed: false,
dirtySettings: {...props.asset.settings},
closedAt: !props.asset.isClosed ? 'open' : 'closed'
};
this.toggleStatus = this.toggleStatus.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleApply = this.handleApply.bind(this);
}
handleApply (e) {
e.preventDefault();
const {elements} = e.target;
const {questionBoxIcon, questionBoxContent} = this.state.dirtySettings;
const premod = elements.premod.checked;
const questionBoxEnable = elements.qboxenable.checked;
const premodLinksEnable = elements.plinksenable.checked;
const {changed} = this.state;
const newConfig = {
moderation: premod ? 'PRE' : 'POST',
questionBoxEnable,
questionBoxContent,
questionBoxIcon,
premodLinksEnable
};
if (changed) {
this.props.updateConfiguration(newConfig);
setTimeout(() => {
this.setState({
changed: false
});
}, 300);
}
}
handleChange (e, newChanges) {
let changes = {};
if (changes) {
changes = {...newChanges};
}
if (e.target && e.target.id === 'qboxenable') {
changes.questionBoxEnable = e.target.checked;
}
if (e.currentTarget && e.currentTarget.id === 'qboxicon') {
changes.questionBoxIcon = e.currentTarget.dataset.icon;
}
if (e.target && e.target.id === 'plinksenable') {
changes.premodLinksEnable = e.target.value;
}
this.setState({
changed: true,
dirtySettings: {
...this.state.dirtySettings,
...changes,
},
});
}
toggleStatus () {
// update the closedAt status for the asset
this.props.updateStatus(
this.state.closedAt === 'open' ? 'closed' : 'open'
);
this.setState({
closedAt: (this.state.closedAt === 'open' ? 'closed' : 'open')
});
}
getClosedIn () {
const {closedTimeout} = this.props.asset.settings;
const {created_at} = this.props.asset;
return timeago(new Date(created_at).getTime() + (1000 * closedTimeout));
}
render () {
const {dirtySettings} = this.state;
const premod = dirtySettings.moderation === 'PRE';
const {closedAt} = this.state;
const closedTimeout = dirtySettings.closedTimeout;
return (
<div className='talk-embed-stream-configuration-container'>
<ConfigureCommentStream
handleChange={this.handleChange}
handleApply={this.handleApply}
changed={this.state.changed}
premodLinksEnable={dirtySettings.premodLinksEnable}
premod={premod}
questionBoxIcon={dirtySettings.questionBoxIcon}
questionBoxEnable={dirtySettings.questionBoxEnable}
questionBoxContent={dirtySettings.questionBoxContent}
/>
<hr />
<h3>{closedAt === 'open' ? t('configure.close') : t('configure.open')} {t('configure.comment_stream')}</h3>
{(closedAt === 'open' && closedTimeout) ? <p>{t('configure.comment_stream_will_close')} {this.getClosedIn()}.</p> : ''}
<CloseCommentsInfo
onClick={this.toggleStatus}
status={closedAt}
/>
</div>
);
}
}
const mapStateToProps = (state) => ({
asset: state.asset
});
const mapDispatchToProps = (dispatch) => ({
updateStatus: (status) => dispatch(updateOpenStatus(status)),
updateConfiguration: (newConfig) => dispatch(updateConfiguration(newConfig)),
});
ConfigureStreamContainer.propTypes = {
updateStatus: PropTypes.func,
closedTimeout: PropTypes.string,
created_at: PropTypes.string,
updateConfiguration: PropTypes.func,
asset: PropTypes.object,
};
export default compose(
connect(mapStateToProps, mapDispatchToProps)
)(ConfigureStreamContainer);
+14 -9
View File
@@ -23,6 +23,10 @@ export const hideSignInDialog = () => (dispatch) => {
dispatch({type: actions.HIDE_SIGNIN_DIALOG});
};
export const resetSignInDialog = () => (dispatch) => {
dispatch({type: actions.HIDE_SIGNIN_DIALOG});
};
export const focusSignInDialog = () => ({
type: actions.FOCUS_SIGNIN_DIALOG,
});
@@ -94,8 +98,9 @@ export const cleanState = () => ({
// Sign In Actions
const signInRequest = () => ({
type: actions.FETCH_SIGNIN_REQUEST
const signInRequest = (email) => ({
type: actions.FETCH_SIGNIN_REQUEST,
email,
});
const signInFailure = (error) => ({
@@ -122,7 +127,7 @@ export const handleAuthToken = (token) => (dispatch, _, {storage}) => {
export const fetchSignIn = (formData) => {
return (dispatch, _, {rest}) => {
dispatch(signInRequest());
dispatch(signInRequest(formData.email));
return rest('/auth/local', {method: 'POST', body: formData})
.then(({token}) => {
@@ -144,8 +149,7 @@ export const fetchSignIn = (formData) => {
// invalid credentials
dispatch(signInFailure(t('error.email_password'), error.metadata));
} else {
const str = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(signInFailure(str));
dispatch(signInFailure(error));
}
});
};
@@ -349,8 +353,9 @@ const verifyEmailSuccess = () => ({
type: actions.VERIFY_EMAIL_SUCCESS
});
const verifyEmailFailure = () => ({
type: actions.VERIFY_EMAIL_FAILURE
const verifyEmailFailure = (error) => ({
type: actions.VERIFY_EMAIL_FAILURE,
error,
});
export const requestConfirmEmail = (email) => (dispatch, getState, {rest}) => {
@@ -366,8 +371,8 @@ export const requestConfirmEmail = (email) => (dispatch, getState, {rest}) => {
})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(verifyEmailFailure(errorMessage));
dispatch(verifyEmailFailure(error));
throw error;
});
};
@@ -0,0 +1,9 @@
import * as actions from '../constants/configure';
export const updatePending = ({updater, errorUpdater}) => {
return {type: actions.UPDATE_PENDING, updater, errorUpdater};
};
export const clearPending = () => {
return {type: actions.CLEAR_PENDING};
};
@@ -16,12 +16,5 @@
color: #262626;
}
.qbIconContainer {
position: relative;
border: 0;
color: white;
display: inline-block;
padding: 5px 20px;
vertical-align: middle;
width: 10px;
}
.root {
}
@@ -1,13 +1,13 @@
import React from 'react';
import cn from 'classnames';
import styles from './DefaultIcon.css';
import styles from './DefaultQuestionBoxIcon.css';
import {Icon} from 'coral-ui';
const DefaultIcon = ({className}) => (
<div className={cn(styles.qbIconContainer, className)}>
const DefaultQuestionBoxIcon = ({className}) => (
<div className={cn(styles.root, className)}>
<Icon name="chat_bubble" className={cn(styles.iconBubble)} />
<Icon name="person" className={cn(styles.iconPerson)} />
</div>
);
export default DefaultIcon;
export default DefaultQuestionBoxIcon;
@@ -1,16 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import Stream from '../containers/Stream';
import Stream from '../tabs/stream/containers/Stream';
import Configure from '../tabs/configure/containers/Configure';
import Slot from 'coral-framework/components/Slot';
import {can} from 'coral-framework/services/perms';
import t from 'coral-framework/services/i18n';
import AutomaticAssetClosure from '../containers/AutomaticAssetClosure';
import {TabBar, Tab, TabContent, TabPane} from 'coral-ui';
import ExtendableTabPanel from '../containers/ExtendableTabPanel';
import {Tab, TabPane} from 'coral-ui';
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
import Popup from 'coral-framework/components/Popup';
import IfSlotIsNotEmpty from 'coral-framework/components/IfSlotIsNotEmpty';
import ConfigureStreamContainer
from 'coral-configure/containers/ConfigureStreamContainer';
import cn from 'classnames';
export default class Embed extends React.Component {
@@ -21,20 +22,37 @@ export default class Embed extends React.Component {
case 'profile':
this.props.data.refetch();
break;
case 'config':
this.props.data.refetch();
break;
}
this.props.setActiveTab(tab);
};
render() {
const {activeTab, commentId, root, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog, router: {location: {query: {parentUrl}}}} = this.props;
getTabs() {
const {user} = this.props.auth;
const tabs = [
<Tab key='stream' tabId='stream' className='talk-embed-stream-comments-tab'>
{t('embed_comments_tab')}
</Tab>,
<Tab key='profile' tabId='profile' className='talk-embed-stream-profile-tab'>
{t('framework.my_profile')}
</Tab>,
];
if (can(user, 'UPDATE_CONFIG')) {
tabs.push(
<Tab key='config' tabId='config'>
{t('framework.configure_stream')}
</Tab>
);
}
return tabs;
}
render() {
const {activeTab, commentId, root, root: {asset}, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog, router: {location: {query: {parentUrl}}}} = this.props;
const hasHighlightedComment = !!commentId;
return (
<div className={cn('talk-embed-stream', {'talk-embed-stream-highlight-comment': hasHighlightedComment})}>
<AutomaticAssetClosure asset={asset} />
<IfSlotIsNotEmpty slot="login">
<Popup
href={`embed/stream/login?parentUrl=${encodeURIComponent(parentUrl)}`}
@@ -47,44 +65,36 @@ export default class Embed extends React.Component {
onClose={hideSignInDialog}
/>
</IfSlotIsNotEmpty>
<TabBar
onTabClick={this.changeTab}
activeTab={activeTab}
className='talk-embed-stream-tab-bar'
aria-controls='talk-embed-stream-tab-content'
>
<Tab tabId={'stream'} className={'talk-embed-stream-comments-tab'}>
{t('embed_comments_tab')}
</Tab>
<Tab tabId={'profile'} className={'talk-embed-stream-profile-tab'}>
{t('framework.my_profile')}
</Tab>
{can(user, 'UPDATE_CONFIG') &&
<Tab tabId={'config'} className={'talk-embed-stream-configuration-tab'}>
{t('framework.configure_stream')}
</Tab>
}
</TabBar>
<Slot
data={data}
queryData={{root}}
fill="embed"
/>
<TabContent
<ExtendableTabPanel
className='talk-embed-stream-tab-bar'
activeTab={activeTab}
id='talk-embed-stream-tab-content'
>
<TabPane tabId={'stream'} className={'talk-embed-stream-comments-tab-pane'}>
<Stream data={data} root={root} />
</TabPane>
<TabPane tabId={'profile'} className={'talk-embed-stream-profile-tab-pane'}>
<ProfileContainer />
</TabPane>
<TabPane tabId={'config'} className={'talk-embed-stream-configuration-tab-pane'}>
<ConfigureStreamContainer />
</TabPane>
</TabContent>
setActiveTab={this.changeTab}
fallbackTab='stream'
tabSlot='embedStreamTabs'
tabSlotPrepend='embedStreamTabsPrepend'
tabPaneSlot='embedStreamTabPanes'
slotProps={{data}}
queryData={{root}}
tabs={this.getTabs()}
tabPanes={[
<TabPane key='stream' tabId='stream' className='talk-embed-stream-comments-tab-pane'>
<Stream data={data} root={root} asset={root.asset} />
</TabPane>,
<TabPane key='profile' tabId='profile' className='talk-embed-stream-profile-tab-pane'>
<ProfileContainer />
</TabPane>,
<TabPane key='config' tabId='config' className='talk-embed-stream-configuration-tab-pane'>
<Configure data={data} root={root} asset={root.asset} />
</TabPane>,
]}
/>
</div>
);
}
@@ -0,0 +1,35 @@
import React from 'react';
import {Tab} from 'coral-ui';
import PropTypes from 'prop-types';
/**
* ExtendableTab adds a hover property to its children, because
* Tab is rendered as a button and under Firefox its children do
* not support mouse events.
*/
class ExtendableTab extends React.Component {
state = {
hover: false,
}
handleMouseEnter = () => this.setState({hover: true});
handleMouseLeave = () => this.setState({hover: false});
render() {
return (
<Tab
{...this.props}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
>
{React.cloneElement(this.props.children, {hover: this.state.hover})}
</Tab>
);
}
}
ExtendableTab.propTypes = {
children: PropTypes.node,
};
export default ExtendableTab;

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