Merge branch 'master' into next

This commit is contained in:
Wyatt Johnson
2017-11-08 13:49:21 -07:00
220 changed files with 7598 additions and 3649 deletions
+4
View File
@@ -15,6 +15,10 @@ client/coral-framework/graphql/introspection.json
*.DS_STORE
coverage/
test/e2e/reports/
test/e2e/bslocal.log
test/e2e/selenium-debug.log
browserstack.err
plugins.json
plugins/*
+5
View File
@@ -0,0 +1,5 @@
{
"exceptions": [
"https://nodesecurity.io/advisories/531"
]
}
+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.
+26 -22
View File
@@ -1,33 +1,37 @@
# Talk [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/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)
[![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)
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](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).
[![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).
+3 -175
View File
@@ -1,161 +1,7 @@
#!/usr/bin/env node
const program = require('./commander');
const app = require('../app');
const debug = require('debug')('talk:cli:serve');
const errors = require('../errors');
const {createServer} = require('http');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const MigrationService = require('../services/migration');
const SetupService = require('../services/setup');
const kue = require('../services/kue');
const mongoose = require('../services/mongoose');
const util = require('./util');
const cache = require('../services/cache');
const {createSubscriptionManager} = require('../graph/subscriptions');
const {
PORT
} = require('../config');
/**
* Get port from environment and store in Express.
*/
const port = normalizePort(PORT);
app.set('port', port);
/**
* Create HTTP server.
*/
const server = createServer(app);
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
let bind = typeof port === 'string'
? `Pipe ${port}`
: `Port ${port}`;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(`${bind} requires elevated privileges`);
break;
case 'EADDRINUSE':
console.error(`${bind} is already in use`);
break;
}
throw error;
}
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
let port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "listening" event.
*/
async function onListening() {
// Start the cache instance.
await cache.init();
let addr = server.address();
let bind = typeof addr === 'string'
? `pipe ${addr}`
: `port ${addr.port}`;
debug(`API Server Listening on ${bind}`);
}
/**
* Start the app.
*/
async function startApp(program) {
try {
// Check to see if the application is installed. If the application
// has been installed, then it will throw errors.ErrSettingsNotInit, this
// just means we don't have to check that the migrations have run.
await SetupService.isAvailable();
debug('setup is currently available, migrations not being checked');
} catch (e) {
// Check the error.
switch (e) {
case errors.ErrInstallLock, errors.ErrSettingsInit:
debug('setup is not currently available, migrations now being checked');
// The error was expected, just continue.
break;
default:
// The error was not expected, throw the error!
throw e;
}
// Now try and check the migration status.
try {
// Verify that the minimum migration version is met.
await MigrationService.verify();
} catch (e) {
console.error(e);
process.exit(1);
}
debug('migrations do not have to be run');
}
/**
* Listen on provided port, on all network interfaces.
*/
server.on('error', onError);
server.on('listening', onListening);
server.on('listening', () => {
});
server.listen(port, () => {
// Mount the websocket server if requested.
if (program.websockets) {
debug(`Websocket Server Listening on ${port}`);
// Mount the subscriptions server on the application server.
createSubscriptionManager(server);
}
});
}
const serve = require('../serve');
//==============================================================================
// Setting up the program command line arguments.
@@ -166,24 +12,6 @@ program
.option('-w, --websockets', 'enable the websocket (subscriptions) handler on this thread')
.parse(process.argv);
// Start the application serving.
startApp(program);
// Start serving.
serve({jobs: program.jobs, websockets: program.websockets});
// Enable job processing on the thread if enabled.
if (program.jobs) {
// Start the scraper processor.
scraper.process();
// Start the mail processor.
mailer.process();
}
// Define a safe shutdown function to call in the event we need to shutdown
// because the node hooks are below which will interrupt the shutdown process.
// Shutdown the mongoose connection, the app server, and the scraper.
util.onshutdown([
() => program.jobs ? kue.Task.shutdown() : null,
() => mongoose.disconnect(),
() => server.close()
]);
+1 -1
View File
@@ -195,7 +195,7 @@ const performSetup = async () => {
password: user.password
}
});
console.log('Settings created.');
console.log(`User ${newUser.id} created.`);
console.log('\nTalk is now installed!');
+31 -1
View File
@@ -241,13 +241,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 +404,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 +492,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.
+15 -4
View File
@@ -7,9 +7,7 @@ machine:
environment:
PATH: "${PATH}:${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin"
NODE_ENV: "test"
dependencies:
override:
pre:
# TODO: use the following to add in support for MongoDB 3.4.
# # Upgrade the database version to 3.4.
# - sudo apt-get purge mongodb-org*
@@ -19,10 +17,20 @@ dependencies:
# - sudo apt-get install -y mongodb-org
# - sudo service mongod restart
# Install chromium for e2e and remove old google-chrome
- sudo rm -rf /opt/google/chrome
- sudo rm -f /usr/bin/google-chrome*
- sudo apt-get update
- sudo apt-get install chromium-browser
dependencies:
override:
# Install node dependencies.
- yarn --version
- yarn global add node-gyp --force
- yarn global add node-gyp nsp --force
- yarn
post:
# Build the static assets.
- yarn build
@@ -40,6 +48,9 @@ test:
override:
# Run the tests using the junit reporter.
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml MOCHA_REPORTER=mocha-junit-reporter yarn test
# Check dependancies using nsp.
- nsp check
- yarn e2e-ci
deployment:
release:
+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}) => {
@@ -0,0 +1,13 @@
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};
};
export const setActiveSection = (section) => {
return {type: actions.SET_ACTIVE_SECTION, section};
};
@@ -1,58 +0,0 @@
import t from 'coral-framework/services/i18n';
export const SETTINGS_LOADING = 'SETTINGS_LOADING';
export const SETTINGS_RECEIVED = 'SETTINGS_RECEIVED';
export const SETTINGS_FETCH_ERROR = 'SETTINGS_FETCH_ERROR';
export const SETTINGS_UPDATED = 'SETTINGS_UPDATED';
export const SAVE_SETTINGS_LOADING = 'SAVE_SETTINGS_LOADING';
export const SAVE_SETTINGS_SUCCESS = 'SAVE_SETTINGS_SUCCESS';
export const SAVE_SETTINGS_FAILED = 'SAVE_SETTINGS_FAILED';
export const WORDLIST_UPDATED = 'WORDLIST_UPDATED';
export const DOMAINLIST_UPDATED = 'DOMAINLIST_UPDATED';
export const fetchSettings = () => (dispatch, _, {rest}) => {
dispatch({type: SETTINGS_LOADING});
rest('/settings')
.then((settings) => {
dispatch({type: SETTINGS_RECEIVED, settings});
})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: SETTINGS_FETCH_ERROR, error: errorMessage});
});
};
// for updating top-level settings
export const updateSettings = (settings) => {
return {type: SETTINGS_UPDATED, settings};
};
// this is a nested property, so it needs a special action.
export const updateWordlist = (listName, list) => {
return {type: WORDLIST_UPDATED, listName, list};
};
export const updateDomainlist = (listName, list) => {
return {type: DOMAINLIST_UPDATED, listName, list};
};
export const saveSettingsToServer = () => (dispatch, getState, {rest}) => {
let settings = getState().settings;
if (settings.charCount) {
settings.charCount = parseInt(settings.charCount);
}
dispatch({type: SAVE_SETTINGS_LOADING});
rest('/settings', {method: 'PUT', body: settings})
.then(() => {
dispatch({type: SAVE_SETTINGS_SUCCESS, settings});
})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch({type: SAVE_SETTINGS_FAILED, error: errorMessage});
});
};
@@ -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
};
};
@@ -4,6 +4,7 @@ import Layout from 'coral-admin/src/components/ui/Layout';
import styles from './NotFound.css';
import {Button, TextField, Alert, Success} from 'coral-ui';
import Recaptcha from 'react-recaptcha';
import cn from 'classnames';
class AdminLogin extends React.Component {
@@ -34,19 +35,22 @@ class AdminLogin extends React.Component {
render () {
const {errorMessage, loginMaxExceeded, recaptchaPublic} = this.props;
const signInForm = (
<form onSubmit={this.handleSignIn}>
<form className="talk-admin-login-sign-in" onSubmit={this.handleSignIn}>
{errorMessage && <Alert>{errorMessage}</Alert>}
<TextField
id="email"
label='Email Address'
value={this.state.email}
onChange={(e) => this.setState({email: e.target.value})} />
<TextField
id="password"
label='Password'
value={this.state.password}
onChange={(e) => this.setState({password: e.target.value})}
type='password' />
<div style={{height: 10}}></div>
<Button
className="talk-admin-login-sign-in-button"
type='submit'
cStyle='black'
full
@@ -90,7 +94,7 @@ class AdminLogin extends React.Component {
);
return (
<Layout fixedDrawer restricted={true}>
<div className={styles.loginLayout}>
<div className={cn(styles.loginLayout, 'talk-admin-login')}>
<h1 className={styles.loginHeader}>Team sign in</h1>
<p className={styles.loginCTA}>Sign in to interact with your community.</p>
{ this.state.requestPassword ? requestPasswordForm : signInForm }
@@ -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,
@@ -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.
@@ -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>
+9 -6
View File
@@ -5,14 +5,15 @@ 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}
className={cn('talk-admin-nav-dashboard', styles.navLink)}
to="/admin/dashboard"
activeClassName={styles.active}>
{t('configure.dashboard')}
@@ -20,19 +21,21 @@ const CoralDrawer = ({handleLogout, auth = {}}) => (
{
can(auth.user, 'MODERATE_COMMENTS') && (
<Link
className={styles.navLink}
className={cn('talk-admin-nav-moderate', styles.navLink)}
to="/admin/moderate"
activeClassName={styles.active}>
{t('configure.moderate')}
</Link>
)
}
<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 +44,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,
+18 -2
View File
@@ -2,17 +2,25 @@ import React from 'react';
import TagsInput from 'react-tagsinput';
import styles from './TagsInput.css';
import AutosizeInput from 'react-input-autosize';
import PropTypes from 'prop-types';
import cn from 'classnames';
const autosizingRenderInput = ({onChange, value, addTag: _, ...other}) =>
<AutosizeInput type='text' onChange={onChange} value={value} {...other} />;
export default (props) => {
autosizingRenderInput.propTypes = {
onChange: PropTypes.func,
value: PropTypes.string,
addTag: PropTypes.func,
};
const TagsInputComponent = ({className = '', ...props}) => {
return (
<TagsInput
addOnBlur={true}
addOnPaste={true}
pasteSplit={(data) => data.split(',').map((d) => d.trim())}
className={styles.root}
className={cn(styles.root, 'tags-input', className)}
focusedClassName={styles.rootFocus}
renderInput={autosizingRenderInput}
{...props}
@@ -29,3 +37,11 @@ export default (props) => {
/>
);
};
TagsInputComponent.propTypes = {
className: PropTypes.string,
inputProps: PropTypes.object,
tagProps: PropTypes.object,
};
export default TagsInputComponent;
@@ -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;
}
}
+87 -55
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 {
@@ -17,14 +20,22 @@ export default class UserDetail extends React.Component {
userId: PropTypes.string.isRequired,
hideUserDetail: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
bannedWords: PropTypes.array.isRequired,
suspectWords: PropTypes.array.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
changeStatus: PropTypes.func.isRequired,
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,
}),
activeTab: PropTypes.string.isRequired,
selectedCommentIds: PropTypes.array.isRequired,
viewUserDetail: PropTypes.any.isRequired,
loadMore: PropTypes.any.isRequired,
notify: PropTypes.func.isRequired
}
rejectThenReload = async (info) => {
@@ -33,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));
}
}
@@ -44,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));
}
}
@@ -79,14 +112,11 @@ export default class UserDetail extends React.Component {
},
activeTab,
selectedCommentIds,
bannedWords,
suspectWords,
toggleSelect,
bulkAccept,
bulkReject,
hideUserDetail,
viewUserDetail,
loadMore,
toggleSelectAll
} = this.props;
let rejectedPercent = (rejectedComments / totalComments) * 100;
@@ -104,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>
@@ -120,24 +150,22 @@ export default class UserDetail extends React.Component {
<ul className={styles.stats}>
<li className={styles.stat}>
<span className={styles.statItem}> Total Comments </span>
<spam className={styles.statResult}> {totalComments} </spam>
<span className={styles.statItem}>Total Comments</span>
<span className={styles.statResult}>{totalComments}</span>
</li>
<li className={styles.stat}>
<spam className={styles.statItem}> Reject Rate </spam>
<spam className={styles.statResult}> {`${(rejectedPercent).toFixed(1)}%`} </spam>
<span className={styles.statItem}>Reject Rate</span>
<span className={styles.statResult}>
{rejectedPercent.toFixed(1)}%
</span>
</li>
<li className={styles.stat}>
<spam className={styles.statItem}> Reports </spam>
<spam className={cn(styles.statReportResult, styles[getReliability(user.reliable.flagger)])}>
<span className={styles.statItem}>Reports</span>
<span className={cn(styles.statReportResult, styles[getReliability(user.reliable.flagger)])}>
{capitalize(getReliability(user.reliable.flagger))}
</spam>
</span>
</li>
</ul>
<p className={styles.small}>
Data represents the last six months of activity
</p>
</div>
<Slot
@@ -145,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;
@@ -184,8 +218,6 @@ export default class UserDetail extends React.Component {
root={root}
data={data}
comment={comment}
suspectWords={suspectWords}
bannedWords={bannedWords}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
selected={selected}
@@ -205,7 +237,7 @@ export default class UserDetail extends React.Component {
);
}
render () {
render() {
if (this.props.loading) {
return this.renderLoading();
}
@@ -30,12 +30,11 @@ class UserDetailComment extends React.Component {
render() {
const {
comment,
suspectWords,
bannedWords,
selected,
toggleSelect,
className,
data,
root: {settings: {wordlist: {banned, suspect}}},
} = this.props;
return (
@@ -72,8 +71,8 @@ class UserDetailComment extends React.Component {
<div className={styles.bodyContainer}>
<div className={styles.body}>
<CommentBodyHighlighter
suspectWords={suspectWords}
bannedWords={bannedWords}
suspectWords={suspect}
bannedWords={banned}
body={comment.body}
/>
{' '}
@@ -123,9 +122,15 @@ UserDetailComment.propTypes = {
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
className: PropTypes.string,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
toggleSelect: PropTypes.func,
root: PropTypes.shape({
settings: PropTypes.shape({
wordlist: PropTypes.shape({
suspect: PropTypes.arrayOf(PropTypes.string).isRequired,
banned: PropTypes.arrayOf(PropTypes.string).isRequired,
}),
}),
}),
comment: PropTypes.shape({
id: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
@@ -136,8 +141,8 @@ UserDetailComment.propTypes = {
title: PropTypes.string,
url: PropTypes.string,
id: PropTypes.string
})
})
}),
}),
};
export default UserDetailComment;
+10 -9
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';
@@ -24,7 +25,7 @@ const CoralHeader = ({
<Navigation className={styles.nav}>
<IndexLink
id='dashboardNav'
className={styles.navLink}
className={cn('talk-admin-nav-dashboard', styles.navLink)}
to="/admin/dashboard"
activeClassName={styles.active}>
{t('configure.dashboard')}
@@ -33,7 +34,7 @@ const CoralHeader = ({
can(auth.user, 'MODERATE_COMMENTS') && (
<Link
id='moderateNav'
className={styles.navLink}
className={cn('talk-admin-nav-moderate', styles.navLink)}
to="/admin/moderate"
activeClassName={styles.active}>
{t('configure.moderate')}
@@ -42,8 +43,8 @@ const CoralHeader = ({
)
}
<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 +52,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 +63,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 +76,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>
@@ -90,7 +91,7 @@ const CoralHeader = ({
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`;
@@ -0,0 +1,5 @@
const prefix = 'TALK_ADMIN_CONFIGURE';
export const UPDATE_PENDING = `${prefix}_UPDATE_PENDING`;
export const CLEAR_PENDING = `${prefix}_CLEAR_PENDING`;
export const SET_ACTIVE_SECTION = `${prefix}_SET_ACTIVE_SECTION`;
@@ -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')}
+12 -14
View File
@@ -11,10 +11,12 @@ 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 {
@@ -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}
@@ -152,7 +149,7 @@ export const withUserDetailQuery = withQuery(gql`
}
${getSlotFragmentSpreads(slots, 'user')}
}
totalComments: commentCount(query: {author_id: $author_id})
totalComments: commentCount(query: {author_id: $author_id, statuses: []})
rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
comments: comments(query: {
author_id: $author_id,
@@ -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,
@@ -179,8 +177,6 @@ const mapStateToProps = (state) => ({
selectedCommentIds: state.userDetail.selectedCommentIds,
statuses: state.userDetail.statuses,
activeTab: state.userDetail.activeTab,
bannedWords: state.settings.wordlist.banned,
suspectWords: state.settings.wordlist.suspect,
});
const mapDispatchToProps = (dispatch) => ({
@@ -190,6 +186,8 @@ const mapDispatchToProps = (dispatch) => ({
toggleSelectCommentInUserDetail,
viewUserDetail,
hideUserDetail,
toggleSelectAllCommentInUserDetail,
notify
}, dispatch)
});
@@ -8,7 +8,12 @@ import CommentDetails from './CommentDetails';
export default withFragments({
root: gql`
fragment CoralAdmin_UserDetailComment_root on RootQuery {
__typename
settings {
wordlist {
banned
suspect
}
}
...${getDefinitionName(CommentLabels.fragments.root)}
...${getDefinitionName(CommentDetails.fragments.root)}
}
+21
View File
@@ -1,4 +1,15 @@
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);
});
}
export default {
mutations: {
@@ -29,6 +40,16 @@ export default {
}
}
}),
UpdateSettings: ({variables: {input}}) => ({
updateQueries: {
TalkAdmin_Configure: (prev) => {
const updated = update(prev, {
settings: mapLeaves(input, (leaf) => ({$set: leaf})),
});
return updated;
}
}
}),
},
};
+1
View File
@@ -23,6 +23,7 @@ function init({store, storage}) {
async function main() {
const notification = createNotificationService(toast);
const context = await createContext({reducers, graphqlExtension, pluginsConfig, notification, init});
render(
<TalkProvider {...context}>
<App />
-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;
}
@@ -0,0 +1,47 @@
import * as actions from '../constants/configure';
import isEmpty from 'lodash/isEmpty';
import update from 'immutability-helper';
const initialState = {
canSave: false,
pending: {},
errors: {},
activeSection: 'stream',
};
export default function configure(state = initialState, action) {
switch (action.type) {
case actions.UPDATE_PENDING: {
let next = state;
if (action.updater) {
next = update(next, {
pending: action.updater,
});
}
if (action.errorUpdater) {
next = update(next, {
errors: action.errorUpdater,
});
}
const noErrors = Object.keys(next.errors).reduce((res, error) => res && !next.errors[error], true);
const canSave = !isEmpty(next.pending) && noErrors;
next = update(next, {
canSave: {$set: canSave},
});
return next;
}
case actions.CLEAR_PENDING:
return {
...state,
pending: {},
canSave: false,
};
case actions.SET_ACTIVE_SECTION:
return {
...state,
activeSection: action.section,
};
}
return state;
}
@@ -0,0 +1,15 @@
// 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;
}
+6 -4
View File
@@ -1,6 +1,7 @@
import auth from './auth';
import assets from './assets';
import settings from './settings';
import stories from './stories';
import dashboard from './dashboard';
import configure from './configure';
import community from './community';
import moderation from './moderation';
import install from './install';
@@ -12,10 +13,11 @@ import userDetail from './userDetail';
export default {
auth,
banUserDialog,
dashboard,
configure,
suspendUserDialog,
userDetail,
assets,
settings,
stories,
community,
moderation,
install,
@@ -1,92 +0,0 @@
import * as actions from '../actions/settings';
import update from 'immutability-helper';
// this is initialized here because
// currently you have to reload the dashboard to get new stats
// cleaner updates are planned in the future.
// TODO: if there are more than two fields for the dashboard being created here,
// please create a new reducer specifically for the Dashboard.
const DASHBOARD_WINDOW_MINUTES = 5;
let then = new Date();
then.setMinutes(then.getMinutes() - DASHBOARD_WINDOW_MINUTES);
const initialState = {
wordlist: {
banned: [],
suspect: []
},
dashboardWindowStart: then.toISOString(),
dashboardWindowEnd: new Date().toISOString(),
domains: {
whitelist: []
},
saveSettingsError: null,
fetchSettingsError: null,
fetchingSettings: false
};
export default function settings (state = initialState, action) {
switch (action.type) {
case actions.SETTINGS_LOADING:
return {
...state,
fetchingSettings: true,
fetchSettingsError: null,
};
case actions.SETTINGS_RECEIVED:
return {
...state,
fetchingSettings: false,
fetchSettingsError: null,
...action.settings
};
case actions.SETTINGS_FETCH_ERROR:
return {
...state,
fetchingSettings: false,
fetchSettingsError: action.error,
};
case actions.SETTINGS_UPDATED:
return {
...state,
fetchingSettings: false,
fetchSettingsError: null,
...action.settings
};
case actions.SAVE_SETTINGS_LOADING:
return {
...state,
fetchingSettings: true,
saveSettingsError: null,
};
case actions.SAVE_SETTINGS_SUCCESS:
return {
...state,
fetchingSettings: false,
fetchSettingsError: null,
...action.settings
};
case actions.SAVE_SETTINGS_FAILED:
return {
...state,
fetchingSettings: false,
fetchSettingsError: action.error,
};
case actions.WORDLIST_UPDATED:
return update(state, {
wordlist: {
[action.listName]: {
$set: action.list
}
}
});
case actions.DOMAINLIST_UPDATED:
return update(state, {
domains: {
[action.listName]: {$set: action.list},
}
});
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;
@@ -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,32 +1,28 @@
import React from 'react';
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';
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'
}
];
import PropTypes from 'prop-types';
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={styles.leftColumn}>
@@ -46,20 +42,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 = {
onHeaderClickHandler: PropTypes.func,
users: PropTypes.array,
page: PropTypes.number.isRequired,
searchValue: PropTypes.string,
onSearchChange: PropTypes.func,
totalPages: PropTypes.number,
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 {
@@ -19,6 +23,13 @@
}
}
.username, .email {
max-width: 215px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email {
display: block;
}
@@ -1,56 +1,96 @@
import React from 'react';
import {SelectField, Option} from 'react-mdl-selectfield';
import styles from '../components/Table.css';
import styles from './Table.css';
import t from 'coral-framework/services/i18n';
import PropTypes from 'prop-types';
import {Paginate, Dropdown, Option} from 'coral-ui';
import cn from 'classnames';
export default ({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"
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={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">
<SelectField
value={row.status || ''}
className={styles.selectField}
label={t('community.status')}
onChange={(status) => onCommenterStatusChange(row.id, status)}>
<Option value={'ACTIVE'}>{t('community.active')}</Option>
<Option value={'BANNED'}>{t('community.banned')}</Option>
</SelectField>
</td>
<td className="mdl-data-table__cell--non-numeric">
<SelectField
value={row.roles[0] || ''}
className={styles.selectField}
label={t('community.role')}
onChange={(role) => onRoleChange(row.id, role)}>
<Option value={''}>.</Option>
<Option value={'STAFF'}>{t('community.staff')}</Option>
<Option value={'MODERATOR'}>{t('community.moderator')}</Option>
<Option value={'ADMIN'}>{t('community.admin')}</Option>
</SelectField>
</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}>
<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) => 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
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 = {
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,39 +0,0 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose} from 'react-apollo';
import {setRole, setCommenterStatus} from '../../../actions/community';
import Table from '../components/Table';
import {viewUserDetail} from '../../../actions/userDetail';
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}
/>;
}
}
const mapStateToProps = (state) => ({
commenters: state.community.accounts,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
setCommenterStatus,
setRole,
viewUserDetail,
}, dispatch);
export default compose(
connect(mapStateToProps, mapDispatchToProps),
)(TableContainer);
@@ -1,19 +1,7 @@
/**
* @TODO: deprecated as this file contains styles from multiple components. Please refactor.
*/
.container {
max-width: 1280px;
margin: 0 auto;
display: flex;
max-width: 1280px;
margin: 0 auto;
h3 {
color: black;
font-size: 1.26em;
font-weight: 500;
}
}
.leftColumn {
@@ -21,108 +9,8 @@
width: 234px;
}
.mainContent {
width: calc(100% - 300px);
padding: 10px 14px;
box-sizing: border-box;
max-width: 718px;
}
.configSetting {
margin-bottom: 20px;
align-items: flex-start;
min-height: 100px;
max-width: 600px;
h3 {
margin: 0;
}
.actions {
display: inline-block;
width: 100%;
.copiedText {
display: inline-block;
color: #00796b;
padding: 12px;
font-size: 14px;
float: right;
}
.copyButton {
display: inline-block;
width: 200px;
float: right;
}
}
}
.settingsError {
color: #d50000;
}
.settingsError i {
font-size: 14px;
margin-right: 3px;
}
.settingsHeader {
margin-top: 3px;
margin-bottom: 7px;
font-size: 18px;
font-weight: 500;
}
.disabledSettingText {
color: #ccc;
}
.configSettingInfoBox {
min-height: 100px;
margin-bottom: 20px;
width: auto;
height: auto;
text-align: left;
overflow: visible;
}
.configSettingEmbed {
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 10px;
display: block;
}
.configTimeoutSelect {
display: inline-block;
margin-left: 20px;
i { /* fix for firefox and react-mdl-selectfield@0.2.0 */
padding: 20px 0;
vertical-align: top;
}
}
.inlineTextfield {
border-color: #ccc;
border-style: solid;
border-width: 0px 0px 1px 0px;
text-align: center;
font-size: inherit;
}
.inlineTextfield:focus {
outline: none;
}
.charCountTexfield, .editCommentTimeframeTextfield {
width: 4em;
padding: 0px;
}
.charCountTexfieldEnabled {
border-color: #00796b;
.saveBox {
margin-top: 38px;
}
.changedSave {
@@ -130,81 +18,10 @@
color: white;
}
.embedInput {
width: 100%;
display: block;
outline: none;
border: 1px solid rgba(0,0,0,.12);
padding: 6px;
.mainContent {
width: calc(100% - 300px);
padding: 10px 14px 80px 14px;
box-sizing: border-box;
border-radius: 2px;
margin: 5px auto;
min-height: 175px;
font-size: 14px;
resize: none;
max-width: 718px;
}
.customCSSInput {
width: 100%;
font-size: 14px;
padding: 14px;
letter-spacing: 0.03em;
color: #555;
box-sizing: border-box;
}
.enabledSetting {
border-left-color: #00796b;
border-left-style: solid;
border-left-width: 7px;
}
.disabledSetting {
padding-left: 22px;
}
.hidden {
display: none;
}
.saveBox {
margin-top: 38px;
}
.settingsSection {
padding-bottom: 200px;
.action {
display: inline-block;
position: absolute;
top: 0;
left: 0;
padding: 20px;
}
.content {
display: inline-block;
padding: 0px 30px;
box-sizing: border-box;
width: 100%;
}
}
.Configure {
p {
line-height: 1.2;
max-width: 550px;
}
.wrapper {
width: 100%;
}
.descriptionBox {
margin-top: 15px;
input {
height: 150px;
}
}
}
@@ -1,119 +1,40 @@
import React, {Component} from 'react';
import {Button, List, Item, Card, Spinner} from 'coral-ui';
import {Button, List, Item} from 'coral-ui';
import styles from './Configure.css';
import StreamSettings from './StreamSettings';
import ModerationSettings from './ModerationSettings';
import TechSettings from './TechSettings';
import StreamSettings from '../containers/StreamSettings';
import ModerationSettings from '../containers/ModerationSettings';
import TechSettings from '../containers/TechSettings';
import t from 'coral-framework/services/i18n';
import {can} from 'coral-framework/services/perms';
import PropTypes from 'prop-types';
export default class Configure extends Component {
state = {
activeSection: 'stream',
changed: false,
errors: {}
};
saveSettings = () => {
this.props.saveSettingsToServer();
this.setState({changed: false});
}
changeSection = (activeSection) => {
this.setState({activeSection});
}
onChangeWordlist = (listName, list) => {
this.setState({changed: true});
this.props.updateWordlist(listName, list);
}
onChangeDomainlist = (listName, list) => {
this.setState({changed: true});
this.props.updateDomainlist(listName, list);
}
onSettingUpdate = (setting) => {
this.setState({changed: true});
this.props.updateSettings(setting);
}
// Sets an arbitrary error string and a boolean state.
// This allows the system to track multiple errors.
onSettingError = (error, state) => {
this.setState((prevState) => {
prevState.errors[error] = state;
return prevState;
});
}
getSection (section) {
const pageTitle = this.getPageTitle(section);
let sectionComponent;
getSectionComponent(section) {
switch(section){
case 'stream':
sectionComponent = <StreamSettings
settings={this.props.settings}
updateSettings={this.onSettingUpdate}
errors={this.state.errors}
settingsError={this.onSettingError}/>;
break;
return StreamSettings;
case 'moderation':
sectionComponent = <ModerationSettings
onChangeWordlist={this.onChangeWordlist}
settings={this.props.settings}
updateSettings={this.onSettingUpdate} />;
break;
return ModerationSettings;
case 'tech':
sectionComponent = <TechSettings
onChangeDomainlist={this.onChangeDomainlist}
settings={this.props.settings}
updateSettings={this.onSettingUpdate} />;
}
if (this.props.settings.fetchingSettings) {
return <Card shadow="4"><Spinner/>Loading settings...</Card>;
}
return (
<div className={styles.settingsSection}>
<h3>{pageTitle}</h3>
{sectionComponent}
</div>
);
}
getPageTitle (section) {
switch(section) {
case 'stream':
return t('configure.stream_settings');
case 'moderation':
return t('configure.moderation_settings');
case 'tech':
return t('configure.tech_settings');
default:
return '';
return TechSettings;
}
throw new Error(`Unknown section ${section}`);
}
render () {
const {activeSection} = this.state;
const section = this.getSection(activeSection);
const {auth: {user}} = this.props;
const {auth: {user}, canSave, savePending, setActiveSection, activeSection} = this.props;
const SectionComponent = this.getSectionComponent(activeSection);
if (!can(user, 'UPDATE_CONFIG')) {
return <p>You must be an administrator to access config settings. Please find the nearest Admin and ask them to level you up!</p>;
}
const showSave = Object.keys(this.state.errors).reduce(
(bool, error) => this.state.errors[error] ? false : bool, this.state.changed);
return (
<div className={styles.container}>
<div className={styles.leftColumn}>
<List onChange={this.changeSection} activeItem={activeSection}>
<List onChange={setActiveSection} activeItem={activeSection}>
<Item itemId='stream' icon='speaker_notes'>
{t('configure.stream_settings')}
</Item>
@@ -126,10 +47,10 @@ export default class Configure extends Component {
</List>
<div className={styles.saveBox}>
{
showSave ?
canSave ?
<Button
raised
onClick={this.saveSettings}
onClick={savePending}
className={styles.changedSave}
icon='check'
full
@@ -150,11 +71,25 @@ export default class Configure extends Component {
</div>
<div className={styles.mainContent}>
{ this.props.saveFetchingError }
{ this.props.fetchSettingsError }
{ section }
<SectionComponent
data={this.props.data}
root={this.props.root}
settings={this.props.settings}
/>
</div>
</div>
);
}
}
Configure.propTypes = {
notify: PropTypes.func.isRequired,
savePending: PropTypes.func.isRequired,
auth: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
setActiveSection: PropTypes.func.isRequired,
activeSection: PropTypes.string.isRequired,
};
@@ -0,0 +1,5 @@
.title {
color: black;
font-size: 1.26em;
font-weight: 500;
}
@@ -0,0 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './ConfigurePage.css';
const ConfigurePage = ({title, children, ...rest}) => (
<div {...rest}>
<h3 className={styles.title}>{title}</h3>
{children}
</div>
);
ConfigurePage.propTypes = {
title: PropTypes.string.isRequired,
children: PropTypes.node,
};
export default ConfigurePage;
@@ -1,25 +1,25 @@
import React from 'react';
import {Card} from 'coral-ui';
import styles from './Configure.css';
import PropTypes from 'prop-types';
import TagsInput from 'coral-admin/src/components/TagsInput';
import t from 'coral-framework/services/i18n';
import ConfigureCard from 'coral-framework/components/ConfigureCard';
const Domainlist = ({domains, onChangeDomainlist}) => {
return (
<Card id={styles.domainlist} className={styles.configSetting}>
<div className={styles.wrapper}>
<div className={styles.settingsHeader}>{t('configure.domain_list_title')}</div>
<p className={styles.domainlistDesc}>{t('configure.domain_list_text')}</p>
<div className={styles.wrapper}>
<TagsInput
value={domains}
inputProps={{placeholder: 'URL'}}
onChange={(tags) => onChangeDomainlist('whitelist', tags)}
/>
</div>
</div>
</Card>
<ConfigureCard title={t('configure.domain_list_title')}>
<p>{t('configure.domain_list_text')}</p>
<TagsInput
value={domains}
inputProps={{placeholder: 'URL'}}
onChange={(tags) => onChangeDomainlist('whitelist', tags)}
/>
</ConfigureCard>
);
};
Domainlist.propTypes = {
domains: PropTypes.array.isRequired,
onChangeDomainlist: PropTypes.func.isRequired,
};
export default Domainlist;
@@ -0,0 +1,32 @@
.embedInput {
width: 100%;
display: block;
outline: none;
border: 1px solid rgba(0,0,0,.12);
padding: 6px;
box-sizing: border-box;
border-radius: 2px;
margin: 5px auto;
min-height: 175px;
font-size: 14px;
resize: none;
}
.copiedText {
display: inline-block;
color: #00796b;
padding: 12px;
font-size: 14px;
float: right;
}
.copyButton {
display: inline-block;
width: 200px;
float: right;
}
.actions {
display: inline-block;
width: 100%;
}
@@ -1,17 +1,14 @@
import React, {Component} from 'react';
import t from 'coral-framework/services/i18n';
import join from 'url-join';
import styles from './Configure.css';
import {Button, Card} from 'coral-ui';
import styles from './EmbedLink.css';
import {Button} from 'coral-ui';
import {BASE_URL} from 'coral-framework/constants/url';
import ConfigureCard from 'coral-framework/components/ConfigureCard';
class EmbedLink extends Component {
constructor (props) {
super(props);
this.state = {copied: false};
}
state = {copied: false};
copyToClipBoard = () => {
const copyTextarea = document.querySelector(`.${styles.embedInput}`);
@@ -38,21 +35,18 @@ class EmbedLink extends Component {
"></script>
`.trim();
return (
<Card shadow="2" className={styles.configSetting}>
<div className={styles.wrapper}>
<div className={styles.settingsHeader}>Embed Comment Stream</div>
<p>{t('configure.copy_and_paste')}</p>
<textarea rows={5} type='text' className={styles.embedInput} value={embedText} readOnly={true}/>
<div className={styles.actions}>
<Button raised className={styles.copyButton} onClick={this.copyToClipBoard} cStyle="black">
{t('embedlink.copy')}
</Button>
<div className={styles.copiedText}>
{this.state.copied && 'Copied!'}
</div>
<ConfigureCard title={'Embed Comment Stream'}>
<p>{t('configure.copy_and_paste')}</p>
<textarea rows={5} type='text' className={styles.embedInput} value={embedText} readOnly={true}/>
<div className={styles.actions}>
<Button raised className={styles.copyButton} onClick={this.copyToClipBoard} cStyle="black">
{t('embedlink.copy')}
</Button>
<div className={styles.copiedText}>
{this.state.copied && 'Copied!'}
</div>
</div>
</Card>
</ConfigureCard>
);
}
}
@@ -1,90 +1,90 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './Configure.css';
import {Card} from 'coral-ui';
import {Checkbox} from 'react-mdl';
import Wordlist from './Wordlist';
import Slot from 'coral-framework/components/Slot';
import t from 'coral-framework/services/i18n';
import ConfigurePage from './ConfigurePage';
import ConfigureCard from 'coral-framework/components/ConfigureCard';
const updateModeration = (updateSettings, mod) => () => {
const moderation = mod === 'PRE' ? 'POST' : 'PRE';
updateSettings({moderation});
};
class ModerationSettings extends React.Component {
const updateEmailConfirmation = (updateSettings, verify) => () => {
updateSettings({requireEmailConfirmation: !verify});
};
updateModeration = () => {
const updater = {moderation: {$set: this.props.settings.moderation === 'PRE' ? 'POST' : 'PRE'}};
this.props.updatePending({updater});
};
const updatePremodLinksEnable = (updateSettings, premodLinks) => () => {
const premodLinksEnable = !premodLinks;
updateSettings({premodLinksEnable});
};
updateEmailConfirmation = () => {
const updater = {requireEmailConfirmation: {$set: !this.props.settings.requireEmailConfirmation}};
this.props.updatePending({updater});
};
const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => {
updatePremodLinksEnable = () => {
const updater = {premodLinksEnable: {$set: !this.props.settings.premodLinksEnable}};
this.props.updatePending({updater});
};
// just putting this here for shorthand below
const on = styles.enabledSetting;
const off = styles.disabledSetting;
updateWordlist = (listName, list) => {
this.props.updatePending({updater: {
wordlist: {$apply: (wordlist) => {
const changeSet = {[listName]: list};
if (!wordlist) {
return changeSet;
}
return {
...wordlist,
...changeSet,
};
}},
}});
};
return (
<div className={styles.Configure}>
<Card className={`${styles.configSetting} ${settings.requireEmailConfirmation ? on : off}`}>
<div className={styles.action}>
<Checkbox
onChange={updateEmailConfirmation(updateSettings, settings.requireEmailConfirmation)}
checked={settings.requireEmailConfirmation} />
</div>
<div className={styles.content}>
<div className={styles.settingsHeader}>{t('configure.require_email_verification')}</div>
<p className={settings.requireEmailConfirmation ? '' : styles.disabledSettingText}>
{t('configure.require_email_verification_text')}
</p>
</div>
</Card>
<Card className={`${styles.configSetting} ${settings.moderation === 'PRE' ? on : off}`}>
<div className={styles.action}>
<Checkbox
onChange={updateModeration(updateSettings, settings.moderation)}
checked={settings.moderation === 'PRE'} />
</div>
<div className={styles.content}>
<div className={styles.settingsHeader}>{t('configure.enable_pre_moderation')}</div>
<p className={settings.moderation === 'PRE' ? '' : styles.disabledSettingText}>
{t('configure.enable_pre_moderation_text')}
</p>
</div>
</Card>
<Card className={`${styles.configSetting} ${settings.premodLinksEnable ? on : off}`}>
<div className={styles.action}>
<Checkbox
onChange={updatePremodLinksEnable(updateSettings, settings.premodLinksEnable)}
checked={settings.premodLinksEnable} />
</div>
<div className={styles.content}>
<div className={styles.settingsHeader}>{t('configure.enable_premod_links')}</div>
<p>
{t('configure.enable_premod_links_text')}
</p>
</div>
</Card>
<Wordlist
bannedWords={settings.wordlist.banned}
suspectWords={settings.wordlist.suspect}
onChangeWordlist={onChangeWordlist} />
</div>
);
};
render() {
const {settings, data, root} = this.props;
return (
<ConfigurePage
title={t('configure.moderation_settings')}
>
<ConfigureCard
checked={settings.requireEmailConfirmation}
onCheckbox={this.updateEmailConfirmation}
title={t('configure.require_email_verification')}
>
{t('configure.require_email_verification_text')}
</ConfigureCard>
<ConfigureCard
checked={settings.moderation === 'PRE'}
onCheckbox={this.updateModeration}
title={t('configure.enable_pre_moderation')}
>
{t('configure.enable_pre_moderation_text')}
</ConfigureCard>
<ConfigureCard
checked={settings.premodLinksEnable}
onCheckbox={this.updatePremodLinksEnable}
title={t('configure.enable_premod_links')}
>
{t('configure.enable_premod_links_text')}
</ConfigureCard>
<Wordlist
bannedWords={settings.wordlist.banned}
suspectWords={settings.wordlist.suspect}
onChangeWordlist={this.updateWordlist} />
<Slot
fill="adminModerationSettings"
data={data}
queryData={{root, settings}}
/>
</ConfigurePage>
);
}
}
ModerationSettings.propTypes = {
onChangeWordlist: PropTypes.func.isRequired,
settings: PropTypes.shape({
moderation: PropTypes.string.isRequired,
wordlist: PropTypes.shape({
banned: PropTypes.array.isRequired,
suspect: PropTypes.array.isRequired
})
}).isRequired,
updateSettings: PropTypes.func.isRequired
updatePending: PropTypes.func.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
};
export default ModerationSettings;
@@ -0,0 +1,55 @@
.configSettingInfoBox {
min-height: 100px;
margin-bottom: 20px;
width: auto;
height: auto;
text-align: left;
overflow: visible;
}
.descriptionBox {
margin-top: 15px;
input {
height: 150px;
}
}
.configTimeoutSelect {
display: inline-block;
margin-left: 20px;
i { /* fix for firefox and react-mdl-selectfield@0.2.0 */
padding: 20px 0;
vertical-align: top;
}
}
.hidden {
display: none;
}
.inlineTextfield {
border-color: #ccc;
border-style: solid;
border-width: 0px 0px 1px 0px;
text-align: center;
font-size: inherit;
}
.inlineTextfield:focus {
outline: none;
}
.charCountTexfield, .editCommentTimeframeTextfield {
width: 4em;
padding: 0px;
}
.charCountTexfieldEnabled {
border-color: #00796b;
}
@@ -1,10 +1,15 @@
import React from 'react';
import {SelectField, Option} from 'react-mdl-selectfield';
import t from 'coral-framework/services/i18n';
import styles from './Configure.css';
import {Checkbox, Textfield} from 'react-mdl';
import {Card, Icon, TextArea} from 'coral-ui';
import styles from './StreamSettings.css';
import {Textfield} from 'react-mdl';
import {Icon, TextArea} from 'coral-ui';
import PropTypes from 'prop-types';
import Slot from 'coral-framework/components/Slot';
import MarkdownEditor from 'coral-framework/components/MarkdownEditor';
import cn from 'classnames';
import ConfigurePage from './ConfigurePage';
import ConfigureCard from 'coral-framework/components/ConfigureCard';
const TIMESTAMPS = {
weeks: 60 * 60 * 24 * 7,
@@ -12,187 +17,6 @@ const TIMESTAMPS = {
hours: 60 * 60
};
const updateCharCountEnable = (updateSettings, charCountChecked) => () => {
const charCountEnable = !charCountChecked;
updateSettings({charCountEnable});
};
const updateCharCount = (updateSettings, settingsError) => (event) => {
const charCount = event.target.value;
if (charCount.match(/[^0-9]/) || charCount.length === 0) {
settingsError('charCount', true);
} else {
settingsError('charCount', false);
}
updateSettings({charCount: charCount});
};
const updateInfoBoxEnable = (updateSettings, infoBox) => () => {
const infoBoxEnable = !infoBox;
updateSettings({infoBoxEnable});
};
const updateInfoBoxContent = (updateSettings) => (value) => {
const infoBoxContent = value;
updateSettings({infoBoxContent});
};
const updateAutoClose = (updateSettings, autoCloseStream) => () => {
updateSettings({autoCloseStream});
};
const updateClosedMessage = (updateSettings) => (event) => {
const closedMessage = event.target.value;
updateSettings({closedMessage});
};
// If we are changing the measure we need to recalculate using the old amount
// Same thing if we are just changing the amount
const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => {
if (isMeasure) {
const amount = getTimeoutAmount(ts);
const closedTimeout = amount * TIMESTAMPS[event];
updateSettings({closedTimeout});
} else {
const val = event.target.value;
const measure = getTimeoutMeasure(ts);
const closedTimeout = val * TIMESTAMPS[measure];
updateSettings({closedTimeout});
}
};
const updateEditCommentWindowLength = (updateSettings) => (e) => {
const value = e.target.value;
const valueAsNumber = parseFloat(value);
const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000);
updateSettings({editCommentWindowLength: milliseconds || value});
};
const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
// just putting this here for shorthand below
const on = styles.enabledSetting;
const off = styles.disabledSetting;
return (
<div className={styles.Configure}>
<Card className={`${styles.configSetting} ${settings.charCountEnable ? on : off}`}>
<div className={styles.action}>
<Checkbox
onChange={updateCharCountEnable(updateSettings, settings.charCountEnable)}
checked={settings.charCountEnable} />
</div>
<div className={styles.content}>
<div className={styles.settingsHeader}>{t('configure.comment_count_header')}</div>
<p className={settings.charCountEnable ? '' : styles.disabledSettingText}>
<span>{t('configure.comment_count_text_pre')}</span>
<input type='text'
className={`${styles.inlineTextfield} ${styles.charCountTexfield} ${settings.charCountEnable && styles.charCountTexfieldEnabled}`}
htmlFor='charCount'
onChange={updateCharCount(updateSettings, settingsError)}
value={settings.charCount}
disabled={settings.charCountEnable ? '' : 'disabled'}
/>
<span>{t('configure.comment_count_text_post')}</span>
{
errors.charCount &&
<span className={styles.settingsError}>
<br/>
<Icon name="error_outline"/>
{t('configure.comment_count_error')}
</span>
}
</p>
</div>
</Card>
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox} ${settings.infoBoxEnable ? on : off}`}>
<div className={styles.action}>
<Checkbox
onChange={updateInfoBoxEnable(updateSettings, settings.infoBoxEnable)}
checked={settings.infoBoxEnable} />
</div>
<div className={styles.content}>
<div className={styles.settingsHeader}>
{t('configure.include_comment_stream')}
</div>
<p className={settings.infoBoxEnable ? '' : styles.disabledSettingText}>
{t('configure.include_comment_stream_desc')}
</p>
<div className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
<MarkdownEditor
className={styles.descriptionBox}
onChange={updateInfoBoxContent(updateSettings)}
value={settings.infoBoxContent}
/>
</div>
</div>
</Card>
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox}`}>
<div className={styles.wrapper}>
<div className={styles.settingsHeader}>{t('configure.closed_stream_settings')}</div>
<p>{t('configure.closed_comments_desc')}</p>
<div>
<TextArea className={styles.descriptionBox}
onChange={updateClosedMessage(updateSettings)}
value={settings.closedMessage}
/>
</div>
</div>
</Card>
{/* Edit Comment Timeframe */}
<Card className={styles.configSetting}>
<div className={styles.settingsHeader}>{t('configure.edit_comment_timeframe_heading')}</div>
<p>
{t('configure.edit_comment_timeframe_text_pre')}
&nbsp;
<input
className={`${styles.inlineTextfield} ${styles.editCommentTimeframeTextfield}`}
type="number"
min="0"
onChange={updateEditCommentWindowLength(updateSettings)}
placeholder="30"
defaultValue={(settings.editCommentWindowLength / 1000) /* saved as ms, rendered as seconds */}
pattern='[0-9]+([\.][0-9]*)?'
/>
&nbsp;
{t('configure.edit_comment_timeframe_text_post')}
</p>
</Card>
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox}`}>
<div className={styles.action}>
<Checkbox
onChange={updateAutoClose(updateSettings, !settings.autoCloseStream)}
checked={settings.autoCloseStream} />
</div>
<div className={styles.content}>
<div className={styles.settingsHeader}>{t('configure.close_after')}</div>
<br />
<Textfield
type='number'
pattern='[0-9]+'
style={{width: 50}}
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout)}
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={updateClosedTimeout(updateSettings, settings.closedTimeout, true)}>
<Option value={'hours'}>{t('configure.hours')}</Option>
<Option value={'days'}>{t('configure.days')}</Option>
<Option value={'weeks'}>{t('configure.weeks')}</Option>
</SelectField>
</div>
</div>
</Card>
{/* the above card should be the last one if at all possible because of z-index issues with the selects */}
</div>
);
};
export default StreamSettings;
// To see if we are talking about weeks, days or hours
// We talk the remainder of the division and see if it's 0
const getTimeoutMeasure = (ts) => {
@@ -208,3 +32,190 @@ const getTimeoutMeasure = (ts) => {
// Dividing the amount by it's measure (hours, days, weeks) we
// obtain the amount of time
const getTimeoutAmount = (ts) => ts / TIMESTAMPS[getTimeoutMeasure(ts)];
class StreamSettings extends React.Component {
updateCharCountEnable = () => {
const updater = {charCountEnable: {$set: !this.props.settings.charCountEnable}};
this.props.updatePending({updater});
};
updateCharCount = (event) => {
let error = null;
const charCount = event.target.value;
if (charCount.match(/[^0-9]/) || charCount.length === 0) {
error = true;
}
const updater = {charCount: {$set: charCount}};
const errorUpdater = {charCount: {$set: error}};
this.props.updatePending({updater, errorUpdater});
};
updateInfoBoxEnable = () => {
const updater = {infoBoxEnable: {$set: !this.props.settings.infoBoxEnable}};
this.props.updatePending({updater});
};
updateInfoBoxContent = (value) => {
const updater = {infoBoxContent: {$set: value}};
this.props.updatePending({updater});
};
updateClosedMessage = (event) => {
const updater = {closedMessage: {$set: event.target.value}};
this.props.updatePending({updater});
};
updateEditCommentWindowLength = (e) => {
const value = e.target.value;
const valueAsNumber = parseFloat(value);
const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000);
const updater = {editCommentWindowLength: {$set: milliseconds || value}};
this.props.updatePending({updater});
};
updateAutoClose = () => {
const updater = {autoCloseStream: {$set: !this.props.settings.autoCloseStream}};
this.props.updatePending({updater});
};
updateClosedTimeout = (event) => {
const val = event.target.value;
const measure = getTimeoutMeasure(this.props.settings.closedTimeout);
const updater = {closedTimeout: {$set: val * TIMESTAMPS[measure]}};
this.props.updatePending({updater});
};
// If we are changing the measure we need to recalculate using the old amount
// Same thing if we are just changing the amount
updateClosedTimeoutMeasure = (event) => {
const amount = getTimeoutAmount(this.props.settings.closedTimeout);
const updater = {closedTimeout: {$set: amount * TIMESTAMPS[event]}};
this.props.updatePending({updater});
};
render() {
const {settings, data, root, errors} = this.props;
return (
<ConfigurePage
title={t('configure.stream_settings')}
>
<ConfigureCard
checked={settings.charCountEnable}
onCheckbox={this.updateCharCountEnable}
title={t('configure.comment_count_header')}
>
<span>{t('configure.comment_count_text_pre')}</span>
<input type='text'
className={cn(styles.inlineTextfield, styles.charCountTexfield, settings.charCountEnable && styles.charCountTexfieldEnable)}
htmlFor='charCount'
onChange={this.updateCharCount}
value={settings.charCount}
disabled={settings.charCountEnable ? '' : 'disabled'}
/>
<span>{t('configure.comment_count_text_post')}</span>
{
errors.charCount &&
<span className={styles.settingsError}>
<br/>
<Icon name="error_outline"/>
{t('configure.comment_count_error')}
</span>
}
</ConfigureCard>
<ConfigureCard
checked={settings.infoBoxEnable}
onCheckbox={this.updateInfoBoxEnable}
title={t('configure.include_comment_stream')}
>
<p>
{t('configure.include_comment_stream_desc')}
</p>
<div className={cn(styles.configSettingInfoBox, settings.infoBoxEnable ? null : styles.hidden)} >
<MarkdownEditor
className={styles.descriptionBox}
onChange={this.updateInfoBoxContent}
value={settings.infoBoxContent}
/>
</div>
</ConfigureCard>
<ConfigureCard
checked={settings.configSettingInfoBox}
onCheckbox={this.updateClosedMessage}
title={t('configure.closed_stream_settings')}
>
<p>{t('configure.closed_comments_desc')}</p>
<div>
<TextArea className={styles.descriptionBox}
onChange={this.updateClosedMessage}
value={settings.closedMessage}
/>
</div>
</ConfigureCard>
<ConfigureCard
title={t('configure.edit_comment_timeframe_heading')}
>
{t('configure.edit_comment_timeframe_text_pre')}
&nbsp;
<input
className={cn(styles.inlineTextfield, styles.editCommentTimeframeTextfield)}
type="number"
min="0"
onChange={this.updateEditCommentWindowLength}
placeholder="30"
defaultValue={(settings.editCommentWindowLength / 1000) /* saved as ms, rendered as seconds */}
pattern='[0-9]+([\.][0-9]*)?'
/>
&nbsp;
{t('configure.edit_comment_timeframe_text_post')}
</ConfigureCard>
<ConfigureCard
checked={settings.autoCloseStream}
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>
</ConfigureCard>
{/* the above card should be the last one if at all possible because of z-index issues with the selects */}
<Slot
fill="adminStreamSettings"
data={data}
queryData={{root, settings}}
/>
</ConfigurePage>
);
}
}
StreamSettings.propTypes = {
updatePending: PropTypes.func.isRequired,
errors: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
};
export default StreamSettings;
@@ -0,0 +1,10 @@
.customCSSInput {
width: 100%;
font-size: 14px;
padding: 14px;
letter-spacing: 0.03em;
color: #555;
box-sizing: border-box;
}
@@ -1,44 +1,67 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Card} from 'coral-ui';
import Domainlist from './Domainlist';
import EmbedLink from './EmbedLink';
import styles from './Configure.css';
import styles from './TechSettings.css';
import Slot from 'coral-framework/components/Slot';
import t from 'coral-framework/services/i18n';
import ConfigurePage from './ConfigurePage';
import ConfigureCard from 'coral-framework/components/ConfigureCard';
const updateCustomCssUrl = (updateSettings) => (event) => {
const customCssUrl = event.target.value;
updateSettings({customCssUrl});
};
class TechSettings extends React.Component {
const TechSettings = ({settings, onChangeDomainlist, updateSettings}) => {
return (
<div className={styles.Configure}>
<Domainlist
domains={settings.domains.whitelist}
onChangeDomainlist={onChangeDomainlist} />
<EmbedLink />
<Card className={styles.configSetting}>
<div className={styles.wrapper}>
<div className={styles.settingsHeader}>{t('configure.custom_css_url')}</div>
updateCustomCssUrl = (event) => {
const updater = {customCssUrl: {$set: event.target.value}};
this.props.updatePending({updater});
};
updateDomainlist = (listName, list) => {
this.props.updatePending({updater: {
domains: {$apply: (domains) => {
const changeSet = {[listName]: list};
if (!domains) {
return changeSet;
}
return {
...domains,
...changeSet,
};
}},
}});
};
render() {
const {settings, data, root} = this.props;
return (
<ConfigurePage
title={t('configure.tech_settings')}
>
<Domainlist
domains={settings.domains.whitelist}
onChangeDomainlist={this.updateDomainlist} />
<EmbedLink />
<ConfigureCard title={t('configure.custom_css_url')}>
<p>{t('configure.custom_css_url_desc')}</p>
<input
className={styles.customCSSInput}
value={settings.customCssUrl}
onChange={updateCustomCssUrl(updateSettings)} />
</div>
</Card>
</div>
);
};
onChange={this.updateCustomCssUrl} />
</ConfigureCard>
<Slot
fill="adminTechSettings"
data={data}
queryData={{root, settings}}
/>
</ConfigurePage>
);
}
}
TechSettings.propTypes = {
settings: PropTypes.shape({
domains: PropTypes.shape({
whitelist: PropTypes.array.isRequired
})
}).isRequired,
updateSettings: PropTypes.func.isRequired
updatePending: PropTypes.func.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
};
export default TechSettings;
@@ -1,33 +1,33 @@
import React from 'react';
import t from 'coral-framework/services/i18n';
import TagsInput from 'coral-admin/src/components/TagsInput';
import styles from './Configure.css';
import {Card} from 'coral-ui';
import PropTypes from 'prop-types';
import ConfigureCard from 'coral-framework/components/ConfigureCard';
const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
<div>
<Card id={styles.bannedWordlist} className={styles.configSetting}>
<div className={styles.settingsHeader}>{t('configure.banned_words_title')}</div>
<p className={styles.wordlistDesc}>{t('configure.banned_word_text')}</p>
<div className={styles.wrapper}>
<TagsInput
value={bannedWords}
inputProps={{placeholder: 'word or phrase'}}
onChange={(tags) => onChangeWordlist('banned', tags)}
/>
</div>
</Card>
<Card id={styles.suspectWordlist} className={styles.configSetting}>
<div className={styles.settingsHeader}>{t('configure.suspect_word_title')}</div>
<p className={styles.wordlistDesc}>{t('configure.suspect_word_text')}</p>
<div className={styles.wrapper}>
<TagsInput
value={suspectWords}
inputProps={{placeholder: 'word or phrase'}}
onChange={(tags) => onChangeWordlist('suspect', tags)} />
</div>
</Card>
<ConfigureCard title={t('configure.banned_words_title')}>
<p>{t('configure.banned_word_text')}</p>
<TagsInput
value={bannedWords}
inputProps={{placeholder: 'word or phrase'}}
onChange={(tags) => onChangeWordlist('banned', tags)}
/>
</ConfigureCard>
<ConfigureCard title={t('configure.suspect_word_title')}>
<p>{t('configure.suspect_word_text')}</p>
<TagsInput
value={suspectWords}
inputProps={{placeholder: 'word or phrase'}}
onChange={(tags) => onChangeWordlist('suspect', tags)} />
</ConfigureCard>
</div>
);
Wordlist.propTypes = {
suspectWords: PropTypes.array.isRequired,
bannedWords: PropTypes.array.isRequired,
onChangeWordlist: PropTypes.func.isRequired,
};
export default Wordlist;
@@ -1,42 +1,128 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose} from 'react-apollo';
import {
fetchSettings,
updateSettings,
saveSettingsToServer,
updateWordlist,
updateDomainlist
} from '../../../actions/settings';
import {compose, gql} from 'react-apollo';
import withQuery from 'coral-framework/hocs/withQuery';
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 {
componentWillMount = () => {
this.props.fetchSettings();
// 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);
this.props.clearPending();
}
catch(err) {
this.props.notify('error', getErrorMessages(err));
}
};
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 () {
return <Configure {...this.props} />;
if(this.props.data.loading) {
return <Spinner/>;
}
return <Configure
notify={this.props.notify}
auth={this.props.auth}
data={this.props.data}
root={this.props.root}
settings={this.mergedSettings}
canSave={this.props.canSave}
savePending={this.savePending}
setActiveSection={this.props.setActiveSection}
activeSection={this.props.activeSection}
/>;
}
}
const withConfigureQuery = withQuery(gql`
query TalkAdmin_Configure {
settings {
...${getDefinitionName(StreamSettings.fragments.settings)}
...${getDefinitionName(TechSettings.fragments.settings)}
...${getDefinitionName(ModerationSettings.fragments.settings)}
}
...${getDefinitionName(StreamSettings.fragments.root)}
...${getDefinitionName(TechSettings.fragments.root)}
...${getDefinitionName(ModerationSettings.fragments.root)}
}
${StreamSettings.fragments.root}
${StreamSettings.fragments.settings}
${TechSettings.fragments.root}
${TechSettings.fragments.settings}
${ModerationSettings.fragments.root}
${ModerationSettings.fragments.settings}
`, {
options: () => ({
variables: {},
}),
});
const mapStateToProps = (state) => ({
auth: state.auth,
settings: state.settings
pending: state.configure.pending,
canSave: state.configure.canSave,
activeSection: state.configure.activeSection,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
fetchSettings,
updateSettings,
saveSettingsToServer,
updateWordlist,
updateDomainlist
notify,
clearPending,
setActiveSection,
}, dispatch);
export default compose(
withUpdateSettings,
withConfigureQuery,
connect(mapStateToProps, mapDispatchToProps),
)(ConfigureContainer);
ConfigureContainer.propTypes = {
updateSettings: PropTypes.func.isRequired,
clearPending: PropTypes.func.isRequired,
setActiveSection: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
auth: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
pending: PropTypes.object.isRequired,
activeSection: PropTypes.string.isRequired,
};
@@ -0,0 +1,40 @@
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose, gql} from 'react-apollo';
import ModerationSettings from '../components/ModerationSettings';
import withFragments from 'coral-framework/hocs/withFragments';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
import {updatePending} from '../../../actions/configure';
const slots = [
'adminModerationSettings',
];
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
updatePending,
}, dispatch);
export default compose(
withFragments({
root: gql`
fragment TalkAdmin_ModerationSettings_root on RootQuery {
__typename
${getSlotFragmentSpreads(slots, 'root')}
}
`,
settings: gql`
fragment TalkAdmin_ModerationSettings_settings on Settings {
requireEmailConfirmation
moderation
premodLinksEnable
wordlist {
suspect
banned
}
${getSlotFragmentSpreads(slots, 'settings')}
}
`
}),
connect(null, mapDispatchToProps),
)(ModerationSettings);
@@ -0,0 +1,45 @@
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose, gql} from 'react-apollo';
import StreamSettings from '../components/StreamSettings';
import withFragments from 'coral-framework/hocs/withFragments';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
import {updatePending} from '../../../actions/configure';
const slots = [
'adminStreamSettings',
];
const mapStateToProps = (state) => ({
errors: state.configure.errors,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
updatePending,
}, dispatch);
export default compose(
withFragments({
root: gql`
fragment TalkAdmin_StreamSettings_root on RootQuery {
__typename
${getSlotFragmentSpreads(slots, 'root')}
}
`,
settings: gql`
fragment TalkAdmin_StreamSettings_settings on Settings {
infoBoxEnable
charCount
charCountEnable
infoBoxContent
editCommentWindowLength
autoCloseStream
closedTimeout
closedMessage
${getSlotFragmentSpreads(slots, 'settings')}
}
`
}),
connect(mapStateToProps, mapDispatchToProps),
)(StreamSettings);
@@ -0,0 +1,37 @@
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose, gql} from 'react-apollo';
import TechSettings from '../components/TechSettings';
import withFragments from 'coral-framework/hocs/withFragments';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
import {updatePending} from '../../../actions/configure';
const slots = [
'adminTechSettings',
];
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
updatePending,
}, dispatch);
export default compose(
withFragments({
root: gql`
fragment TalkAdmin_TechSettings_root on RootQuery {
__typename
${getSlotFragmentSpreads(slots, 'root')}
}
`,
settings: gql`
fragment TalkAdmin_TechSettings_settings on Settings {
customCssUrl
domains {
whitelist
}
${getSlotFragmentSpreads(slots, 'settings')}
}
`
}),
connect(null, mapDispatchToProps),
)(TechSettings);
@@ -42,11 +42,11 @@ export const witDashboardQuery = withQuery(gql`
}
}
`, {
options: ({settings: {dashboardWindowStart, dashboardWindowEnd}}) => {
options: ({windowStart, windowEnd}) => {
return {
variables: {
from: dashboardWindowStart,
to: dashboardWindowEnd
from: windowStart,
to: windowEnd,
}
};
}
@@ -54,8 +54,8 @@ export const witDashboardQuery = withQuery(gql`
const mapStateToProps = (state) => {
return {
settings: state.settings,
moderation: state.moderation
windowStart: state.dashboard.windowStart,
windowEnd: state.dashboard.windowEnd,
};
};
@@ -1,13 +1,14 @@
import React from 'react';
import styles from './style.css';
import {TextField, Button} from 'coral-ui';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
import cn from 'classnames';
const AddOrganizationName = (props) => {
const {handleSettingsChange, handleSettingsSubmit, install} = props;
return (
<div className={styles.step}>
<div className={cn(styles.step, 'talk-install-step-2')}>
<p>{t('install.add_organization.description')}</p>
<div className={styles.form}>
<form onSubmit={handleSettingsSubmit}>
@@ -19,11 +20,17 @@ const AddOrganizationName = (props) => {
onChange={handleSettingsChange}
showErrors={install.showErrors}
errorMsg={install.errors.organizationName} />
<Button type="submit" cStyle='black' full>{t('install.add_organization.save')}</Button>
<Button className="talk-install-step-2-save-button" type="submit" cStyle='black' full>{t('install.add_organization.save')}</Button>
</form>
</div>
</div>
);
};
AddOrganizationName.propTypes = {
handleSettingsChange: PropTypes.func,
handleSettingsSubmit: PropTypes.func,
install: PropTypes.object,
};
export default AddOrganizationName;
@@ -1,13 +1,14 @@
import React from 'react';
import styles from './style.css';
import {TextField, Button, Spinner} from 'coral-ui';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
import cn from 'classnames';
const InitialStep = (props) => {
const {handleUserChange, handleUserSubmit, install} = props;
return (
<div className={styles.step}>
<div className={cn(styles.step, 'talk-install-step-3')}>
<div className={styles.form}>
<form onSubmit={handleUserSubmit}>
<TextField
@@ -53,7 +54,7 @@ const InitialStep = (props) => {
{
!props.install.isLoading ?
<Button cStyle='black' type="submit" full>{t('install.create.save')}</Button>
<Button className="talk-install-step-3-save-button" cStyle='black' type="submit" full>{t('install.create.save')}</Button>
:
<Spinner />
}
@@ -64,4 +65,10 @@ const InitialStep = (props) => {
);
};
InitialStep.propTypes = {
handleUserChange: PropTypes.func,
handleUserSubmit: PropTypes.func,
install: PropTypes.object,
};
export default InitialStep;
@@ -2,12 +2,13 @@ import React from 'react';
import styles from './style.css';
import {Button} from 'coral-ui';
import {Link} from 'react-router';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
const InitialStep = () => {
return (
<div className={`${styles.step} ${styles.finalStep}`}>
<div className={cn(styles.step, styles.finalStep, 'talk-install-step-5')}>
<p>{t('install.final.description')}</p>
<Button raised><Link to='/admin'>{t('install.final.launch')}</Link></Button>
<Button cStyle='black' raised><a href="http://coralproject.net">{t('install.final.close')}</a></Button>
@@ -1,17 +1,22 @@
import React from 'react';
import styles from './style.css';
import {Button} from 'coral-ui';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
import cn from 'classnames';
const InitialStep = (props) => {
const {nextStep} = props;
return (
<div className={styles.step}>
<div className={cn(styles.step, 'talk-install-step-1')}>
<p>{t('install.initial.description')}</p>
<Button cStyle='green' onClick={nextStep} raised>{t('install.initial.submit')}</Button>
<Button className={'talk-install-get-started-button'} cStyle='green' onClick={nextStep} raised>{t('install.initial.submit')}</Button>
</div>
);
};
InitialStep.propTypes = {
nextStep: PropTypes.func,
};
export default InitialStep;
@@ -2,26 +2,34 @@ import React from 'react';
import styles from './style.css';
import {Button, Card} from 'coral-ui';
import TagsInput from 'coral-admin/src/components/TagsInput';
import PropTypes from 'prop-types';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
const PermittedDomainsStep = (props) => {
const {finishInstall, install, handleDomainsChange} = props;
const domains = install.data.settings.domains.whitelist;
return (
<div className={styles.step}>
<div className={cn(styles.step, 'talk-install-step-4')}>
<h3>{t('install.permitted_domains.title')}</h3>
<Card className={styles.card}>
<p>{t('install.permitted_domains.description')}</p>
<TagsInput
className="talk-install-step-4-permited-domains-input"
value={domains}
inputProps={{placeholder: 'URL'}}
onChange={(tags) => handleDomainsChange(tags)}
/>
</Card>
<Button cStyle='green' onClick={finishInstall} raised>{t('install.permitted_domains.submit')}</Button>
<Button className="talk-install-step-4-save-button" cStyle='green' onClick={finishInstall} raised>{t('install.permitted_domains.submit')}</Button>
</div>
);
};
PermittedDomainsStep.propTypes = {
finishInstall: PropTypes.func,
handleDomainsChange: PropTypes.func,
install: PropTypes.object,
};
export default PermittedDomainsStep;
@@ -58,12 +58,11 @@ class Comment extends React.Component {
render() {
const {
comment,
suspectWords,
bannedWords,
selected,
className,
data,
root,
root: {settings},
currentUserId,
currentAsset,
} = this.props;
@@ -130,8 +129,8 @@ class Comment extends React.Component {
<div className={styles.itemBody}>
<div className={styles.body}>
<CommentBodyHighlighter
suspectWords={suspectWords}
bannedWords={bannedWords}
suspectWords={settings.wordlist.suspect}
bannedWords={settings.wordlist.banned}
body={comment.body}
/>
{' '}
@@ -188,8 +187,6 @@ Comment.propTypes = {
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
className: PropTypes.string,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
currentAsset: PropTypes.object,
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
@@ -34,6 +34,8 @@ class Moderation extends Component {
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 +43,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);
@@ -77,7 +82,7 @@ class Moderation extends Component {
const comments = this.getComments();
const commentIdx = comments.findIndex((comment) => comment.id === selectedCommentId);
const comment = comments[commentIdx];
if (accept) {
comment.status !== 'ACCEPTED' && acceptComment({commentId: comment.id});
} else {
@@ -175,6 +180,8 @@ class Moderation extends Component {
key.unbind('k');
key.unbind('f');
key.unbind('d');
this.getMenuItems()
.forEach((menuItem, idx) => key.unbind(`${idx + 1}`));
}
componentWillReceiveProps(nextProps) {
@@ -220,7 +227,7 @@ class Moderation extends Component {
}
render () {
const {root, data, moderation, settings, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props;
const {root, data, moderation, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props;
const {asset} = root;
const assetId = asset && asset.id;
@@ -262,15 +269,11 @@ class Moderation extends Component {
activeTab={activeTab}
singleView={moderation.singleView}
selectedCommentId={this.state.selectedCommentId}
bannedWords={settings.wordlist.banned}
suspectWords={settings.wordlist.suspect}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
loadMore={this.loadMore}
assetId={assetId}
sort={this.props.moderation.sortOrder}
commentCount={activeTabCount}
currentUserId={this.props.auth.user.id}
viewUserDetail={viewUserDetail}
@@ -280,6 +283,7 @@ class Moderation extends Component {
shortcutsNoteVisible={moderation.shortcutsNoteVisible}
open={moderation.modalOpen}
onClose={this.onClose}
queueCount={this.getMenuItems().length}
/>
</div>
<StorySearch
@@ -308,7 +312,6 @@ Moderation.propTypes = {
storySearchChange: PropTypes.func.isRequired,
moderation: PropTypes.object.isRequired,
auth: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
queueConfig: PropTypes.object.isRequired,
handleCommentChange: PropTypes.func.isRequired,
setSortOrder: PropTypes.func.isRequired,
@@ -321,6 +324,7 @@ Moderation.propTypes = {
activeTab: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
router: PropTypes.object.isRequired,
};
export default Moderation;
@@ -147,8 +147,6 @@ class ModerationQueue extends React.Component {
key={comment.id}
comment={comment}
selected={true}
suspectWords={props.suspectWords}
bannedWords={props.bannedWords}
viewUserDetail={viewUserDetail}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
@@ -193,8 +191,6 @@ class ModerationQueue extends React.Component {
key={comment.id}
comment={comment}
selected={comment.id === selectedCommentId}
suspectWords={props.suspectWords}
bannedWords={props.bannedWords}
viewUserDetail={viewUserDetail}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
@@ -218,8 +214,6 @@ class ModerationQueue extends React.Component {
ModerationQueue.propTypes = {
viewUserDetail: PropTypes.func.isRequired,
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
currentAsset: PropTypes.object,
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
@@ -53,45 +53,26 @@
list-style: none;
}
.selectField {
.dropdownContainer {
position: relative;
width: 140px;
height: 36px;
top: 5px;
margin-right: 10px;
background: #7B7B7B;
color: white;
padding: 6px 15px;
box-sizing: border-box;
border-radius: 2px;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
margin-top: 5px;
@media (--tablet) {
display: inline-block;
margin-top: 0px;
margin-left: 10px;
}
}
> div {
padding: 0;
}
i {
position: absolute;
top: 7px;
right: 7px;
}
input {
font-size: 1rem;
border-bottom: 0px;
font-family: inherit;
}
.dropdownToggle {
background: #7B7B7B;
color: white;
label {
top: -4px;
&:focus {
background: #aaa;
}
}
&:hover {
cursor: pointer;
}
.dropdownToggleOpen {
background: #aaa;
}
@@ -1,10 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './ViewOptions.css';
import {Card} from 'coral-ui';
import cn from 'classnames';
import {SelectField, Option} from 'react-mdl-selectfield';
import t from 'coral-framework/services/i18n';
import {Card, Dropdown, Option} from 'coral-ui';
class ViewOptions extends React.Component {
render() {
@@ -19,18 +18,20 @@ class ViewOptions extends React.Component {
<h2 className={cn(styles.headline, 'talk-admin-moderation-view-options-headline')}>
View Options
</h2>
<div className={styles.viewOptionsContent}>
<div className={styles.viewOptionsContent}>
<ul className={styles.viewOptionsList}>
<li className={styles.viewOptionsItem}>
Sort Comments
<SelectField
className={styles.selectField}
label="Sort"
<Dropdown
containerClassName={styles.dropdownContainer}
toggleClassName={styles.dropdownToggle}
toggleOpenClassName={styles.dropdownToggleOpen}
placeholder={t('modqueue.sort')}
value={sort}
onChange={(sort) => selectSort(sort)}>
<Option value={'DESC'}>{t('modqueue.newest_first')}</Option>
<Option value={'ASC'}>{t('modqueue.oldest_first')}</Option>
</SelectField>
<Option value={'DESC'} label={t('modqueue.newest_first')} />
<Option value={'ASC'} label={t('modqueue.oldest_first')} />
</Dropdown>
</li>
</ul>
</div>
@@ -15,7 +15,12 @@ const slots = [
export default withFragments({
root: gql`
fragment CoralAdmin_ModerationComment_root on RootQuery {
__typename
settings {
wordlist {
banned
suspect
}
}
${getSlotFragmentSpreads(slots, 'root')}
...${getDefinitionName(CommentLabels.fragments.root)}
...${getDefinitionName(CommentDetails.fragments.root)}
@@ -13,7 +13,6 @@ import {isPremod, getModPath} from '../../../utils';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {handleCommentChange} from '../graphql';
import {fetchSettings} from 'actions/settings';
import {showBanUserDialog} from 'actions/banUserDialog';
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
import {viewUserDetail} from '../../../actions/userDetail';
@@ -116,17 +115,14 @@ class ModerationContainer extends Component {
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);
},
},
];
@@ -146,7 +142,6 @@ class ModerationContainer extends Component {
componentWillMount() {
this.props.clearState();
this.props.fetchSettings();
this.subscribeToUpdates();
}
@@ -384,7 +379,6 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
const mapStateToProps = (state) => ({
moderation: state.moderation,
settings: state.settings,
auth: state.auth,
});
@@ -392,7 +386,6 @@ const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
toggleModal,
singleView,
fetchSettings,
showBanUserDialog,
hideShortcutsNote,
toggleStorySearch,
@@ -76,31 +76,6 @@
display: block;
}
.statusMenu {
border-radius: 2px;
width: 10em;
text-align: center;
float: right;
color: #fff;
cursor: pointer;
letter-spacing: 0.7px;
font-weight: 400;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
}
.statusMenuOpen {
padding: 10px;
background-color: #268D81;
}
.statusMenuIcon {
float: right;
}
.statusMenuClosed {
padding: 10px;
background-color: #262626;
}
.hidden {
display: none;
@@ -1,128 +1,48 @@
import React, {Component} from 'react';
import styles from './Stories.css';
import t from 'coral-framework/services/i18n';
import cn from 'classnames';
import {Link} from 'react-router';
import {Pager, Icon} from 'coral-ui';
import PropTypes from 'prop-types';
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';
import sortBy from 'lodash/sortBy';
const limit = 25;
export default 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;
});
}
class Stories extends Component {
renderDate = (date) => {
const d = new Date(date);
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
}
onStatusClick = (closeStream, id, statusMenuOpen) => async () => {
if (statusMenuOpen) {
this.setState((prev) => {
prev.statusMenus[id] = false;
return prev;
});
try {
await 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) {
// TODO: handle error.
console.error(err);
}
} else {
this.setState((prev) => {
prev.statusMenus[id] = true;
return prev;
});
}
}
renderTitle = (title, {id}) => <Link to={`/admin/moderate/${id}`}>{title}</Link>
renderStatus = (closedAt, {id}) => {
const closed = closedAt && new Date(closedAt).getTime() < Date.now();
const statusMenuOpen = this.state.statusMenus[id];
return <div className={styles.statusMenu}>
<div
className={closed ? styles.statusMenuClosed : styles.statusMenuOpen}
onClick={this.onStatusClick(closed, id, statusMenuOpen)}>
{!statusMenuOpen && <Icon className={styles.statusMenuIcon} name='keyboard_arrow_down'/>}
{closed ? t('streams.closed') : t('streams.open')}
</div>
{
statusMenuOpen &&
<div
className={!closed ? styles.statusMenuClosed : styles.statusMenuOpen}
onClick={this.onStatusClick(!closed, id, statusMenuOpen)}>
{!closed ? t('streams.closed') : t('streams.open')}
</div>
}
</div>;
}
onPageClick = (page) => {
this.setState({page});
const {search, sort, filter} = this.state;
this.props.fetchAssets((page - 1) * limit, limit, search, sort, filter);
const closed = !!(closedAt && new Date(closedAt).getTime() < Date.now());
return (
<Dropdown
value={closed}
onChange={(value) => this.props.onStatusChange(value, id)}>
<Option value={false} label={t('streams.open')} />
<Option value={true} label={t('streams.closed')} />
</Dropdown>
);
}
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>
@@ -131,7 +51,7 @@ export default class Stories extends Component {
name='status filter'
value={filter}
childContainer='div'
onChange={this.onSettingChange('filter')}
onChange={onSettingChange('filter')}
className={styles.radioGroup}
>
<Radio value='all'>{t('streams.all')}</Radio>
@@ -141,19 +61,19 @@ export default class Stories extends Component {
<div className={styles.optionHeader}>{t('streams.sort_by')}</div>
<RadioGroup
name='sort by'
value={sort}
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')}
@@ -162,10 +82,10 @@ export default 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>
}
@@ -174,3 +94,16 @@ export default class Stories extends Component {
}
}
Stories.propTypes = {
assets: PropTypes.object,
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,9 +1,9 @@
import React from 'react';
import {Button} from 'coral-ui';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
export default ({status, onClick}) => (
const CloseCommentsInfo = ({status, onClick}) => (
status === 'open' ? (
<div className="close-comments-intro-wrapper">
<p>
@@ -20,3 +20,10 @@ export default ({status, onClick}) => (
</div>
)
);
CloseCommentsInfo.propTypes = {
status: PropTypes.string,
onClick: PropTypes.func,
};
export default CloseCommentsInfo;
@@ -1,12 +1,10 @@
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 {
@@ -134,6 +132,14 @@ const mapDispatchToProps = (dispatch) => ({
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;
});
};
@@ -1,10 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import LoadMore from './LoadMore';
import NewCount from './NewCount';
import {TransitionGroup} from 'react-transition-group';
import {forEachError} from 'coral-framework/utils';
import Comment from '../containers/Comment';
import NoComments from './NoComments';
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
@@ -144,53 +145,82 @@ class AllCommentsPane extends React.Component {
} = this.props;
const {loadingState} = this.state;
const view = this.getVisibleComments();
const visibleComments = this.getVisibleComments();
return (
<div className="talk-stream-comments-container">
<NewCount
count={comments.nodes.length - view.length}
loadMore={this.viewNewComments}
/>
<TransitionGroup component='div' className="embed__stream">
{view.map((comment) => {
return (
<Comment
commentClassNames={commentClassNames}
data={data}
root={root}
disableReply={disableReply}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
notify={notify}
depth={0}
postComment={postComment}
asset={asset}
currentUser={currentUser}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
comment={comment}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
editComment={editComment}
emit={emit}
/>
);
})}
</TransitionGroup>
<LoadMore
topLevel={true}
moreComments={asset.comments.hasNextPage}
loadMore={this.loadMore}
loadingState={loadingState}
/>
<div>
{visibleComments.length ? (
<div className="talk-stream-comments-container">
<NewCount
count={comments.nodes.length - visibleComments.length}
loadMore={this.viewNewComments}
/>
<TransitionGroup component='div' className="embed__stream">
{visibleComments.map((comment) => {
return (
<Comment
commentClassNames={commentClassNames}
data={data}
root={root}
disableReply={disableReply}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
notify={notify}
depth={0}
postComment={postComment}
asset={asset}
currentUser={currentUser}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
comment={comment}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
editComment={editComment}
emit={emit}
/>
);
})}
</TransitionGroup>
<LoadMore
topLevel={true}
moreComments={asset.comments.hasNextPage}
loadMore={this.loadMore}
loadingState={loadingState}
/>
</div>
) : (
<NoComments assetClosed={asset.isClosed} />
)}
</div>
);
}
}
AllCommentsPane.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
comments: PropTypes.object,
commentClassNames: PropTypes.array,
setActiveReplyBox: PropTypes.func,
activeReplyBox: PropTypes.string,
notify: PropTypes.func,
disableReply: PropTypes.bool,
postComment: PropTypes.func,
asset: PropTypes.object,
currentUser: PropTypes.object,
postFlag: PropTypes.func,
postDontAgree: PropTypes.func,
loadNewReplies: PropTypes.func,
deleteAction: PropTypes.func,
showSignInDialog: PropTypes.func,
charCountEnable: PropTypes.bool,
maxCharCount: PropTypes.number,
editComment: PropTypes.func,
emit: PropTypes.func,
};
export default AllCommentsPane;
@@ -519,6 +519,7 @@ export default class Comment extends React.Component {
: <div>
<Slot
fill="commentContent"
className='talk-stream-comment-content'
defaultComponent={CommentContent}
{...slotProps}
queryData={queryData}
@@ -0,0 +1,6 @@
.commentTombstone {
background-color: #F0F0F0;
text-align: center;
padding: 1em;
color: #3E4F71;
}
@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
import styles from './CommentTombstone.css';
// Render in place of a Comment when the author of the comment is <action>
class CommentTombstone extends React.Component {
@@ -19,14 +20,9 @@ class CommentTombstone extends React.Component {
render() {
return (
<div>
<div className="talk-comment-tombstone">
<hr aria-hidden={true} />
<p style={{
backgroundColor: '#F0F0F0',
textAlign: 'center',
padding: '1em',
color: '#3E4F71',
}}>
<p className={styles.commentTombstone}>
{this.getCopy()}
</p>
</div>
@@ -34,4 +30,8 @@ class CommentTombstone extends React.Component {
}
}
CommentTombstone.propTypes = {
action: PropTypes.string,
};
export default CommentTombstone;
@@ -75,13 +75,13 @@ export default class Embed extends React.Component {
activeTab={activeTab}
id='talk-embed-stream-tab-content'
>
<TabPane tabId={'stream'}>
<TabPane tabId={'stream'} className={'talk-embed-stream-comments-tab-pane'}>
<Stream data={data} root={root} />
</TabPane>
<TabPane tabId={'profile'}>
<TabPane tabId={'profile'} className={'talk-embed-stream-profile-tab-pane'}>
<ProfileContainer />
</TabPane>
<TabPane tabId={'config'}>
<TabPane tabId={'config'} className={'talk-embed-stream-configuration-tab-pane'}>
<ConfigureStreamContainer />
</TabPane>
</TabContent>
@@ -0,0 +1,6 @@
.message {
padding: 20px 10px;
background-color: #f7f7f7;
margin: 20px 0;
text-align: center;
}
@@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
import styles from './NoComments.css';
const NoComments = ({assetClosed}) => (
<div className="talk-stream-no-comments">
{assetClosed ? (
<div className={cn(styles.message, 'talk-stream-no-comments-closed-message')}>
{t('stream.no_comments_and_closed')}
</div>
) : (
<div className={cn(styles.message, 'talk-stream-no-comments-message')}>
{t('stream.no_comments')}
</div>
)}
</div>
);
NoComments.propTypes = {
assetClosed: PropTypes.bool,
};
export default NoComments;
@@ -213,12 +213,14 @@ class Stream extends React.Component {
const open = !asset.isClosed;
const banned = user && user.status === 'BANNED';
const pending = user && user.status === 'PENDING';
const temporarilySuspended =
user &&
user.suspension.until &&
new Date(user.suspension.until) > new Date();
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox);
const showCommentBox = loggedIn && ((!banned && !pending & !temporarilySuspended && !highlightedComment) || keepCommentBox);
const slotProps = {data};
const slotQueryData = {root, asset};
@@ -303,11 +305,32 @@ class Stream extends React.Component {
}
Stream.propTypes = {
activeStreamTab: PropTypes.string,
data: PropTypes.object,
root: PropTypes.object,
activeReplyBox: PropTypes.string,
setActiveReplyBox: PropTypes.func,
commentClassNames: PropTypes.array,
setActiveStreamTab: PropTypes.func,
loadMoreComments: PropTypes.func,
postFlag: PropTypes.func,
postDontAgree: PropTypes.func,
deleteAction: PropTypes.func,
showSignInDialog: PropTypes.func,
loadNewReplies: PropTypes.func,
auth: PropTypes.object,
emit: PropTypes.func,
sortOrder: PropTypes.string,
sortBy: PropTypes.string,
loading: PropTypes.bool,
editName: PropTypes.func,
appendItemArray: PropTypes.func,
updateItem: PropTypes.func,
viewAllComments: PropTypes.func,
notify: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
// edit a comment, passed (id, asset_id, { body })
editComment: PropTypes.func
editComment: PropTypes.func,
userIsDegraged: PropTypes.bool,
};
export default Stream;
@@ -6,9 +6,9 @@ import styles from './StreamTabPanel.css';
class StreamTabPanel extends React.Component {
render() {
const {activeTab, setActiveTab, tabs, tabPanes, sub, loading} = this.props;
const {activeTab, setActiveTab, tabs, tabPanes, sub, loading, ...rest} = this.props;
return (
<div>
<div {...rest}>
<TabBar activeTab={activeTab} onTabClick={setActiveTab} sub={sub}>
{tabs}
</TabBar>
@@ -26,6 +26,7 @@ class StreamTabPanel extends React.Component {
StreamTabPanel.propTypes = {
activeTab: PropTypes.string.isRequired,
setActiveTab: PropTypes.func.isRequired,
loading: PropTypes.bool,
tabs: PropTypes.oneOfType([
PropTypes.element,
PropTypes.arrayOf(PropTypes.element)

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