Merge branch 'master' into dropdown

This commit is contained in:
Kim Gardner
2017-10-11 09:49:19 +01:00
committed by GitHub
57 changed files with 1333 additions and 1069 deletions
+12
View File
@@ -0,0 +1,12 @@
# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
version: v1.7.1
ignore: {}
# patches apply the minimum changes required to fix a vulnerability
patch:
'npm:marked:20170112':
- marked:
patched: '2017-10-11T02:07:15.455Z'
- graphql-docs > marked:
patched: '2017-10-11T02:07:15.455Z'
- simplemde > marked:
patched: '2017-10-11T02:07:15.455Z'
@@ -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,
@@ -76,6 +76,7 @@
display: flex;
justify-content: center;
flex-direction: column;
width: 100%;
}
.editCommentForm .buttonContainerLeft .editWindowRemaining {
@@ -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 -4
View File
@@ -3,10 +3,13 @@ import has from 'lodash/has';
import get from 'lodash/get';
import merge from 'lodash/merge';
import esTA from '../../../node_modules/timeago.js/locales/es';
import frTA from '../../../node_modules/timeago.js/locales/fr';
import pt_BRTA from '../../../node_modules/timeago.js/locales/pt_BR';
import daTA from 'timeago.js/locales/da';
import esTA from 'timeago.js/locales/es';
import frTA from 'timeago.js/locales/fr';
import pt_BRTA from 'timeago.js/locales/pt_BR';
import en from '../../../locales/en.yml';
import da from '../../../locales/da.yml';
import es from '../../../locales/es.yml';
import fr from '../../../locales/fr.yml';
import pt_BR from '../../../locales/pt_BR.yml';
@@ -14,7 +17,7 @@ import pt_BR from '../../../locales/pt_BR.yml';
// Translations are happening at https://translate.lingohub.com/the-coral-project/dashboard
const defaultLanguage = process.env.TALK_DEFAULT_LANG;
const translations = {...en, ...es, ...fr, ...pt_BR};
const translations = {...en, ...da, ...es, ...fr, ...pt_BR};
let lang;
let timeagoInstance;
@@ -44,6 +47,7 @@ function init() {
}
ta.register('es', esTA);
ta.register('da', daTA);
ta.register('fr', frTA);
ta.register('pt_BR', pt_BRTA);
timeagoInstance = ta();
@@ -8,6 +8,8 @@ import ClickOutside from 'coral-framework/components/ClickOutside';
import cn from 'classnames';
import styles from './styles.css';
import {getErrorMessages} from 'coral-framework/utils';
const name = 'talk-plugin-flags';
export default class FlagButton extends Component {
@@ -99,6 +101,7 @@ export default class FlagButton extends Component {
}
})
.catch((err) => {
this.props.notify('error', getErrorMessages(err));
console.error(err);
});
} else {
@@ -109,6 +112,7 @@ export default class FlagButton extends Component {
}
})
.catch((err) => {
this.props.notify('error', getErrorMessages(err));
console.error(err);
});
}
-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
+85 -85
View File
@@ -1,8 +1,8 @@
dk:
your_account_has_been_suspended: Din konto er midlertidigt suspenderet
your_account_has_been_banned: Din konto er blevet banned.
your_username_has_been_rejected: Din konto er blevet suspenderet fordi dit brugernavn er blevet anset for upassende. For at gendanne din konto, venligst indtast et nyt brugernavn.
embed_comments_tab: Kommentarer
da:
your_account_has_been_suspended: "Din konto er midlertidigt suspenderet"
your_account_has_been_banned: "Din konto er blevet banned."
your_username_has_been_rejected: "Din konto er blevet suspenderet fordi dit brugernavn er blevet anset for upassende. For at gendanne din konto, venligst indtast et nyt brugernavn."
embed_comments_tab: "Kommentarer"
bandialog:
are_you_sure: "Er du sikker på du vil banne {0}?"
ban_user: "Ban Bruger?"
@@ -19,7 +19,7 @@ dk:
anon: "Anonym"
ban_user: "Ban Bruger"
comment: "Skriv en kommentar"
edited: Redigeret
edited: "Redigeret"
flagged: "markeret"
view_context: "Vis kontekst"
comment_box:
@@ -30,53 +30,53 @@ dk:
name: "Navn"
comment_post_notif: "Din kommentar er blevet sendt"
comment_post_notif_premod: "Tak for at skrive en kommentar. Vores moderatorer vil gennemgå din kommentar inden for kort tid."
comment_post_banned_word: "Din kommentar indeholder et eller flere ord, der ikke er tilladt, derfor vil den ikke bliver offentliggjort. Hvis du mener, at denne besked er forkert, bedes du kontakte vores moderatorer"
comment_post_banned_word: "Din kommentar indeholder et eller flere ord, der ikke er tilladt, derfor vil den ikke bliver offentliggjort. Hvis du mener, at denne besked er forkert, bedes du kontakte vores moderatorer"
characters_remaining: "tegn tilbage"
comment_offensive: "Denne kommentar er stødende"
comment_singular: Kommentar
comment_plural: Kommentarer
comment_singular: "Kommentar"
comment_plural: "Kommentarer"
comment_post_banned_word: "Din kommentyar indeholder et eller flere ord, der ikke er tilladt, derfor vil den ikke bliver offentliggjort. Hvis du mener, at denne besked er forkert, bedes du kontakte vores moderatorer"
comment_post_notif: "Din kommentar er blevet offentliggjort"
comment_post_notif_premod: "Tak for at skrive en kommentar. Vores moderatorer vil gennemgå din kommentar inden for kort tid."
common:
copy: 'Kopier'
error: 'Der er sket en fejl.'
reply: 'svar'
replies: 'svar'
reaction: 'reaktion'
reactions: 'reaktioner'
story: 'Historie'
copy: "Kopier"
error: "Der er sket en fejl."
reply: "svar"
replies: "svar"
reaction: "reaktion"
reactions: "reaktioner"
story: "Historie"
community:
account_creation_date: "Oprettelsedato for konto"
active: Aktive
admin: Administrator
active: "Aktive"
admin: "Administrator"
ads_marketing: "Dette ligner en annonce/markedsføring"
are_you_sure: "Er du sikker på at du vil banne {0}?"
ban_user: "Ban Bruger?"
banned: Bannet
banned: "Bannet"
banned_user: "Bannet Bruger"
cancel: Afbryd
cancel: "Afbryd"
dont_like_username: "Synses ikke om brugernavnet"
flaggedaccounts: "Flagged Brugernavne"
flags: Flags
flags: "Flags"
impersonating: "Personefterligning"
loading: "Indlæser resultater"
moderator: Moderator
moderator: "Moderator"
newsroom_role: "Nyhedsrum Rolle"
no_flagged_accounts: "Køen for flagget kontoer er tom i øjeblikket"
no_results: "Igen brugere fundet med det brugernavn eller email-adresse. De gemmer sig!"
offensive: "Offensiv"
other: Andre
people: Mennesker
other: "Andre"
people: "Mennesker"
role: "Vælg rolle..."
select_status: "Vælg status..."
spam_ads: "Spam/Reklamer"
staff: "Personale"
status: Status
status: "Status"
username_and_email: "Brugernavn eller email-adresse"
yes_ban_user: "Ja Ban Bruger"
configure:
apply: Anvend
apply: "Anvend"
banned_word_text: "Kommentarer, der indeholder disse ord eller sætninger (Ikke følsom over for store eller små bogstaver) fjernes automatisk fra kommentarstrømmen. Indtast et ord og tryk på Enter eller Tab for at tilføje. Indsæt eventuelt en kommasepareret liste."
banned_words_title: "Bannet ord liste"
close: "Luk"
@@ -88,18 +88,18 @@ dk:
closed_stream_settings: "Lukket strøm besked"
comment_count_error: "Indtæst venligst et gyldigt numemr."
comment_count_header: "Begræns kommentar længde"
comment_count_text_post: tegn
comment_count_text_post: "tegn"
comment_count_text_pre: "Kommentarer vil være begrænset til"
comment_settings: Indstillinger
comment_settings: "Indstillinger"
comment_stream: "Kommentarstrøm"
comment_stream_will_close: "Denne kommentarstrøm vil blive lukkes"
community: Fællesskab
configure: Konfigurer
community: "Fællesskab"
configure: "Konfigurer"
copy_and_paste: "Kopier og indsæt kode nedenfor i dit CMS system for at indlejre din kommentarboks i dine artiker."
custom_css_url: "Brugerdefineret CSS URL"
custom_css_url_desc: "URL til et CSS stylesheet der tilsidesætter den alimdelige strøms stilart. Kan være internt eller externt"
dashboard: Instrumentbræt
days: Dage
dashboard: "Instrumentbræt"
days: "Dage"
description: "Som administrator kan du tilpasse indstillingerne til kommentarstrømmen for denne historie:"
domain_list_text: "Indtast de domæner du gerne vil tillade til Talk. f.eks. Dit lokale udviklings miljø (fx. localhost:3000 staging.domain.com domain.com)."
domain_list_title: "Tilladte domæner."
@@ -116,12 +116,12 @@ dk:
enable_premod_links_description: "Moderatorer skal godkende enhver kommentar, der indeholder et link før det bliver offentliggjort."
enable_questionbox: "Stil et spørgsmål til læserne"
enable_questionbox_description: "Dette spørgsmål vises øverst i denne kommentarstrøm. Stil et spørgsmål til læserne om et bestemt problem i artiklen eller stil spørgsmål til diskussion mv."
hours: Timer
hours: "Timer"
include_comment_stream: "Inkluder kommentarstrømmens beskrivelse for læsere"
include_comment_stream_desc: "Skriv en besked, der skal tilføjes øverst i din kommentarstrøm. Indsæt et emne der omfatter fællesskabsretningslinjer mv."
include_text: "Medtag din tekst her."
include_question_here: "Stil dit spørgsmål her:"
moderate: Moderat
moderate: "Moderat"
moderation_settings: "Moderator indstillinger"
open: "Åben"
open_stream: "Åben strøm"
@@ -129,15 +129,15 @@ dk:
require_email_verification: "Kræv email verifikation"
require_email_verification_text: "Nye brugere skal verificere deres email før de kan kommenterer"
save_changes: "Gem ændringer"
shortcuts: Genveje
shortcuts: "Genveje"
sign_out: "Log ud"
stories: Historier
stories: "Historier"
stream_settings: "Strøm indstillinger"
suspect_word_title: "Mistænkte ord liste"
suspect_word_text: "Kommentarer der indeholder disse ord eller sætninger (Ikke følsom over for store eller små bogstaver) vil blive fremhævet i kommentarstrømmen. Indtast et ord, og tryk på Enter eller Tab for at tilføje. Indsæt eventuelt en kommasepareret liste."
tech_settings: "Tekniske indstillinger"
title: "Konfigurer kommentarstrøm"
weeks: Uger
weeks: "Uger"
wordlist: "Forbudte ord"
continue: "Fortsæt"
createdisplay:
@@ -148,22 +148,22 @@ dk:
fake_comment_date: "1 minut siden"
if_you_dont_change_your_name: "Hvis du ikke ændrer dit brugernavn på dette trin, vil dit Facebook brugernavn vises sammen med alle dine kommentarer"
required_field: "Påkrævet felt"
save: Gem
save: "Gem"
special_characters: "Brugernavne kan kun indeholde bogstaver og _"
username: Brugernavn
username: "Brugernavn"
write_your_username: "Rediger dit brugernavn"
your_username: "Dit brugernavn vises på hver kommentar du sender."
dashboard:
auto_update: "Data opdateres automatisk hvert femte minut, eller når du genindlæser."
comment_count: kommentarer
flags: Flags
comment_count: "kommentarer"
flags: "Flags"
most_flags: "Historier med de fleste flags"
most_conversations: "Historier med de fleste samtaler"
next_update: "{0} minutter indtil næste opdatering."
no_activity: "Der har ikke været nogen kommentarer i løbet af de sidste fem minutter."
no_flags: "Der har ikke været nogle flag i de sidste 5 minutter! Hurra!"
no_likes: "Der har ikke været nogen der syntes godt om noget i de sidste 5 minutter. Helt stille"
done: Færdig
done: "Færdig"
edit_comment:
body_input_label: "Rediger denne kommentar"
save_button: "Gem ændringer"
@@ -203,7 +203,7 @@ dk:
COMMENTING_CLOSED: "Kommentering er allerede lukket"
NOT_FOUND: "Ressource ikke fundet"
ALREADY_EXISTS: "Ressource eksisterer allerede"
INVALID_ASSET_URL: "Aktivitetswebadresse er ugyldig"
INVALID_ASSET_URL: "Aktivitetswebadresse er ugyldig"
email: "Ikke en gylding e-mail"
confirm_password: "Kodeordene matcher ikke. Tjek venligst igen."
network_error: "Det lykkedes ikke at oprette forbindelse til serveren. Tjek din internetforbindelse og prøv igen."
@@ -219,64 +219,64 @@ dk:
framework:
banned_account_header: "Din konto er i øjeblikket bannet"
banned_account_body: "Det betyder at du ikke kan synes godt om, rapportere eller skrive kommentarer."
comment: kommentar
comment: "kommentar"
comment_is_ignored: "Denne kommentar er skjult, fordi du ignoerede denne bruger."
comment_is_rejected: "Du har afvist denne kommentar."
comment_is_hidden: "Denne kommentar er ikke tilgængelig"
comments: kommentarer
comments: "kommentarer"
configure_stream: "Konfigurer"
content_not_available: "Dette indhold er ikke tilgængeligt"
edit_name:
button: Indsend
button: "Indsend"
error: "Brugernavne kan kun indeholde bogstaver og _"
label: "Nyt brugernavn"
msg: "Din konto er midlertidigt suspenderet, fordi dit brugernavn er blevet anset for upassende. For at gendanne din konto skal du indtaste et nyt brugernavn. Kontakt os venligst, hvis du har spørgsmål."
my_comments: "Mine kommentarer"
my_profile: "Min profil"
new_count: "Se {0} mere {1}"
profile: Profil
profile: "Profil"
show_all_comments: "Vis alle kommentarer"
success_bio_update: "Din biografi er blevet opdateret"
success_name_update: "Dit brugernavn er blevet opdateret"
success_update_settings: "De ændringer du har foretaget, er blevet anvendt til kommentarstrømmen på denne artikel."
show_all_replies: Vis alle svar
show_more_replies: Vis flere svar
show_all_replies: "Vis alle svar"
show_more_replies: "Vis flere svar"
view_more_comments: "se flere kommentarer"
view_reply: "vis svar"
from_settings_page: "Fra profilsiden kan du se din kommentarhistorie."
like: Synes godt om
like: "Synes godt om"
loading_results: "Indlæser resultater"
marketing: "Dette ligner en annonce/markedsføring"
moderate_this_stream: "Moderer denne strøm"
modqueue:
account: "konto flag"
actions: Handlinger
all: alle
actions: "Handlinger"
all: "alle"
all_streams: "Alle Strømme"
notify_edited: '{0} redigeret kommentar "{1}"'
notify_accepted: '{0} accepteret kommentar "{1}"'
notify_rejected: '{0} afvist kommentar "{1}"'
notify_flagged: '{0} flagget kommentar "{1}"'
notify_edited: "{0} redigeret kommentar {1}"
notify_accepted: "{0} accepteret kommentar {1}"
notify_rejected: "{0} afvist kommentar {1}"
notify_flagged: "{0} flagget kommentar {1}"
approve: "Godkend"
approved: "Godkendt"
ban_user: "Ban"
billion: M
close: Close
billion: "M"
close: "Close"
empty_queue: "Ikke flere kommentarer til at moderer! Du har indhentet det hele. Gå ud og tag noget ☕️"
flagged: flagged
reported: Rapporteret
flagged: "flagged"
reported: "Rapporteret"
less_detail: "Færre detajler"
likes: Syntes godt om
million: M
likes: "Syntes godt om"
million: "M"
mod_faster: "Moderer hurtigere med tastaturgenveje"
moderate: "Moderer →"
more_detail: "Flere detaljer"
new: Ny
new: "Ny"
newest_first: "Nyeste først"
navigation: Navigation
navigation: "Navigation"
next_comment: "Gå til næste kommentar"
oldest_first: "Ældste Først"
premod: pre-mod
premod: "pre-mod"
prev_comment: "Gå til den forrige kommentar"
reject: "Afvis"
rejected: "Afvist"
@@ -287,29 +287,29 @@ dk:
show_shortcuts: "Vis genveje"
singleview: "Skift enkeltkommentar redigerings visning"
thismenu: "Åben denne menu"
thousand: k
thousand: "k"
try_these: "Prøv disse"
view_more_shortcuts: "Se flere genveje"
my_comment_history: "Min kommentar Historie"
name: Navn
name: "Navn"
no_agree_comment: "Jeg er ikke enig med denne kommentar"
no_like_bio: "Jeg kan ikke lide denne biografi"
no_like_username: "Jeg kan ikke lide dette brugernavn"
other: Andet
permalink: Link
other: "Andet"
permalink: "Link"
personal_info: "Denne kommentar afslører personligt identificerbare oplysninger"
post: Post
profile: Profil
post: "Post"
profile: "Profil"
profile_settings: "Profilindstillinger"
reply: Svar
report: Rapporter
reply: "Svar"
report: "Rapporter"
report_notif: "Tak fordi du rapporterede denne kommentar. Vores modereringsteam har fået besked, og vil gennemgå det inden for kort tid."
report_notif_remove: "Din rapport er blevet fjernet"
reported: Rapporteret
reported: "Rapporteret"
settings:
from_settings_page: "Fra profilsiden kan du se din kommentarhistorie."
my_comment_history: "Min kommentarhistorie"
profile: Profil
profile: "Profil"
profile_settings: "Profilindstillinger"
sign_in: "Log ind"
to_access: "for at tilgå din profil"
@@ -322,16 +322,16 @@ dk:
step_2_header: "Hjælp os med at forstå"
step_3_header: "Tak for din indsats"
streams:
all: Alle
article: Historie
closed: Lukket
all: "Alle"
article: "Historie"
closed: "Lukket"
empty_result: "Ingen egenskaber matcher denne søgning. Måske prøv og forsøge at udvide din søgning?"
filter_streams: "Filtrer strømme"
newest: Nyeste
oldest: Ældste
open: Åben
newest: "Nyeste"
oldest: "Ældste"
open: "Åben"
pubdate: "Udgivelsedato"
search: Søg
search: "Søg"
sort_by: "Sorter efter"
status: "Strøm status"
stream_status: "Strøm Status"
@@ -349,9 +349,9 @@ dk:
notify_suspend_until: "Bruger {0} er midlertidigt suspenderet. Denne suspension ophører automatisk om {1}."
description_notify: "Suspendering af denne bruger vil midlertidigt deaktivere deres konto og skjule alle deres kommentarer på webstedet."
write_message: "Skriv en besked"
send: Send
send: "Send"
reject_username:
username: brugernavn
username: "brugernavn"
no_cancel: "Nej Afbryd"
description_reject: "Vil du midlertidigt forbyde denne bruger på grund af deres {0}? Hvis du gør det, vil det midlertidigt skjule deres kommentarer, indtil de omskriver deres {0}."
title_notify: "Underret brugeren om deres midlertidige suspension"
@@ -361,7 +361,7 @@ dk:
yes_suspend: "Ja suspender"
email_message_reject: "Et andet medlem af samfundet har for nylig flagget dit brugernavn til anmeldelse. På grund af indholdet blev din bruger afvist. Det betyder, at du ikke længere kan kommentere, synes godt om eller flagge indhold, før du omskriver dit brugernavn. Venligst send os en e-mail, hvis du har spørgsmål eller bekymringer."
write_message: "Skriv en besked"
send: Send
send: "Send"
thank_you: "Vi sætter pris på din sikkerhed og feedback. En moderator vil gennemgå din rapport"
user:
bio_flags: "flag for denne bio"
+11 -6
View File
@@ -12,13 +12,15 @@
"build": "WEBPACK=TRUE NODE_ENV=production webpack -p --config webpack.config.js --bail",
"prebuild-watch": "yarn generate-introspection",
"build-watch": "WEBPACK=TRUE NODE_ENV=development webpack --progress --config webpack.config.js --watch",
"lint": "eslint --ext=.js --ext=.json bin/* .",
"lint": "yamllint locales/*.yml && eslint --ext=.js --ext=.json bin/* .",
"lint-fix": "yarn lint --fix",
"jest-watch": "TEST_MODE=unit NODE_ENV=test jest --watch",
"test": "TEST_MODE=unit NODE_ENV=test jest && TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}",
"test-cover": "TEST_MODE=unit NODE_ENV=test istanbul cover _mocha --report text --check-coverage -- -R spec",
"heroku-postbuild": "./bin/cli plugins reconcile && yarn build",
"generate-introspection": "WEBPACK=TRUE NODE_ENV=test ./scripts/generateIntrospectionResult.js"
"generate-introspection": "WEBPACK=TRUE NODE_ENV=test ./scripts/generateIntrospectionResult.js",
"snyk-protect": "snyk protect",
"prepublish": "npm run snyk-protect"
},
"talk": {
"migration": {
@@ -177,13 +179,14 @@
"timekeeper": "^1.0.0",
"tlds": "^1.196.0",
"url-join": "^2.0.2",
"url-loader": "^0.5.9",
"url-loader": "^0.6.0",
"url-search-params": "^0.9.0",
"uuid": "^3.1.0",
"webpack": "^2.3.1",
"webpack-sources": "^1.0.1",
"yaml-loader": "^0.4.0",
"yamljs": "^0.2.10"
"yamljs": "^0.2.10",
"snyk": "^1.42.5"
},
"devDependencies": {
"@coralproject/eslint-config-talk": "^0.0.4",
@@ -204,12 +207,14 @@
"nodemon": "^1.11.0",
"pre-git": "^3.15.3",
"sinon": "^3.2.1",
"sinon-chai": "^2.13.0"
"sinon-chai": "^2.13.0",
"yaml-lint": "^1.0.0"
},
"engines": {
"node": "^8"
},
"release": {
"analyzeCommits": "simple-commit-message"
}
},
"snyk": true
}
-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';
+3 -2
View File
@@ -3,6 +3,7 @@ const get = require('lodash/get');
const yaml = require('yamljs');
const da = yaml.load('./locales/da.yml');
const es = yaml.load('./locales/es.yml');
const en = yaml.load('./locales/en.yml');
const fr = yaml.load('./locales/fr.yml');
@@ -13,9 +14,9 @@ const accepts = require('accepts');
// default language
let defaultLanguage = 'en';
let language = defaultLanguage;
const languages = ['en', 'es', 'fr', 'pt_BR'];
const languages = ['en', 'da', 'es', 'fr', 'pt_BR'];
const translations = Object.assign(en, es, fr, pt_BR);
const translations = Object.assign(en, es, fr, pt_BR, da);
/**
* Exposes a service object to allow translations.
+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);
}
});
});
});
});
+1
View File
@@ -14,6 +14,7 @@
margin-left:auto;
margin-right:auto;
max-width:500px;
display: block;
}
</style>
<title><%= title %></title>
+72 -3
View File
@@ -1093,6 +1093,10 @@ bluebird@^2.10.2:
version "2.11.0"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1"
bluebird@^3.5.0:
version "3.5.1"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
version "4.11.6"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.6.tgz#53344adb14617a13f6e8dd2ce28905d1c0ba3215"
@@ -1289,6 +1293,10 @@ camelcase@^1.0.2:
version "1.2.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39"
camelcase@^2.0.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
camelcase@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a"
@@ -1547,7 +1555,7 @@ cliui@^2.1.0:
right-align "^0.1.1"
wordwrap "0.0.2"
cliui@^3.2.0:
cliui@^3.0.3, cliui@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d"
dependencies:
@@ -3738,7 +3746,7 @@ inherits@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1"
ini@~1.3.0:
ini@^1.3.0, ini@~1.3.0:
version "1.3.4"
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e"
@@ -4765,6 +4773,12 @@ lcid@^1.0.0:
dependencies:
invert-kv "^1.0.0"
leprechaun@0.0.2:
version "0.0.2"
resolved "https://registry.yarnpkg.com/leprechaun/-/leprechaun-0.0.2.tgz#8b96514a9e634c53fbe59a8094f3378c8fb2084d"
dependencies:
log-symbols "^1.0.2"
leven@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/leven/-/leven-2.1.0.tgz#c2e7a9f772094dee9d34202ae8acce4687875580"
@@ -5001,6 +5015,10 @@ lodash.memoize@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
lodash.merge@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.0.tgz#69884ba144ac33fe699737a6086deffadd0f89c5"
lodash.noop@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/lodash.noop/-/lodash.noop-3.0.1.tgz#38188f4d650a3a474258439b96ec45b32617133c"
@@ -5033,6 +5051,10 @@ lodash.shuffle@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.shuffle/-/lodash.shuffle-4.2.0.tgz#145b5053cf875f6f5c2a33f48b6e9948c6ec7b4b"
lodash.snakecase@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d"
lodash.toarray@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz#24c4bfcd6b2fba38bfd0594db1179d8e9b656561"
@@ -5057,6 +5079,12 @@ lodash@4.17.4, lodash@^4.0.0, lodash@^4.1.0, lodash@^4.14.0, lodash@^4.15.0, lod
version "4.17.4"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"
log-symbols@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
dependencies:
chalk "^1.0.0"
lolex@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.6.0.tgz#3a9a0283452a47d7439e72731b9e07d7386e49f6"
@@ -5412,6 +5440,15 @@ natural@^0.2.0:
sylvester ">= 0.0.12"
underscore ">=1.3.1"
nconf@^0.8.4:
version "0.8.5"
resolved "https://registry.yarnpkg.com/nconf/-/nconf-0.8.5.tgz#f2941e1561952fa906bbb32328cf88d4c635e794"
dependencies:
async "^1.4.0"
ini "^1.3.0"
secure-keys "^1.0.0"
yargs "^3.19.0"
nearley@^2.7.10:
version "2.11.0"
resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.11.0.tgz#5e626c79a6cd2f6ab9e7e5d5805e7668967757ae"
@@ -7387,6 +7424,10 @@ sax@^1.1.4, sax@^1.2.1, sax@~1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.2.tgz#fd8631a23bc7826bef5d871bdb87378c95647828"
secure-keys@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/secure-keys/-/secure-keys-1.0.0.tgz#f0c82d98a3b139a8776a8808050b824431087fca"
select@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d"
@@ -8438,6 +8479,10 @@ window-size@0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d"
window-size@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.4.tgz#f8e1aa1ee5a53ec5bf151ffa09742a6ad7697876"
window-size@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.2.0.tgz#b4315bb4214a3d7058ebeee892e13fa24d98b075"
@@ -8534,7 +8579,7 @@ xtend@^4.0.0, xtend@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
y18n@^3.2.1:
y18n@^3.2.0, y18n@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"
@@ -8542,6 +8587,18 @@ yallist@^2.0.0:
version "2.1.2"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
yaml-lint@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/yaml-lint/-/yaml-lint-1.0.0.tgz#655068f583263eec7e2a0ded8f5b1895dd667e1c"
dependencies:
bluebird "^3.5.0"
glob "^7.1.2"
js-yaml "^3.9.1"
leprechaun "0.0.2"
lodash.merge "^4.6.0"
lodash.snakecase "^4.1.1"
nconf "^0.8.4"
yaml-loader@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/yaml-loader/-/yaml-loader-0.4.0.tgz#4aae447d13c1aa73a989d8a2a5309b0b1a3ca353"
@@ -8581,6 +8638,18 @@ yargs-parser@^7.0.0:
dependencies:
camelcase "^4.1.0"
yargs@^3.19.0:
version "3.32.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.32.0.tgz#03088e9ebf9e756b69751611d2a5ef591482c995"
dependencies:
camelcase "^2.0.1"
cliui "^3.0.3"
decamelize "^1.1.1"
os-locale "^1.4.0"
string-width "^1.0.1"
window-size "^0.1.4"
y18n "^3.2.0"
yargs@^4.0.0:
version "4.8.1"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-4.8.1.tgz#c0c42924ca4aaa6b0e6da1739dfb216439f9ddc0"