Merge branch 'master' of github.com:coralproject/talk into e2e-tests

This commit is contained in:
Belen Curcio
2016-12-17 10:48:37 -03:00
52 changed files with 618 additions and 67 deletions
+1
View File
@@ -1,2 +1,3 @@
dist
client/lib
**/*.html
+2
View File
@@ -21,6 +21,8 @@
"no-eval": [2],
"no-global-assign": [2],
"no-implied-eval": [2],
"lines-around-comment": ["warn", {"beforeLineComment": true}],
"spaced-comment": ["warn", "always", { "line": { "exceptions": ["-", "="] } }],
"no-script-url": [2],
"no-throw-literal": [2],
"yoda": [1],
+1
View File
@@ -22,6 +22,7 @@ if (app.get('env') !== 'test') {
//==============================================================================
app.set('trust proxy', 1);
// We disable frameward on helmet to allow crossdomain injection of the embed
app.use(helmet({
frameguard: false
+2
View File
@@ -60,11 +60,13 @@ function normalizePort(val) {
let port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
@@ -14,7 +14,7 @@ const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => {
return (
<Dialog className={styles.dialog} open={open} onClose={() => handleClose()} onCancel={() => handleClose()} title={lang.t('bandialog.ban_user')}>
<span className={styles.close} onClick={() => handleClose()}>×</span>
<span className={styles.close} onClick={() => handleClose()}>×</span>
<div>
<div className={styles.header}>
<h3>
@@ -31,6 +31,7 @@ export default class CommentList extends React.Component {
// add key handlers and gestures
componentDidMount () {
this.bindKeyHandlers();
// this.bindGestures() // need to check whether we're on a mobile device or this throws an Error
}
@@ -80,6 +81,7 @@ export default class CommentList extends React.Component {
const {commentIds} = this.props;
const {active} = this.state;
// check boundaries
if (active === null || !commentIds.length) {
this.setState({active: commentIds[0]});
@@ -102,6 +104,7 @@ export default class CommentList extends React.Component {
// TODO: In the future this can be improved and look at the actual state to
// resolve since the content of the list could change externally. For now it works as expected
onClickAction (action, id, author_id) {
// activate the next comment
if (id === this.state.active) {
const {commentIds} = this.props;
@@ -1,4 +1,5 @@
import React from 'react';
import {SelectField, Option} from 'react-mdl-selectfield';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
import styles from './Configure.css';
@@ -12,6 +13,12 @@ import {
Icon
} from 'react-mdl';
const TIMESTAMPS = {
weeks: 60 * 60 * 24 * 7,
days: 60 * 60 * 24,
hours: 60 * 60
};
const updateCharCountEnable = (updateSettings, charCountChecked) => () => {
const charCountEnable = !charCountChecked;
updateSettings({charCountEnable});
@@ -47,11 +54,32 @@ const updateClosedMessage = (updateSettings) => (event) => {
updateSettings({closedMessage});
};
const CommentSettings = ({updateSettings, settingsError, settings, errors}) => <List>
// 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 CommentSettings = ({fetchingSettings, updateSettings, settingsError, settings, errors}) => {
if (fetchingSettings) {
/* maybe a spinner here at some point */
return <p>Loading settings...</p>;
}
return <List>
<ListItem className={`${styles.configSetting} ${settings.moderation === 'pre' ? styles.enabledSetting : styles.disabledSetting}`}>
<ListItemAction>
<Checkbox
onClick={updateModeration(updateSettings, settings.moderation)}
onChange={updateModeration(updateSettings, settings.moderation)}
checked={settings.moderation === 'pre'} />
</ListItemAction>
<ListItemContent>
@@ -64,7 +92,7 @@ const CommentSettings = ({updateSettings, settingsError, settings, errors}) => <
<ListItem className={`${styles.configSetting} ${settings.charCountEnable ? styles.enabledSetting : styles.disabledSetting}`}>
<ListItemAction>
<Checkbox
onClick={updateCharCountEnable(updateSettings, settings.charCountEnable)}
onChange={updateCharCountEnable(updateSettings, settings.charCountEnable)}
checked={settings.charCountEnable} />
</ListItemAction>
<ListItemContent>
@@ -91,7 +119,7 @@ const CommentSettings = ({updateSettings, settingsError, settings, errors}) => <
<ListItem threeLine className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? styles.enabledSetting : styles.disabledSetting}`}>
<ListItemAction>
<Checkbox
onClick={updateInfoBoxEnable(updateSettings, settings.infoBoxEnable)}
onChange={updateInfoBoxEnable(updateSettings, settings.infoBoxEnable)}
checked={settings.infoBoxEnable} />
</ListItemAction>
<ListItemContent>
@@ -110,6 +138,29 @@ const CommentSettings = ({updateSettings, settingsError, settings, errors}) => <
rows={3}/>
</ListItemContent>
</ListItem>
<ListItem className={styles.configSettingInfoBox}>
<ListItemContent>
{lang.t('configure.close-after')}
<br />
<Textfield
type='number'
pattern='[0-9]+'
style={{width: 50}}
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout)}
value={getTimeoutAmount(settings.closedTimeout)}
label={lang.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'}>{lang.t('configure.hours')}</Option>
<Option value={'days'}>{lang.t('configure.days')}</Option>
<Option value={'weeks'}>{lang.t('configure.weeks')}</Option>
</SelectField>
</div>
</ListItemContent>
</ListItem>
<ListItem className={styles.configSettingInfoBox}>
<ListItemContent>
{lang.t('configure.closed-comments-desc')}
@@ -121,7 +172,24 @@ const CommentSettings = ({updateSettings, settingsError, settings, errors}) => <
</ListItemContent>
</ListItem>
</List>;
};
export default CommentSettings;
// 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 => {
if (ts % TIMESTAMPS['weeks'] === 0) {
return 'weeks';
} else if (ts % TIMESTAMPS['days'] === 0) {
return 'days';
} else if (ts % TIMESTAMPS['hours'] === 0) {
return 'hours';
}
};
// Dividing the amount by it's measure (hours, days, weeks) we
// obtain the amount of time
const getTimeoutAmount = ts => ts / TIMESTAMPS[getTimeoutMeasure(ts)];
const lang = new I18n(translations);
@@ -63,6 +63,11 @@
display: block;
}
.configTimeoutSelect {
display: inline-block;
margin-left: 20px;
}
.charCountTexfield {
width: 4em;
padding: 0px;
@@ -78,6 +78,7 @@ class Configure extends React.Component {
switch(section){
case 'comments':
return <CommentSettings
fetchingSettings={this.props.fetchingSettings}
settings={this.props.settings}
updateSettings={this.onSettingUpdate}
errors={this.state.errors}
@@ -14,6 +14,18 @@
color: white;
}
.showShortcuts {
position: absolute;
right: 130px;
display: flex;
align-items: center;
font-size: 13px;
span {
margin-left: 7px;
}
}
@media (--big-viewport) {
.tab {
flex: none;
@@ -1,5 +1,6 @@
import React from 'react';
import {connect} from 'react-redux';
import {Icon} from 'react-mdl';
import key from 'keymaster';
import ModerationKeysModal from 'components/ModerationKeysModal';
@@ -47,6 +48,7 @@ class ModerationQueue extends React.Component {
// Hack for dynamic mdl tabs
componentDidMount () {
if (typeof componentHandler !== 'undefined') {
// FIXME: fix this hack
componentHandler.upgradeAllRegistered(); // eslint-disable-line no-undef
}
@@ -54,6 +56,7 @@ class ModerationQueue extends React.Component {
// Dispatch the update status action
onCommentAction (action, id) {
// If not banning then change the status to approved or flagged as action = status
this.props.dispatch(updateStatus(action, id));
}
@@ -70,6 +73,10 @@ class ModerationQueue extends React.Component {
this.props.dispatch(banUser('banned', userId, commentId));
}
showShortcuts = () => {
this.setState({modalOpen: true});
}
onTabClick (activeTab) {
this.setState({activeTab});
}
@@ -93,6 +100,11 @@ class ModerationQueue extends React.Component {
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.rejected')}</a>
<a href='#flagged' onClick={() => this.onTabClick('flagged')}
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.flagged')}</a>
<a href='#shortcuts' onClick={this.showShortcuts}
className={`mdl-tabs__tab ${styles.tab} ${styles.showShortcuts}`}>
<Icon name='keyboard' />
<span>{lang.t('modqueue.showshortcuts')}</span>
</a>
</div>
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='pending'>
<CommentList
@@ -110,7 +122,7 @@ class ModerationQueue extends React.Component {
handleClose={() => this.hideBanUserDialog()}
onClickBanUser={(userId, commentId) => this.banUser(userId, commentId)}
user={comments.banUser}/>
</div>
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
<CommentList
isActive={activeTab === 'rejected'}
@@ -124,7 +136,7 @@ class ModerationQueue extends React.Component {
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='flagged'>
<CommentList
isActive={activeTab === 'rejected'}
isActive={activeTab === 'flagged'}
singleView={singleView}
commentIds={flaggedIds}
comments={comments.byId}
@@ -133,11 +145,14 @@ class ModerationQueue extends React.Component {
actions={['reject', 'approve']}
loading={comments.loading} />
</div>
<ModerationKeysModal open={modalOpen}
onClose={() => this.setState({modalOpen: false})} />
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='shortcuts'>
<ModerationKeysModal open={modalOpen}
onClose={() => this.setState({modalOpen: false})} />
</div>
</div>
</div>
);
}
}
@@ -38,6 +38,7 @@ Promise.all([
coralApi('/comments?action_type=flag')
])
.then(([pending, rejected, flagged]) => {
/* Combine seperate calls into a single object */
let all = {};
all.comments = pending.comments
@@ -55,6 +56,7 @@ Promise.all([
return all;
})
.then(all => {
/* Post comments and users to redux store. Actions will be posted when they are needed. */
store.dispatch({type: 'USERS_MODERATION_QUEUE_FETCH_SUCCESS',
users: all.users});
@@ -62,6 +64,7 @@ Promise.all([
comments: all.comments});
});
// .catch(error => store.dispatch({type: 'COMMENTS_MODERATION_QUEUE_FETCH_FAILED', error}));
// Update a comment. Now to update a comment we need to send back the whole object
+13 -2
View File
@@ -28,7 +28,8 @@
"nextcomment": "Go to the next comment",
"prevcomment": "Go to the previous comment",
"singleview": "Toggle single comment edit view",
"thismenu": "Open this menu"
"thismenu": "Open this menu",
"showshortcuts": "Show Shortcuts"
},
"comment": {
"flagged": "flagged",
@@ -57,6 +58,10 @@
"community": "Community",
"closed-comments-desc": "Write a message for closed threads",
"closed-comments-label": "Write a message...",
"hours": "Hours",
"days": "Days",
"weeks": "Weeks",
"close-after": "Close comments after",
"comment-count-header": "Limit Comment Length",
"comment-count-text-pre": "Comments will be limited to ",
"comment-count-text-post": " characters.",
@@ -91,7 +96,8 @@
"rejected": "rechazado",
"flagged": "marcado",
"shortcuts": "Atajos de teclado",
"close": "Cerrar"
"close": "Cerrar",
"showshortcuts": "Mostrar atajos"
},
"comment": {
"flagged": "marcado",
@@ -117,6 +123,11 @@
"community": "Comunidad",
"closed-comments-desc": "Escribe un mensaje para cuando los comentarios se encuentran cerrados",
"closed-comments-label": "Escribe un mensaje...",
"never": "Nunca",
"hours": "Horas",
"days": "Días",
"weeks": "Semanas",
"close-after": "Cerrar comentarios luego de",
"comment-count-header": "Limitar el largo del comentario",
"comment-count-text-pre": "El largo de comentarios será ",
"comment-count-text-post": " caracteres",
@@ -1,11 +1,14 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {I18n} from '../../coral-framework';
import {updateOpenStatus, updateConfiguration} from '../../coral-framework/actions/config';
import CloseCommentsInfo from '../components/CloseCommentsInfo';
import ConfigureCommentStream from '../components/ConfigureCommentStream';
const lang = new I18n();
class ConfigureStreamContainer extends Component {
constructor (props) {
super(props);
@@ -47,8 +50,15 @@ class ConfigureStreamContainer extends Component {
this.props.updateStatus(this.props.config.status === 'open' ? 'closed' : 'open');
}
getClosedIn () {
const {closedTimeout} = this.props.config;
const {created_at} = this.props.asset;
return lang.timeago(new Date(created_at).getTime() + (1000 * closedTimeout));
}
render () {
const {status} = this.props;
return (
<div>
<ConfigureCommentStream
@@ -59,6 +69,7 @@ class ConfigureStreamContainer extends Component {
/>
<hr />
<h3>{status === 'open' ? 'Close' : 'Open'} Comment Stream</h3>
{status === 'open' ? <p>The comment stream will close in {this.getClosedIn()}.</p> : ''}
<CloseCommentsInfo
onClick={this.toggleStatus}
status={status}
@@ -69,7 +80,11 @@ class ConfigureStreamContainer extends Component {
}
const mapStateToProps = (state) => ({
config: state.config.toJS()
config: state.config.toJS(),
asset: state.items
.get('assets')
.first()
.toJS()
});
const mapDispatchToProps = dispatch => ({
@@ -58,10 +58,15 @@ class CommentStream extends Component {
}
componentDidMount () {
// Set up messaging between embedded Iframe an parent component
this.pym = new Pym.Child({polling: 100});
const path = this.pym.parentUrl.split('#')[0];
let path = this.pym.parentUrl.split('#')[0];
if (!path) {
path = window.location.href.split('#')[0];
}
this.props.getStream(path || window.location);
this.path = path;
+5
View File
@@ -2,6 +2,10 @@ import coralApi from '../helpers/response';
import {fromJS} from 'immutable';
import {UPDATE_CONFIG} from '../constants/config';
/**
* Action name constants
*/
export const ADD_ITEM = 'ADD_ITEM';
export const UPDATE_ITEM = 'UPDATE_ITEM';
export const APPEND_ITEM_ARRAY = 'APPEND_ITEM_ARRAY';
@@ -114,6 +118,7 @@ export function getStream (assetUrl) {
/* Sort comments by date*/
json.comments.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const rels = json.comments.reduce((h, item) => {
/* Check for root and child comments. */
if (
item.asset_id === assetId &&
+29
View File
@@ -1,5 +1,7 @@
import * as actions from '../constants/user';
import * as assetActions from '../constants/assets';
import {addNotification} from '../actions/notification';
import {addItem} from '../actions/items';
import coralApi from '../helpers/response';
import I18n from 'coral-framework/modules/i18n/i18n';
@@ -19,3 +21,30 @@ export const saveBio = (user_id, formData) => dispatch => {
})
.catch(error => dispatch(saveBioFailure(error)));
};
/**
*
* Get a list of comments by a single user
*
* @param {string} user_id
* @returns Promise
*/
export const fetchCommentsByUserId = userId => {
return (dispatch) => {
dispatch({type: actions.COMMENTS_BY_USER_REQUEST});
return coralApi(`/comments?user_id=${userId}`)
.then(({comments, assets}) => {
comments.forEach(comment => dispatch(addItem(comment, 'comments')));
assets.forEach(asset => dispatch(addItem(asset, 'assets')));
dispatch({type: actions.COMMENTS_BY_USER_SUCCESS, comments: comments.map(comment => comment.id)});
dispatch({type: assetActions.MULTIPLE_ASSETS_SUCCESS, assets: assets.map(asset => asset.id)});
})
.catch(error => {
console.error(error.stack);
console.error('FAILURE_COMMENTS_BY_USER', error);
dispatch({type: actions.COMMENTS_BY_USER_FAILURE, error});
});
};
};
@@ -0,0 +1,3 @@
export const MULTIPLE_ASSETS_REQUEST = 'MULTIPLE_ASSETS_REQUEST';
export const MULTIPLE_ASSETS_SUCCESS = 'MULTIPLE_ASSETS_SUCCESS';
export const MULTIPLE_ASSSETS_FAILURE = 'MULTIPLE_ASSSETS_FAILURE';
+3
View File
@@ -1,3 +1,6 @@
export const SAVE_BIO_REQUEST = 'SAVE_BIO_REQUEST';
export const SAVE_BIO_SUCCESS = 'SAVE_BIO_SUCCESS';
export const SAVE_BIO_FAILURE = 'SAVE_BIO_FAILURE';
export const COMMENTS_BY_USER_REQUEST = 'COMMENTS_BY_USER_REQUEST';
export const COMMENTS_BY_USER_SUCCESS = 'COMMENTS_BY_USER_SUCCESS';
export const COMMENTS_BY_USER_FAILURE = 'COMMENTS_BY_USER_FAILURE';
@@ -9,6 +9,7 @@ import get from 'lodash/get';
class i18n {
constructor (translations) {
/**
* Register locales
*/
@@ -16,6 +17,7 @@ class i18n {
this.locales = {'en': 'en', 'es': 'es'};
timeago.register('es_ES', esTA);
this.timeagoInstance = new timeago();
/**
* Load translations
*/
@@ -55,6 +57,7 @@ class i18n {
this.t = (key, ...replacements) => {
if (has(this.translations, key)) {
let translation = get(this.translations, key);
// replace any {n} with the arguments passed to this method
replacements.forEach((str, i) => {
translation = translation.replace(new RegExp(`\\{${i}\\}`, 'g'), str);
+1
View File
@@ -6,6 +6,7 @@ import * as actions from '../actions/items';
const initialState = fromJS({
comments: {},
users: {},
assets: {},
actions: {}
});
+8 -1
View File
@@ -1,11 +1,14 @@
import {Map} from 'immutable';
import * as authActions from '../constants/auth';
import * as actions from '../constants/user';
import * as assetActions from '../constants/assets';
const initialState = Map({
displayName: '',
profiles: [],
settings: {}
settings: {},
myComments: [],
myAssets: [] // the assets from which myComments (above) originated
});
const purge = user => {
@@ -30,6 +33,10 @@ export default function user (state = initialState, action) {
case actions.SAVE_BIO_SUCCESS:
return state
.set('settings', action.settings);
case actions.COMMENTS_BY_USER_SUCCESS:
return state.set('myComments', action.comments);
case assetActions.MULTIPLE_ASSETS_SUCCESS:
return state.set('myAssets', action.assets);
default :
return state;
}
@@ -1,20 +1,23 @@
import React from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
import has from 'lodash/has';
import reduce from 'lodash/reduce';
const name = 'coral-plugin-comment-count';
const CommentCount = ({items, id}) => {
let count = 0;
if (items.assets[id] && items.assets[id].comments) {
if (has(items, `assets.${id}.comments`)) {
count += items.assets[id].comments.length;
}
const itemKeys = Object.keys(items.comments);
for (let i = 0; i < itemKeys.length; i++) {
const item = items.comments[itemKeys[i]];
if (item.children) {
count += item.children.length;
// lodash reduce works on {}
count += reduce(items.comments, (accum, comment) => {
if (comment.children) {
accum += comment.children.length;
}
}
return accum;
}, 0);
return <div className={`${name}-text`}>
{`${count} ${count === 1 ? lang.t('comment') : lang.t('comment-plural')}`}
+1 -1
View File
@@ -103,7 +103,7 @@ class CommentBox extends Component {
<div className={`${name}-button-container`}>
{ author && (
<Button
cStyle={length > charCount ? 'lightGrey' : 'darkGrey'}
cStyle={!length || (charCount && length > charCount) ? 'lightGrey' : 'darkGrey'}
className={`${name}-button`}
onClick={this.postComment}>
{lang.t('post')}
+2 -1
View File
@@ -32,7 +32,7 @@ class FlagButton extends Component {
const {postAction, addItem, updateItem, flag, id, author_id} = this.props;
const {itemType, field, detail, step, otherText, posted} = this.state;
//Proceed to the next step or close the menu if we've reached the end
// Proceed to the next step or close the menu if we've reached the end
if (step + 1 >= this.props.getPopupMenu.length) {
this.setState({showMenu: false});
} else {
@@ -41,6 +41,7 @@ class FlagButton extends Component {
// If itemType and detail are both set, post the action
if (itemType && detail && !posted) {
// Set the text from the "other" field if it exists.
const updatedDetail = otherText || detail;
let item_id;
+16
View File
@@ -0,0 +1,16 @@
.myComment {
border-bottom: 1px solid lightgrey;
}
.myComment:last-child {
border-bottom: none;
}
.assetURL {
font-size: 16px;
color: black;
}
.commentBody {
}
+26
View File
@@ -0,0 +1,26 @@
import React, {PropTypes} from 'react';
import styles from './Comment.css';
const Comment = props => {
return (
<div className={styles.myComment}>
<p className="myCommentAsset">
<a className={`${styles.assetURL} myCommentAnchor`} href={`${props.asset.url}#${props.comment.id}`}>{props.asset.url}</a>
</p>
<p className={`${styles.commentBody} myCommentBody`}>{props.comment.body}</p>
</div>
);
};
Comment.propTypes = {
comment: PropTypes.shape({
id: PropTypes.string,
body: PropTypes.string
}).isRequired,
asset: PropTypes.shape({
url: PropTypes.string
}).isRequired
};
export default Comment;
@@ -0,0 +1,26 @@
import React, {PropTypes} from 'react';
import Comment from './Comment';
import styles from './CommentHistory.css';
const CommentHistory = props => {
return (
<div className={`${styles.header} commentHistory`}>
<div className="commentHistory__list">
{props.comments.map((comment, i) => {
const asset = props.assets.find(asset => asset.id === comment.asset_id);
return <Comment
key={i}
comment={comment}
asset={asset} />;
})}
</div>
</div>
);
};
CommentHistory.propTypes = {
comments: PropTypes.arrayOf(PropTypes.object).isRequired,
assets: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default CommentHistory;
@@ -1,15 +0,0 @@
import React from 'react';
import styles from './CommentHistory.css';
export default ({comments = []}) => (
<div className={styles.header}>
<h1>Comments</h1>
<ul>
{comments.map(() => (
<li>
{/* Comment Data*/}
</li>
))}
</ul>
</div>
);
@@ -1,12 +1,12 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {saveBio} from 'coral-framework/actions/user';
import {saveBio, fetchCommentsByUserId} from 'coral-framework/actions/user';
import BioContainer from './BioContainer';
import NotLoggedIn from '../components/NotLoggedIn';
import {TabBar, Tab, TabContent} from '../../coral-ui';
import CommentHistory from '../components/CommentHistory';
import CommentHistory from 'coral-plugin-history/CommentHistory';
import SettingsHeader from '../components/SettingsHeader';
import RestrictedContent from 'coral-framework/components/RestrictedContent';
@@ -21,7 +21,9 @@ class SignInContainer extends Component {
}
componentWillMount () {
// Fetch commentHistory
this.props.fetchCommentsByUserId(this.props.userData.id);
}
handleTabChange(tab) {
@@ -31,17 +33,34 @@ class SignInContainer extends Component {
}
render() {
const {loggedIn, userData, showSignInDialog} = this.props;
const {loggedIn, userData, showSignInDialog, items, user} = this.props;
const {activeTab} = this.state;
const commentsMostRecentFirst = user
.myComments.map(id => items.comments[id])
.sort(({created_at:a}, {created_at:b}) => {
// descending order, created_at
// js date strings can be sorted lexigraphically.
const aLessThanB = a < b ? 1 : 0;
return a > b ? -1 : aLessThanB;
});
return (
<RestrictedContent restricted={!loggedIn} restrictedComp={<NotLoggedIn showSignInDialog={showSignInDialog} />}>
<SettingsHeader {...this.props} />
<TabBar onChange={this.handleTabChange} activeTab={activeTab} cStyle='material'>
<Tab>All Comments (120)</Tab>
<Tab>All Comments ({user.myComments.length})</Tab>
<Tab>Profile Settings</Tab>
</TabBar>
<TabContent show={activeTab === 0}>
<CommentHistory {...this.props}/>
{
user.myComments.length && user.myAssets.length
? <CommentHistory
comments={commentsMostRecentFirst}
assets={user.myAssets.map(id => items.assets[id])} />
: <p>Loading comment history...</p>
}
</TabContent>
<TabContent show={activeTab === 1}>
<BioContainer bio={userData.settings.bio} handleSave={this.handleSave} {...this.props} />
@@ -51,12 +70,14 @@ class SignInContainer extends Component {
}
}
const mapStateToProps = () => ({
const mapStateToProps = state => ({
items: state.items.toJS(),
user: state.user.toJS()
});
const mapDispatchToProps = dispatch => ({
saveBio: (user_id, formData) => dispatch(saveBio(user_id, formData)),
getHistory: () => dispatch(),
fetchCommentsByUserId: userId => dispatch(fetchCommentsByUserId(userId))
});
export default connect(
+4 -5
View File
@@ -1,5 +1,4 @@
import React, {Component, PropTypes} from 'react';
import ReactDOM from 'react-dom';
import dialogPolyfill from 'dialog-polyfill';
import 'dialog-polyfill/dialog-polyfill.css';
@@ -19,7 +18,7 @@ export default class Dialog extends Component {
};
componentDidMount(){
const dialog = ReactDOM.findDOMNode(this.refs.dialog);
const dialog = this.dialog;
dialogPolyfill.registerDialog(dialog);
if (this.props.open) {
@@ -31,7 +30,7 @@ export default class Dialog extends Component {
}
componentDidUpdate(prevProps) {
const dialog = ReactDOM.findDOMNode(this.refs.dialog);
const dialog = this.dialog;
if (this.props.open !== prevProps.open) {
if (this.props.open) {
dialog.showModal();
@@ -42,7 +41,7 @@ export default class Dialog extends Component {
}
componentWillUnmount() {
const dialog = ReactDOM.findDOMNode(this.refs.dialog);
const dialog = this.dialog;
dialog.removeEventListener('cancel', this.props.onCancel);
}
@@ -51,7 +50,7 @@ export default class Dialog extends Component {
return (
<dialog
ref="dialog"
ref={el => { this.dialog = el; }}
className={`mdl-dialog ${className}`}
{...rest}
>
+1
View File
@@ -7,6 +7,7 @@ const maxRecursion = 3;
* payload response first based on user and role.
*/
module.exports = (req, res, next) => {
/**
* Updates the original document based on filtering out for roles.
* @param {Mixed} o original object to be modified
+10
View File
@@ -152,6 +152,16 @@ AssetSchema.statics.search = (value) => value.length === 0 ? Asset.find({}) : As
}
});
/**
* Finds multiple assets with matching ids
* @param {Array} ids an array of Strings of asset_id
* @return {Promise} resolves to list of Assets
*/
AssetSchema.statics.findMultipleById = function (ids) {
const query = ids.map(id => ({id}));
return Asset.find(query);
};
const Asset = mongoose.model('Asset', AssetSchema);
module.exports = Asset;
+9
View File
@@ -324,6 +324,15 @@ CommentSchema.statics.removeAction = (item_id, user_id, action_type) => Action.r
*/
CommentSchema.statics.all = () => Comment.find();
/**
* Returns all the comments by user
* probably to be paginated at some point in the future
* @return {Promise} array resolves to an array of comments by that user
*/
CommentSchema.statics.findByUserId = function (author_id) {
return Comment.find({author_id});
};
// Comment model.
const Comment = mongoose.model('Comment', CommentSchema);
+10 -1
View File
@@ -88,6 +88,7 @@ const UserSchema = new mongoose.Schema({
// Status provides a string that says in which state the account is.
// When the account is banned, the user login is disabled.
status: {type: String, enum: USER_STATUS, default: 'active'},
// User's settings
settings: {
bio: {
@@ -136,7 +137,13 @@ UserSchema.options.toJSON.transform = (doc, ret, options) => {
*/
UserSchema.method('filterForUser', function(user = false) {
if (!user || !user.roles.includes('admin')) {
return _.pick(this.toJSON(), ['id', 'displayName', 'settings', 'created_at', 'updated_at']);
let allowed = ['id', 'displayName', 'settings', 'created_at', 'updated_at'];
if (user && user.id === this.id) {
allowed.push('roles');
}
return _.pick(this.toJSON(), allowed);
}
return this.toJSON();
@@ -495,6 +502,7 @@ UserService.createPasswordResetToken = function (email) {
.then(user => {
if (user === null) {
// since we don't want to reveal that the email does/doesn't exist
// just go ahead and resolve the Promise with null and check in the endpoint
return Promise.resolve(null);
@@ -522,6 +530,7 @@ UserService.verifyPasswordResetToken = token => {
});
})
.then(decoded => {
/**
* TODO: check the jti from this decoded token in redis
* and make an entry if it does not exist.
+5 -1
View File
@@ -9,7 +9,7 @@
"build-watch": "NODE_ENV=development ./node_modules/.bin/webpack --config webpack.config.dev.js --watch",
"lint": "./node_modules/.bin/eslint bin/* .",
"lint-fix": "./node_modules/.bin/eslint bin/* . --fix",
"test": "NODE_ENV=test ./node_modules/.bin/mocha --compilers js:babel-core/register --recursive tests",
"test": "NODE_ENV=test ./node_modules/.bin/mocha --compilers js:babel-core/register tests/helpers/*.js --require ignore-styles --recursive tests",
"test-watch": "NODE_ENV=test ./node_modules/.bin/mocha --compilers js:babel-core/register --recursive -w tests",
"pree2e": "NODE_ENV=test ./scripts/pree2e.sh",
"e2e": "NODE_ENV=test ./node_modules/.bin/nightwatch",
@@ -92,6 +92,7 @@
"copy-webpack-plugin": "^4.0.0",
"css-loader": "^0.25.0",
"dialog-polyfill": "^0.4.4",
"enzyme": "^2.6.0",
"eslint": "^3.12.1",
"eslint-config-postcss": "^2.0.2",
"eslint-config-standard": "^6.2.1",
@@ -105,8 +106,10 @@
"exports-loader": "^0.6.3",
"fetch-mock": "^5.5.0",
"hammerjs": "^2.0.8",
"ignore-styles": "^5.0.1",
"immutable": "^3.8.1",
"imports-loader": "^0.6.5",
"jsdom": "^9.8.3",
"json-loader": "^0.5.4",
"keymaster": "^1.6.2",
"material-design-lite": "^1.2.1",
@@ -121,6 +124,7 @@
"precss": "^1.4.0",
"pym.js": "^1.1.1",
"react": "15.3.2",
"react-addons-test-utils": "15.3.2",
"react-dom": "15.3.2",
"react-linkify": "^0.1.3",
"react-mdl": "^1.7.2",
+1
View File
@@ -4,6 +4,7 @@ const router = express.Router();
// Get /password-reset expects a signed token (JWT) in the hash.
// Links to this endpoint are generated by /views/password-reset-email.ejs.
router.get('/password-reset', (req, res, next) => {
// TODO: store the redirect uri in the token or something fancy.
// admins and regular users should probably be redirected to different places.
res.render('password-reset', {redirectUri: process.env.TALK_ROOT_URL});
+1
View File
@@ -82,6 +82,7 @@ router.post('/:asset_id/scrape', (req, res, next) => {
});
router.put('/:asset_id/settings', (req, res, next) => {
// Override the settings for the asset.
Asset
.overrideSettings(req.params.asset_id, req.body)
+27 -4
View File
@@ -9,14 +9,27 @@ const _ = require('lodash');
const router = express.Router();
router.get('/', authorization.needed('admin'), (req, res, next) => {
router.get('/', (req, res, next) => {
const {
status = null,
action_type = null,
asset_id = null
asset_id = null,
user_id = null
} = req.query;
// everything on this route requires admin privileges besides listing comments for owner of said comments
if (!authorization.has(req.user, 'admin') && !user_id) {
next(authorization.ErrNotAuthorized);
return;
}
// if the user is not an admin, only return comment list for the owner of the comments
if (req.user.id !== user_id && !authorization.has(req.user, 'admin')) {
next(authorization.ErrNotAuthorized);
return;
}
/**
* This adds the asset_id requirement to the query if the asset_id is defined.
*/
@@ -30,7 +43,12 @@ router.get('/', authorization.needed('admin'), (req, res, next) => {
let query;
if (status) {
// the check for user_id MUST be first here.
// otherwise this will be a vulnerability if you pass user_id and something else,
// the app will return admin-level data without the proper checks
if (user_id) {
query = Comment.findByUserId(user_id);
} else if (status) {
query = assetIDWrap(Comment.findByStatus(status === 'new' ? null : status));
} else if (action_type) {
query = Comment
@@ -47,13 +65,15 @@ router.get('/', authorization.needed('admin'), (req, res, next) => {
query.then((comments) => {
return Promise.all([
comments,
Asset.findMultipleById(comments.map(comment => comment.asset_id)),
User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))),
Action.getActionSummariesFromComments(asset_id, comments, req.user ? req.user.id : false)
]);
})
.then(([comments, users, actions])=>
.then(([comments, assets, users, actions]) =>
res.status(200).json({
comments,
assets,
users,
actions
}))
@@ -87,6 +107,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => {
// Check to see if the asset has closed commenting...
if (asset.isClosed) {
// They have, ensure that we send back an error.
return Promise.reject(new Error(`asset has commenting closed because: ${asset.closedMessage}`));
}
@@ -97,6 +118,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => {
// Return `premod` if pre-moderation is enabled and an empty "new" status
// in the event that it is not in pre-moderation mode.
.then(({moderation, charCountEnable, charCount}) => {
// Reject if the comment is too long
if (charCountEnable && body.length > charCount) {
return 'rejected';
@@ -113,6 +135,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => {
author_id: req.user.id
}))
.then((comment) => {
// The comment was created! Send back the created comment.
res.status(201).json(comment);
})
+2
View File
@@ -25,6 +25,7 @@ router.get('/', (req, res, next) => {
// Get the asset_id for this url (or create it if it doesn't exist)
Promise.all([
// Find or create the asset by url.
Asset.findOrCreateByUrl(asset_url)
@@ -70,6 +71,7 @@ router.get('/', (req, res, next) => {
settings
]);
})
// Get all the users and actions for those comments.
.then(([comments, asset, settings]) => {
+1
View File
@@ -123,6 +123,7 @@ router.post('/request-password-reset', (req, res, next) => {
to: email,
html: resetEmailTemplate({
token,
// probably more clear to explicitly pass this
rootURL: process.env.TALK_ROOT_URL
})
+1
View File
@@ -6,6 +6,7 @@ router.use('/:embed', (req, res, next) => {
case 'stream':
return res.render('embed/stream', {});
default:
// will return a 404.
return next();
}
+1 -1
View File
@@ -2,7 +2,7 @@ const mongoose = require('mongoose');
const debug = require('debug')('talk:db');
const enabled = require('debug').enabled;
//Append '-test' to the db if node_env === 'test'
// Append '-test' to the db if node_env === 'test'
let url = process.env.TALK_MONGO_URL || 'mongodb://localhost/coral-talk';
if (process.env.NODE_ENV === 'test') {
@@ -74,7 +74,7 @@ describe('itemActions', () => {
});
});
//Disabling tests for this function until is is used again.
// Disabling tests for this function until is is used again.
xdescribe('getItemsArray', () => {
const response = {items: [{type: 'comment', id: '123'}, {type: 'comment', id: '456'}]};
const ids = [1, 2];
@@ -0,0 +1,31 @@
import React from 'react';
import {shallow, mount} from 'enzyme';
import {expect} from 'chai';
import Comment from '../../../client/coral-plugin-history/Comment';
describe('coral-plugin-history/Comment', () => {
let render;
const comment = {body: 'this is a comment', id: '123'};
const asset = {url: 'https://google.com'};
beforeEach(() => {
render = shallow(<Comment asset={asset} comment={comment} />);
});
it('should render the provided comment body', () => {
const wrapper = mount(<Comment asset={asset} comment={comment} />);
expect(wrapper.find('.myCommentBody')).to.have.length(1);
expect(wrapper.find('.myCommentBody').text()).to.equal('this is a comment');
});
it('should render the asset url as a link', () => {
const wrapper = mount(<Comment asset={asset} comment={comment} />);
expect(wrapper.find('.myCommentAnchor')).to.have.length(1);
expect(wrapper.find('.myCommentAnchor').text()).to.equal('https://google.com');
expect(wrapper.find('.myCommentAnchor').props().href).to.equal('https://google.com#123');
});
it('should render the comment with styles', () => {
expect(render.props().style).to.be.defined;
});
});
@@ -0,0 +1,24 @@
import React from 'react';
import {shallow, mount} from 'enzyme';
import {expect} from 'chai';
import CommentHistory from '../../../client/coral-plugin-history/CommentHistory';
describe('coral-plugin-history/CommentHistory', () => {
let render;
const comments = [{body: 'a comment or something', 'status_history':[{'type':'premod', 'created_at':'2016-12-09T01:40:53.327Z', 'assigned_by':null}, {'created_at':'2016-12-09T22:52:44.888Z', 'type':'accepted', 'assigned_by':'92256159-1164-4f66-9970-c7f23de7e461'}], 'asset_id':'96fddf96-7c83-4008-80ad-50091997d006', 'created_at':'2016-12-09T01:40:53.360Z', 'author_id':'92256159-1164-4f66-9970-c7f23de7e461', 'status':'accepted', '__v':0, 'updated_at':'2016-12-09T22:52:44.893Z', 'id':'3962c2ea-4ec4-42e4-b9bd-c571ff30f56b'}, {'body':'another comment', 'status_history':[{'type':'premod', 'created_at':'2016-12-09T22:53:43.148Z', 'assigned_by':null}], 'asset_id':'96fddf96-7c83-4008-80ad-50091997d006', 'created_at':'2016-12-09T22:53:43.158Z', 'author_id':'92256159-1164-4f66-9970-c7f23de7e461', 'status':'premod', '__v':0, 'updated_at':'2016-12-09T22:53:43.158Z', 'id':'b51e27af-bcfd-4932-91be-e3f01a4802e6'}, {'body':'can I comment?', 'status_history':[{'type':'premod', 'created_at':'2016-12-13T23:23:47.123Z', 'assigned_by':null}, {'created_at':'2016-12-13T23:23:58.487Z', 'type':'accepted', 'assigned_by':'92256159-1164-4f66-9970-c7f23de7e461'}], 'asset_id':'cef81015-1b53-4d70-b9af-6eca680f22fc', 'created_at':'2016-12-13T23:23:47.131Z', 'author_id':'92256159-1164-4f66-9970-c7f23de7e461', 'status':'accepted', '__v':0, 'updated_at':'2016-12-13T23:23:58.493Z', 'id':'dc9d7be1-b911-4dc3-8e1e-400e8b8d110e'}, {'body':'pre-mod comment', 'status_history':[{'type':'premod', 'created_at':'2016-12-08T21:34:56.994Z', 'assigned_by':null}, {'created_at':'2016-12-08T21:38:04.961Z', 'type':'rejected', 'assigned_by':'92256159-1164-4f66-9970-c7f23de7e461'}], 'asset_id':'96fddf96-7c83-4008-80ad-50091997d006', 'created_at':'2016-12-08T21:34:56.997Z', 'author_id':'92256159-1164-4f66-9970-c7f23de7e461', 'status':'rejected', '__v':0, 'updated_at':'2016-12-08T21:38:04.965Z', 'id':'6f02af16-a8f8-4ead-80ea-0d48824eb74d'}, {'body':'a flagged commetn', 'status_history':[{'type':'premod', 'created_at':'2016-12-08T21:38:26.342Z', 'assigned_by':null}, {'created_at':'2016-12-09T23:47:27.009Z', 'type':'accepted', 'assigned_by':'92256159-1164-4f66-9970-c7f23de7e461'}], 'asset_id':'96fddf96-7c83-4008-80ad-50091997d006', 'created_at':'2016-12-08T21:38:26.344Z', 'author_id':'92256159-1164-4f66-9970-c7f23de7e461', 'status':'accepted', '__v':0, 'updated_at':'2016-12-09T23:47:27.018Z', 'id':'784c5f91-36b9-4bda-b4ca-a114cef2c9f0'}, {'body':'a post mod comment', 'status_history':[{'type':'premod', 'created_at':'2016-12-08T22:19:05.870Z', 'assigned_by':null}, {'created_at':'2016-12-09T23:26:41.427Z', 'type':'accepted', 'assigned_by':'92256159-1164-4f66-9970-c7f23de7e461'}], 'asset_id':'96fddf96-7c83-4008-80ad-50091997d006', 'created_at':'2016-12-08T22:19:05.874Z', 'author_id':'92256159-1164-4f66-9970-c7f23de7e461', 'status':'accepted', '__v':0, 'updated_at':'2016-12-09T23:26:41.450Z', 'id':'e8b86039-f850-4e53-bd9d-f8c9186a9637'}, {'body':'an actual post-mod comment here', 'status_history':[], 'asset_id':'96fddf96-7c83-4008-80ad-50091997d006', 'created_at':'2016-12-08T22:20:11.147Z', 'author_id':'92256159-1164-4f66-9970-c7f23de7e461', 'status':null, '__v':0, 'updated_at':'2016-12-08T22:20:11.147Z', 'id':'cff1a318-50c6-431e-9a63-de7a7b7136bf'}];
const assets = [{'settings': null, 'created_at':'2016-12-06T21:36:09.302Z', 'url':'localhost:3000/', 'scraped':null, 'status':'open', 'updated_at':'2016-12-08T02:11:15.943Z', '_id':'58472f499e775a38f23d5da0', 'type':'article', 'closedMessage':null, 'id':'7302e637-f884-47c0-9723-02cc10a18617', 'closedAt':null}, {'settings':null, 'created_at':'2016-12-07T02:25:31.983Z', 'url':'http://localhost:3000/', 'scraped':null, 'status':'open', 'updated_at':'2016-12-13T22:58:36.061Z', '_id':'5847731b9e775a38f23d5da1', 'type':'article', 'closedMessage':null, 'id':'96fddf96-7c83-4008-80ad-50091997d006', 'closedAt':null}, {'settings':null, 'created_at':'2016-12-12T19:04:05.770Z', 'url':'http://localhost:3000/embed/stream', 'scraped':null, 'updated_at':'2016-12-14T20:13:21.934Z', '_id':'584ef4a59e775a38f23d5e86', 'type':'article', 'closedMessage':null, 'id':'cef81015-1b53-4d70-b9af-6eca680f22fc', 'closedAt':null}];
beforeEach(() => {
render = shallow(<CommentHistory comments={comments} assets={assets} />);
});
it('should render Comments as children when given comments and assets', () => {
const wrapper = mount(<CommentHistory comments={comments} assets={assets} />);
expect(wrapper.find('.commentHistory__list').children()).to.have.length(7);
});
it('should render with styles', () => {
expect(render.props().style).to.be.defined;
});
});
+13 -9
View File
@@ -18,12 +18,13 @@ module.exports = {
client.perform((client, done) => {
mocks.settings({moderation: 'post'})
.then(() => {
//Load Page
// Load Page
client.resizeWindow(1200, 800)
.url(client.globals.baseUrl)
.frame('coralStreamIframe')
//Register and Log In
// Register and Log In
.waitForElementVisible('#commentBox', 1000)
.waitForElementVisible('#coralSignInButton', 2000)
.click('#coralSignInButton')
@@ -44,7 +45,7 @@ module.exports = {
.click('.coral-plugin-commentbox-button')
.waitForElementVisible('.comment', 1000)
//Verify that it appears
// Verify that it appears
.assert.containsText('.comment', mockComment);
done();
})
@@ -58,7 +59,8 @@ module.exports = {
client.perform((client, done) => {
mocks.settings({moderation: 'pre'})
.then(() => {
//Load Page
// Load Page
client.url(client.globals.baseUrl)
.frame('coralStreamIframe');
@@ -68,7 +70,7 @@ module.exports = {
.click('.coral-plugin-commentbox-button')
.waitForElementVisible('#coral-notif', 1000)
//Verify that it appears
// Verify that it appears
.assert.containsText('#coral-notif', 'moderation team');
done();
})
@@ -82,7 +84,8 @@ module.exports = {
client.perform((client, done) => {
mocks.settings({moderation: 'post'})
.then(() => {
//Load Page
// Load Page
client.resizeWindow(1200, 800)
.url(client.globals.baseUrl)
.frame('coralStreamIframe');
@@ -100,7 +103,7 @@ module.exports = {
.click('.coral-plugin-replies-textarea button')
.waitForElementVisible('.reply', 2000)
//Verify that it appears
// Verify that it appears
.assert.containsText('.reply', mockReply);
done();
})
@@ -132,7 +135,8 @@ module.exports = {
}]);
})
.then(() => {
//Load Page
// Load Page
client.resizeWindow(1200, 800)
.url(client.globals.baseUrl)
.frame('coralStreamIframe');
@@ -145,7 +149,7 @@ module.exports = {
.click('.coral-plugin-replies-textarea button')
.waitForElementVisible('#coral-notif', 1000)
//Verify that it appears
// Verify that it appears
.assert.containsText('#coral-notif', 'moderation team');
done();
})
+49
View File
@@ -0,0 +1,49 @@
const jsdom = require('jsdom').jsdom;
const fs = require('fs');
const path = require('path');
// Storage Mock
function storageMock() {
const storage = {};
return {
setItem: function(key, value) {
storage[key] = value || '';
},
getItem: function(key) {
return storage[key] || null;
},
removeItem: function(key) {
delete storage[key];
},
get length() {
return Object.keys(storage).length;
},
key: function(i) {
const keys = Object.keys(storage);
return keys[i] || null;
}
};
}
global.document = jsdom(fs.readFileSync(path.resolve(__dirname, 'index.test.html')));
global.window = document.defaultView;
// these lines are required for react-mdl
global.window.CustomEvent = undefined;
require('react-mdl/extra/material');
global.Element = global.window.Element;
global.navigator = {
userAgent: 'node.js'
};
global.documentRef = document;
global.localStorage = {};
global.sessionStorage = storageMock();
global.XMLHttpRequest = storageMock();
global.Headers = function(headers) {
return headers;
};
File diff suppressed because one or more lines are too long
+23
View File
@@ -85,6 +85,29 @@ describe('/api/v1/comments', () => {
]);
});
it('should return only the owners comments if the user is not an admin', () => {
return chai.request(app)
.get('/api/v1/comments?user_id=456')
.set(passport.inject({id: '456', roles: []}))
.then(res => {
expect(res).to.have.status(200);
expect(res.body.comments).to.have.length(2);
expect(res.body.comments[1]).to.have.property('author_id', '456');
});
});
it('should fail if a non-admin requests comments not owned by them', () => {
return chai.request(app)
.get('/api/v1/comments?user_id=456')
.set(passport.inject({id: '123', roles: []}))
.then((res) => {
expect(res).to.be.empty;
})
.catch((err) => {
expect(err).to.have.property('status', 401);
});
});
it('should return all the comments', () => {
return chai.request(app)
.get('/api/v1/comments')
+2
View File
@@ -3,8 +3,10 @@ const mongoose = require('../../services/mongoose');
// Ensure the NODE_ENV is set to 'test',
// this is helpful when you would like to change behavior when testing.
function clearDB() {
// console.log('Clearing DB', mongoose.connection);
for (let i in mongoose.connection.collections) {
// console.log('Clearing', i);
mongoose.connection.collections[i].remove(function() {});
}