Merge pull request #1083 from coralproject/refactor-configure

Refactor Configure, Add Slots
This commit is contained in:
Kim Gardner
2017-10-11 09:26:37 +01:00
committed by GitHub
48 changed files with 1136 additions and 969 deletions
@@ -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});
});
};
@@ -17,8 +17,6 @@ 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,
@@ -79,8 +77,6 @@ export default class UserDetail extends React.Component {
},
activeTab,
selectedCommentIds,
bannedWords,
suspectWords,
toggleSelect,
bulkAccept,
bulkReject,
@@ -184,8 +180,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}
@@ -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;
@@ -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`;
@@ -179,8 +179,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) => ({
@@ -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;
}
}
}),
},
};
@@ -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;
}
+4 -2
View File
@@ -1,6 +1,7 @@
import auth from './auth';
import assets from './assets';
import settings from './settings';
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,
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;
}
}
@@ -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,6 +9,15 @@
width: 234px;
}
.saveBox {
margin-top: 38px;
}
.changedSave {
background-color: #00796B;
color: white;
}
.mainContent {
width: calc(100% - 300px);
padding: 10px 14px;
@@ -28,183 +25,3 @@
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;
}
.changedSave {
background-color: #00796B;
color: white;
}
.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;
}
.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,
};
};
@@ -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,
@@ -77,7 +77,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 {
@@ -220,7 +220,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 +262,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}
@@ -308,7 +304,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 +316,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,
@@ -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';
@@ -146,7 +145,6 @@ class ModerationContainer extends Component {
componentWillMount() {
this.props.clearState();
this.props.fetchSettings();
this.subscribeToUpdates();
}
@@ -384,7 +382,6 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
const mapStateToProps = (state) => ({
moderation: state.moderation,
settings: state.settings,
auth: state.auth,
});
@@ -392,7 +389,6 @@ const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
toggleModal,
singleView,
fetchSettings,
showBanUserDialog,
hideShortcutsNote,
toggleStorySearch,
@@ -0,0 +1,49 @@
.card {
margin-bottom: 20px;
align-items: flex-start;
min-height: 100px;
max-width: 600px;
}
.header {
margin-top: 3px;
margin-bottom: 7px;
font-size: 18px;
font-weight: 500;
}
.wrapper {
width: 100%;
font-size: 14px;
letter-spacing: 0;
}
.action {
display: inline-block;
position: absolute;
top: 0;
left: 0;
padding: 20px;
}
.content {
display: inline-block;
padding: 0px 30px;
box-sizing: border-box;
}
.enabledSetting {
border-left-color: #00796b;
border-left-style: solid;
border-left-width: 7px;
}
.disabledSetting {
padding-left: 22px;
}
.disabledSettingText {
color: #ccc;
pointer-events: none;
}
@@ -0,0 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './ConfigureCard.css';
import {Card} from 'coral-ui';
import {Checkbox} from 'react-mdl';
import cn from 'classnames';
const ConfigureCard = ({title, children, className, onCheckbox, checked, ...rest}) => (
<Card {...rest} className={cn(
styles.card,
className,
{
[styles.enabledSetting]: checked === true,
[styles.disabledSetting]: checked === false,
},
)}>
{checked !== undefined &&
<div className={styles.action}>
<Checkbox
onChange={onCheckbox}
checked={checked} />
</div>
}
<div className={cn(styles.wrapper, {
[styles.content]: checked !== undefined,
})}>
<div className={styles.header}>{title}</div>
<div className={cn({
[styles.disabledSettingText]: checked === false,
})}>
{children}
</div>
</div>
</Card>
);
ConfigureCard.propTypes = {
title: PropTypes.string.isRequired,
className: PropTypes.string,
onCheckbox: PropTypes.func,
checked: PropTypes.bool,
children: PropTypes.node,
};
export default ConfigureCard;
@@ -16,6 +16,7 @@ export default {
'ModifyTagResponse',
'IgnoreUserResponse',
'StopIgnoringUserResponse',
'UpdateSettingsResponse',
)
};
@@ -340,3 +340,21 @@ export const withStopIgnoringUser = withMutation(
});
}}),
});
export const withUpdateSettings = withMutation(
gql`
mutation UpdateSettings($input: UpdateSettingsInput!) {
updateSettings(input: $input) {
...UpdateSettingsResponse
}
}
`, {
props: ({mutate}) => ({
updateSettings: (input) => {
return mutate({
variables: {
input,
},
});
}}),
});
-8
View File
@@ -2,20 +2,16 @@ const errors = require('../../errors');
const {
UPDATE_SETTINGS,
UPDATE_WORDLIST,
} = require('../../perms/constants');
const SettingsService = require('../../services/settings');
const update = async (ctx, settings) => SettingsService.update(settings);
const updateWordlist = async (ctx, wordlist) => SettingsService.updateWordlist(wordlist);
module.exports = (ctx) => {
let mutators = {
Settings: {
update: () => Promise.reject(errors.ErrNotAuthorized),
updateWordlist: () => Promise.reject(errors.ErrNotAuthorized)
}
};
@@ -23,10 +19,6 @@ module.exports = (ctx) => {
if (ctx.user.can(UPDATE_SETTINGS)) {
mutators.Settings.update = (id, settings) => update(ctx, id, settings);
}
if (ctx.user.can(UPDATE_WORDLIST)) {
mutators.Settings.updateWordlist = (id, status) => updateWordlist(ctx, id, status);
}
}
return mutators;
-3
View File
@@ -61,9 +61,6 @@ const RootMutation = {
updateSettings: async (_, {input: settings}, {mutators: {Settings}}) => {
await Settings.update(settings);
},
updateWordlist: async (_, {input: wordlist}, {mutators: {Settings}}) => {
await Settings.updateWordlist(wordlist);
},
createToken: async (_, {input}, {mutators: {Token}}) => ({
token: await Token.create(input),
}),
+12 -11
View File
@@ -1217,6 +1217,12 @@ input UpdateSettingsInput {
# editCommentWindowLength is the length of time (in milliseconds) after a
# comment is posted that it can still be edited by the author.
editCommentWindowLength: Int
# wordlist allows chaninging the available wordlists.
wordlist: UpdateWordlistInput
# domains allows changing the available lists of domains.
domains: UpdateDomainsInput
}
# UpdateSettingsResponse contains any errors that were rendered as a result
@@ -1231,18 +1237,17 @@ type UpdateSettingsResponse implements Response {
input UpdateWordlistInput {
# banned words will by default reject the comment if it is found.
banned: [String!]!
banned: [String!]
# suspect words will simply flag the comment.
suspect: [String!]!
suspect: [String!]
}
# UpdateWordlistResponse contains any errors that were rendered as a result
# of the mutation.
type UpdateWordlistResponse implements Response {
# UpdateDomainsInput describes all the available lists of domains.
input UpdateDomainsInput {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
# whitelist is the list of domains that the embed is allowed to render on.
whitelist: [String!]
}
# CreateTokenInput contains the input to create the token.
@@ -1329,10 +1334,6 @@ type RootMutation {
# Mutation is restricted.
updateSettings(input: UpdateSettingsInput!): UpdateSettingsResponse
# updateWordlist will update the given Wordlist.
# Mutation is restricted.
updateWordlist(input: UpdateWordlistInput!): UpdateWordlistResponse
# Ignore comments by another user
ignoreUser(id: ID!): IgnoreUserResponse
-1
View File
@@ -19,7 +19,6 @@ module.exports = {
UPDATE_ASSET_SETTINGS: 'UPDATE_ASSET_SETTINGS',
UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS',
UPDATE_SETTINGS: 'UPDATE_SETTINGS',
UPDATE_WORDLIST: 'UPDATE_WORDLIST',
// queries
SEARCH_ASSETS: 'SEARCH_ASSETS',
-1
View File
@@ -19,7 +19,6 @@ module.exports = (user, perm) => {
case types.SET_COMMENT_STATUS:
case types.UPDATE_CONFIG:
case types.UPDATE_SETTINGS:
case types.UPDATE_WORDLIST:
case types.UPDATE_ASSET_SETTINGS:
case types.UPDATE_ASSET_STATUS:
return check(user, ['ADMIN', 'MODERATOR']);
@@ -6,3 +6,4 @@ export {default as CommentAuthorName} from 'coral-framework/components/CommentAu
export {default as CommentTimestamp} from 'coral-framework/components/CommentTimestamp';
export {default as CommentDetail} from 'coral-framework/components/CommentDetail';
export {default as CommentContent} from 'coral-framework/components/CommentContent';
export {default as ConfigureCard} from 'coral-framework/components/ConfigureCard';
+27 -14
View File
@@ -1,6 +1,32 @@
const SettingModel = require('../models/setting');
const errors = require('../errors');
function dotizeRecurse(result, object, path = '') {
for (const key in object) {
const newPath = path ? `${path}.${key}` : key;
if (typeof object[key] === 'object' && !Array.isArray(object[key])) {
dotizeRecurse(result, object[key], newPath);
continue;
}
result[newPath] = object[key];
}
}
/**
* Dotize turns a nested object into flattened object with
* dotized key notation. Arrays do not become dotized.
*
* e.g. {a: {b: 'c'}} becomes {'a.b': 'c}
*
* @param {Object} object
* @return {Object} dotized object
*/
function dotize(object) {
const result = {};
dotizeRecurse(result, object);
return result;
}
/**
* The selector used to uniquely identify the settings document.
*/
@@ -34,7 +60,7 @@ module.exports = class SettingsService {
*/
static update(settings) {
return SettingModel.findOneAndUpdate(selector, {
$set: settings
$set: dotize(settings)
}, {
upsert: true,
new: true,
@@ -42,19 +68,6 @@ module.exports = class SettingsService {
});
}
/**
* updateWordlist will update the wordlists.
*
* @param {Object} wordlist the Wordlist object
*/
static updateWordlist(wordlist) {
return SettingModel.findOneAndUpdate(selector, {
$set: {
wordlist,
},
});
}
/**
* This is run once when the app starts to ensure settings are populated.
*/
@@ -4,10 +4,12 @@ const schema = require('../../../../graph/schema');
const Context = require('../../../../graph/context');
const UserModel = require('../../../../models/user');
const SettingsService = require('../../../../services/settings');
const isEqual = require('lodash/isEqual');
const {expect} = require('chai');
describe('graph.mutations.updateSettings', () => {
beforeEach(async () => {
await SettingsService.init();
});
@@ -71,4 +73,86 @@ describe('graph.mutations.updateSettings', () => {
});
});
});
describe('nested objects', () => {
const user = new UserModel({roles: ['ADMIN']});
const ctx = new Context({user});
it('should handle nested objects', async () => {
const initSettings = {
wordlist: {
banned: ['fuck'],
suspect: ['idiot', 'nazis'],
},
domains: {
whitelist: ['localhost:3000'],
},
};
let res = await graphql(schema, QUERY, {}, ctx, {
settings: initSettings,
});
if (res.errors) {
console.error(res.errors);
}
if (res.data.updateSettings && res.data.updateSettings.errors) {
console.error(res.data.updateSettings.errors);
}
expect(res.errors).to.be.empty;
expect(res.data.updateSettings).to.be.null;
let retrievedSettings = await SettingsService.retrieve();
Object.keys(initSettings).forEach((key) => {
Object.keys(initSettings[key]).forEach((nestedKey) => {
expect(retrievedSettings).to.have.property(key);
expect(retrievedSettings[key]).to.have.property(nestedKey);
expect(isEqual(retrievedSettings[key][nestedKey], initSettings[key][nestedKey])).to.be.true;
});
});
const change = {
wordlist: {
suspect: ['idiot'],
},
domains: {
whitelist: ['coralproject.org'],
},
};
const changedSettings = Object.assign({}, initSettings, {
wordlist: {
suspect: change.wordlist.suspect,
},
domains: {
whitelist: change.domains.whitelist,
}
});
res = await graphql(schema, QUERY, {}, ctx, {
settings: change,
});
if (res.errors) {
console.error(res.errors);
}
if (res.data.updateSettings && res.data.updateSettings.errors) {
console.error(res.data.updateSettings.errors);
}
expect(res.errors).to.be.empty;
expect(res.data.updateSettings).to.be.null;
retrievedSettings = await SettingsService.retrieve();
Object.keys(changedSettings).forEach((key) => {
Object.keys(changedSettings[key]).forEach((nestedKey) => {
expect(retrievedSettings).to.have.property(key);
expect(retrievedSettings[key]).to.have.property(nestedKey);
expect(isEqual(retrievedSettings[key][nestedKey], changedSettings[key][nestedKey])).to.be.true;
});
});
});
});
});
@@ -1,82 +0,0 @@
const {graphql} = require('graphql');
const schema = require('../../../../graph/schema');
const Context = require('../../../../graph/context');
const UserModel = require('../../../../models/user');
const SettingsService = require('../../../../services/settings');
const {expect} = require('chai');
describe('graph.mutations.updateWordlist', () => {
beforeEach(async () => {
await SettingsService.init();
});
const QUERY = `
mutation UpdateWordlist($wordlist: UpdateWordlistInput!) {
updateWordlist(input: $wordlist) {
errors {
translation_key
}
}
}
`;
describe('context with different user roles', () => {
[
{error: 'NOT_AUTHORIZED'},
{error: 'NOT_AUTHORIZED', roles: []},
{roles: ['ADMIN']},
{roles: ['ADMIN', 'MODERATOR']},
{roles: ['MODERATOR']},
].forEach(({roles, error}) => {
it(roles && roles.length > 0 ? roles.join(', ') : '<None>', async () => {
let user;
if (roles != null) {
user = new UserModel({roles});
}
const ctx = new Context({user});
const wordlist = {
banned: [
'happy',
],
suspect: [
'sad',
],
};
const res = await graphql(schema, QUERY, {}, ctx, {
wordlist,
});
if (res.errors) {
console.error(res.errors);
}
expect(res.errors).to.be.empty;
if (error) {
expect(res.data.updateWordlist.errors).to.not.be.empty;
expect(res.data.updateWordlist.errors[0]).to.have.property('translation_key', error);
const {wordlist: retrievedWordlist} = await SettingsService.retrieve();
expect(retrievedWordlist).to.have.property('banned');
expect(retrievedWordlist.banned).to.have.members([]);
expect(retrievedWordlist).to.have.property('suspect');
expect(retrievedWordlist.suspect).to.have.members([]);
} else {
if (res.data.updateWordlist && res.data.updateWordlist.errors) {
console.error(res.data.updateWordlist.errors);
}
expect(res.data.updateWordlist).to.be.null;
const {wordlist: retrievedWordlist} = await SettingsService.retrieve();
expect(retrievedWordlist).to.have.property('banned');
expect(retrievedWordlist.banned).to.have.members(wordlist.banned);
expect(retrievedWordlist).to.have.property('suspect');
expect(retrievedWordlist.suspect).to.have.members(wordlist.suspect);
}
});
});
});
});