Merge branch 'master' into font-size-docs

This commit is contained in:
Riley Davis
2016-12-22 10:40:30 -07:00
committed by GitHub
63 changed files with 1379 additions and 331 deletions
+7 -8
View File
@@ -1,16 +1,15 @@
{
"sourceMaps": true,
"presets": [
"stage-0",
"es2015"
],
"plugins": [
["transform-decorators-legacy"],
["transform-react-jsx"],
["transform-object-assign"],
["transform-class-properties"],
["transform-async-to-generator"],
["transform-object-rest-spread"],
["transform-class-properties"]
"add-module-exports",
"transform-async-to-generator",
"transform-class-properties",
"transform-decorators-legacy",
"transform-object-assign",
"transform-object-rest-spread",
"transform-react-jsx"
]
}
+23 -36
View File
@@ -1,62 +1,49 @@
# Talk [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk)
A commenting platform from The Coral Project. [https://coralproject.net](https://coralproject.net)
A commenting platform from [The Coral Project](https://coralproject.net).
## Contributing to Talk
### Product Roadmap
You can view what the Coral Team is working on next here: https://www.pivotaltracker.com/n/projects/1863625
You can view product ideas and our longer term roadmap here: https://trello.com/b/ILND751a/talk
You can view what the Coral Team is working on next here https://www.pivotaltracker.com/n/projects/1863625.
### Local Dependencies
Node
You can view product ideas and our longer term roadmap here https://trello.com/b/ILND751a/talk.
Mongo
### Getting Started
`npm install`
Run it once to install the dependencies.
`npm start`
Runs Talk.
## Usage
### Configuration
The Talk application requires specific configuration options to be available
inside the environment in order to run, those variables are listed here:
- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to
- `TALK_MONGO_URL` (*required*) - the database connection string for the MongoDB database.
- `TALK_REDIS_URL` (*required*) - the database connection string for the Redis database.
- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to
secure cookies.
- `TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook
Login enabled app.
- `TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your
Facebook Login enabled app.
- `TALK_ROOT_URL` (*required*) - root url of the installed application externally
- `TALK_ROOT_URL` (*required*) - root url of the installed application externally
available in the format: `<scheme>://<host>` without the path.
- `TALK_SMTP_PROVIDER` (*required*) - SMTP provider name.
- `TALK_SMTP_EMAIL` (*required*) - the address to send emails from using the
SMTP provider.
- `TALK_SMTP_USERNAME` (*required*) - username of the SMTP provider you are using.
- `TALK_SMTP_PASSWORD` (*required*) - password for the SMTP provider you are using.
- `TALK_SMTP_HOST` (*required*) - SMTP host url with format `smtp.domain.com`.
- `TALK_SMTP_PORT` (*required*) - SMTP port.
### Running with Docker
Make sure you have Docker running first and then run `docker-compose up -d`
### Testing
`npm test`
### Lint
`npm run lint`
### Helpful URLs
Comment stream: http://localhost:3000/
Comment stream embedded on sample article: http://localhost:3000/assets/samplearticle.html
Moderator view: http://localhost:3000/admin
### Docs
`swagger.yaml`
### License
**Apache-2.0**
Copyright 2016 Mozilla Foundation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
+32
View File
@@ -0,0 +1,32 @@
{
"name": "The Coral Project: Talk",
"env": {
"TALK_SESSION_SECRET": {
"description": "The session secret",
"generator": "secret"
},
"TALK_FACEBOOK_APP_ID": {
"value": "",
"required": true
},
"TALK_FACEBOOK_APP_SECRET": {
"value": "",
"required": true
},
"NODE_ENV": "production",
"TALK_SMTP_PORT": "2525",
"REWRITE_ENV": "TALK_PORT:PORT,TALK_MONGO_URL:MONGO_URI,TALK_REDIS_URL:REDIS_URL,TALK_SMTP_HOST:POSTMARK_SMTP_SERVER,TALK_SMTP_USERNAME:POSTMARK_API_TOKEN,TALK_SMTP_PASSWORD:POSTMARK_API_TOKEN"
},
"addons": [{
"plan": "mongolab:sandbox",
"as": "MONGO"
}, {
"plan": "rediscloud:30",
"as": "REDIS"
}, {
"plan": "postmark:10k",
"as": "POSTMARK"
}],
"image": "heroku/nodejs",
"success_url": "/admin/setup"
}
+4 -5
View File
@@ -1,10 +1,9 @@
#!/usr/bin/env node
/**
* Setup the debug paramater.
*/
process.env.DEBUG = process.env.TALK_DEBUG;
// Perform rewrites to the runtime environment variables based on the contents
// of the process.env.REWRITE_ENV if it exists. This is done here as it is the
// entrypoint for the entire application.
require('env-rewrite').rewrite();
/**
* Module dependencies.
-6
View File
@@ -1,11 +1,5 @@
#!/usr/bin/env node
/**
* Setup the debug paramater.
*/
process.env.DEBUG = process.env.TALK_DEBUG;
/**
* Module dependencies.
*/
-6
View File
@@ -1,11 +1,5 @@
#!/usr/bin/env node
/**
* Setup the debug paramater.
*/
process.env.DEBUG = process.env.TALK_DEBUG;
/**
* Module dependencies.
*/
-6
View File
@@ -1,11 +1,5 @@
#!/usr/bin/env node
/**
* Setup the debug paramater.
*/
process.env.DEBUG = process.env.TALK_DEBUG;
const app = require('../app');
const debug = require('debug')('talk:server');
const http = require('http');
-6
View File
@@ -1,11 +1,5 @@
#!/usr/bin/env node
/**
* Setup the debug paramater.
*/
process.env.DEBUG = process.env.TALK_DEBUG;
/**
* Module dependencies.
*/
-6
View File
@@ -1,11 +1,5 @@
#!/usr/bin/env node
/**
* Setup the debug paramater.
*/
process.env.DEBUG = process.env.TALK_DEBUG;
/**
* Module dependencies.
*/
+2
View File
@@ -4,6 +4,7 @@ import {Router, Route, IndexRoute, browserHistory} from 'react-router';
import ModerationQueue from 'containers/ModerationQueue/ModerationQueue';
import CommentStream from 'containers/CommentStream/CommentStream';
import Configure from 'containers/Configure/Configure';
import Streams from 'containers/Streams/Streams';
import CommunityContainer from 'containers/Community/CommunityContainer';
import LayoutContainer from 'containers/LayoutContainer';
@@ -13,6 +14,7 @@ const routes = (
<Route path='embed' component={CommentStream} />
<Route path='community' component={CommunityContainer} />
<Route path='configure' component={Configure} />
<Route path='streams' component={Streams} />
</Route>
);
+36
View File
@@ -0,0 +1,36 @@
import {
FETCH_ASSETS_REQUEST,
FETCH_ASSETS_SUCCESS,
FETCH_ASSETS_FAILURE,
UPDATE_ASSET_STATE_REQUEST,
UPDATE_ASSET_STATE_SUCCESS,
UPDATE_ASSET_STATE_FAILURE
} from '../constants/assets';
import coralApi from '../../../coral-framework/helpers/response';
/**
* Action disptacher related to assets
*/
// Fetch a page of assets
// Get comments to fill each of the three lists on the mod queue
export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filter = '') => (dispatch) => {
dispatch({type: FETCH_ASSETS_REQUEST});
return coralApi(`/assets?skip=${skip}&limit=${limit}&sort=${sort}&search=${search}&filter=${filter}`)
.then(({result, count}) =>
dispatch({type: FETCH_ASSETS_SUCCESS,
assets: result,
count
}))
.catch(error => dispatch({type: FETCH_ASSETS_FAILURE, error}));
};
// Update an asset state
// Get comments to fill each of the three lists on the mod queue
export const updateAssetState = (id, closedAt) => (dispatch) => {
dispatch({type: UPDATE_ASSET_STATE_REQUEST});
return coralApi(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}})
.then(() =>
dispatch({type: UPDATE_ASSET_STATE_SUCCESS}))
.catch(error => dispatch({type: UPDATE_ASSET_STATE_FAILURE, error}));
};
+53 -14
View File
@@ -1,30 +1,69 @@
import coralApi from '../../../coral-framework/helpers/response';
import * as commentActions from '../constants/comments';
// Get comments to fill each of the three lists on the mod queue
export const fetchModerationQueueComments = () => {
return dispatch => {
dispatch({type: commentActions.COMMENTS_MODERATION_QUEUE_FETCH});
return Promise.all([
coralApi('/queue/comments/pending'),
coralApi('/comments?status=rejected'),
coralApi('/comments?action_type=flag')
])
.then(([pending, rejected, flagged]) => {
/* Combine seperate calls into a single object */
flagged.comments.forEach(comment => comment.flagged = true);
return {
comments: [...pending.comments, ...rejected.comments, ...flagged.comments],
users: [...pending.users, ...rejected.users, ...flagged.users],
actions: [...pending.actions, ...rejected.actions, ...flagged.actions]
};
})
.then(({comments, users}) => {
/* Post comments and users to redux store. Actions will be posted when they are needed. */
dispatch({type: commentActions.USERS_MODERATION_QUEUE_FETCH_SUCCESS, users});
dispatch({type: commentActions.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS, comments});
});
};
};
// Create a new comment
export const createComment = (name, body) => {
return dispatch => {
const comment = {body, name};
return coralApi('/comments', {method: 'POST', comment})
.then(res => dispatch({type: commentActions.COMMENT_CREATE_SUCCESS, comment: res}))
.catch(error => dispatch({type: commentActions.COMMENT_CREATE_FAILED, error}));
};
};
/**
* Action disptacher related to comments
*/
export const updateStatus = (status, id) => (dispatch, getState) => {
dispatch({type: 'COMMENT_STATUS_UPDATE', id, status});
dispatch({type: 'COMMENT_UPDATE', comment: getState().comments.get('byId').get(id)});
// Update a comment. Now to update a comment we need to send back the whole object
export const updateStatus = (status, comment) => {
return dispatch => {
dispatch({type: commentActions.COMMENT_STATUS_UPDATE, id: comment.id, status});
return coralApi(`/comments/${comment.id}/status`, {method: 'PUT', body: {status}})
.then(res => dispatch({type: commentActions.COMMENT_UPDATE_SUCCESS, res}))
.catch(error => dispatch({type: commentActions.COMMENT_UPDATE_FAILED, error}));
};
};
export const flagComment = id => (dispatch, getState) => {
dispatch({type: 'COMMENT_FLAG', id});
dispatch({type: commentActions.COMMENT_FLAG, id});
dispatch({type: 'COMMENT_UPDATE', comment: getState().comments.get('byId').get(id)});
};
export const createComment = (name, body) => dispatch => {
dispatch({type: 'COMMENT_CREATE', name, body});
};
// Dialog Actions
export const showBanUserDialog = (userId, userName, commentId) => {
return dispatch => {
dispatch({type: 'SHOW_BANUSER_DIALOG', userId, userName, commentId});
};
return {type: commentActions.SHOW_BANUSER_DIALOG, userId, userName, commentId};
};
export const hideBanUserDialog = (showDialog) => {
return dispatch => {
dispatch({type: 'HIDE_BANUSER_DIALOG', showDialog});
};
return {type: commentActions.HIDE_BANUSER_DIALOG, showDialog};
};
+8 -7
View File
@@ -1,14 +1,15 @@
import coralApi from '../../../coral-framework/helpers/response';
import * as actions from '../constants/user';
/**
* Action disptacher related to users
*/
//
// export const banUser = (status, author_id) => (dispatch) => {
// dispatch({type: 'USER_STATUS_UPDATE', author_id, status});
// };
export const banUser = (status, userId, commentId) => {
// change status of a user
export const userStatusUpdate = (status, userId, commentId) => {
return dispatch => {
dispatch({type: 'USER_BAN', status, userId, commentId});
dispatch({type: 'COMMENTS_MODERATION_QUEUE_FETCH'});
dispatch({type: actions.UPDATE_STATUS_REQUEST});
return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
.then(res => dispatch({type: actions.UPDATE_STATUS_SUCCESS, res}))
.catch(error => dispatch({type: actions.UPDATE_STATUS_FAILURE, error}));
};
};
+8 -6
View File
@@ -30,7 +30,7 @@ export default props => {
<div>
{links ?
<span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
<div className={styles.actions}>
<div className={`actions ${styles.actions}`}>
{props.actions.map((action, i) => getActionButton(action, i, props))}
</div>
</div>
@@ -63,21 +63,23 @@ const getActionButton = (action, i, props) => {
if (action === 'ban') {
return (
<Button
disabled={banned ? 'disabled' : ''}
className='ban'
cStyle='black'
disabled={banned ? 'disabled' : ''}
onClick={() => props.onClickShowBanDialog(author.id, author.displayName, comment.id)}
key={i} >
key={i}
>
{lang.t('comment.ban_user')}
</Button>
);
}
return (
<FabButton
className={styles.actionButton}
icon={props.actionsMap[action].icon}
className={`${action} ${styles.actionButton}`}
cStyle={action}
icon={props.actionsMap[action].icon}
key={i}
onClick={() => props.onClickAction(props.actionsMap[action].status, comment.id)}
onClick={() => props.onClickAction(props.actionsMap[action].status, comment)}
/>
);
};
@@ -126,7 +126,10 @@ export default class CommentList extends React.Component {
const {active} = this.state;
return (
<ul className={`${styles.list} ${singleView ? styles.singleView : ''}`} {...key}>
<ul
className={`${styles.list} ${singleView ? styles.singleView : ''}`} {...key}
id='commentList'
>
{commentIds.map((commentId, index) => {
const comment = comments[commentId];
const author = users[comment.author_id];
@@ -16,6 +16,8 @@ export default ({handleLogout}) => (
activeClassName={styles.active}>{lang.t('configure.community')}</Link>
<Link className={styles.navLink} to="/admin/configure"
activeClassName={styles.active}>{lang.t('configure.configure')}</Link>
<Link className={styles.navLink} to="/admin/streams"
activeClassName={styles.active}>{lang.t('configure.streams')}</Link>
</Navigation>
<div className={styles.rightPanel}>
<ul>
@@ -0,0 +1,6 @@
export const FETCH_ASSETS_REQUEST = 'FETCH_ASSETS_REQUEST';
export const FETCH_ASSETS_SUCCESS = 'FETCH_ASSETS_SUCCESS';
export const FETCH_ASSETS_FAILURE = 'FETCH_ASSETS_FAILURE';
export const UPDATE_ASSET_STATE_REQUEST = 'UPDATE_ASSET_STATE_REQUEST';
export const UPDATE_ASSET_STATE_SUCCESS = 'UPDATE_ASSET_STATE_SUCCESS';
export const UPDATE_ASSET_STATE_FAILURE = 'UPDATE_ASSET_STATE_FAILURE';
+10 -1
View File
@@ -1,3 +1,12 @@
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
export const USER_BAN_SUCESS = 'USER_BAN_SUCESS';
export const USERS_MODERATION_QUEUE_FETCH_SUCCESS = 'USERS_MODERATION_QUEUE_FETCH_SUCCESS';
export const COMMENTS_MODERATION_QUEUE_FETCH = 'COMMENTS_MODERATION_QUEUE_FETCH';
export const COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS = 'COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS';
export const COMMENT_CREATE_SUCCESS = 'COMMENT_CREATE_SUCCESS';
export const COMMENT_CREATE_FAILED = 'COMMENT_CREATE_FAILED';
export const COMMENT_STREAM_FETCH_SUCCESS = 'COMMENT_STREAM_FETCH_SUCCESS';
export const COMMENT_UPDATE_SUCCESS = 'COMMENT_UPDATE_SUCCESS';
export const COMMENT_UPDATE_FAILED = 'COMMENT_UPDATE_FAILED';
export const COMMENT_STATUS_UPDATE = 'COMMENT_STATUS_UPDATE';
export const COMMENT_FLAG = 'COMMENT_FLAG';
+3
View File
@@ -0,0 +1,3 @@
export const UPDATE_STATUS_REQUEST = 'UPDATE_STATUS_REQUEST';
export const UPDATE_STATUS_SUCCESS = 'UPDATE_STATUS_SUCCESS';
export const UPDATE_STATUS_FAILURE = 'UPDATE_STATUS_FAILURE';
@@ -7,7 +7,7 @@ import styles from './Community.css';
import Table from './Table';
import Loading from './Loading';
import NoResults from './NoResults';
import Pager from './Pager';
import Pager from 'coral-ui/components/Pager';
const lang = new I18n(translations);
@@ -71,6 +71,7 @@ const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => {
const CommentSettings = ({fetchingSettings, updateSettings, settingsError, settings, errors}) => {
if (fetchingSettings) {
/* maybe a spinner here at some point */
return <p>Loading settings...</p>;
}
@@ -1,14 +1,18 @@
import React from 'react';
import {connect} from 'react-redux';
import {Icon} from 'react-mdl';
import key from 'keymaster';
import ModerationKeysModal from 'components/ModerationKeysModal';
import CommentList from 'components/CommentList';
import BanUserDialog from 'components/BanUserDialog';
import {updateStatus, showBanUserDialog, hideBanUserDialog} from 'actions/comments';
import {banUser} from 'actions/users';
import {
updateStatus,
showBanUserDialog,
hideBanUserDialog,
fetchModerationQueueComments
} from 'actions/comments';
import {userStatusUpdate} from 'actions/users';
import styles from './ModerationQueue.css';
import I18n from 'coral-framework/modules/i18n/i18n';
@@ -32,7 +36,7 @@ class ModerationQueue extends React.Component {
// Fetch comments and bind singleView key before render
componentWillMount () {
this.props.dispatch({type: 'COMMENTS_MODERATION_QUEUE_FETCH'});
this.props.dispatch(fetchModerationQueueComments());
key('s', () => this.setState({singleView: !this.state.singleView}));
key('shift+/', () => this.setState({modalOpen: true}));
key('esc', () => this.setState({modalOpen: false}));
@@ -55,10 +59,10 @@ class ModerationQueue extends React.Component {
}
// Dispatch the update status action
onCommentAction (action, id) {
onCommentAction (action, comment) {
// If not banning then change the status to approved or flagged as action = status
this.props.dispatch(updateStatus(action, id));
this.props.dispatch(updateStatus(action, comment));
}
showBanUserDialog (userId, userName, commentId) {
@@ -70,11 +74,10 @@ class ModerationQueue extends React.Component {
}
banUser (userId, commentId) {
this.props.dispatch(banUser('banned', userId, commentId));
}
showShortcuts = () => {
this.setState({modalOpen: true});
this.props.dispatch(userStatusUpdate('banned', userId, commentId))
.then(() => {
this.props.dispatch(fetchModerationQueueComments());
});
}
onTabClick (activeTab) {
@@ -100,11 +103,6 @@ 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
@@ -113,7 +111,7 @@ class ModerationQueue extends React.Component {
commentIds={premodIds}
comments={comments.byId}
users={users.byId}
onClickAction={(action, commentId) => this.onCommentAction(action, commentId)}
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
onClickShowBanDialog={(userId, userName, commentId) => this.showBanUserDialog(userId, userName, commentId)}
actions={['reject', 'approve', 'ban']}
loading={comments.loading} />
@@ -122,7 +120,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'}
@@ -130,29 +128,26 @@ class ModerationQueue extends React.Component {
commentIds={rejectedIds}
comments={comments.byId}
users={users.byId}
onClickAction={(action, id) => this.onCommentAction(action, id)}
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
actions={['approve']}
loading={comments.loading} />
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='flagged'>
<CommentList
isActive={activeTab === 'flagged'}
isActive={activeTab === 'rejected'}
singleView={singleView}
commentIds={flaggedIds}
comments={comments.byId}
users={users.byId}
onClickAction={(action, id) => this.onCommentAction(action, id)}
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
actions={['reject', 'approve']}
loading={comments.loading} />
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='shortcuts'>
<ModerationKeysModal open={modalOpen}
onClose={() => this.setState({modalOpen: false})} />
</div>
<ModerationKeysModal open={modalOpen}
onClose={() => this.setState({modalOpen: false})} />
</div>
</div>
);
}
}
@@ -0,0 +1,83 @@
.container {
padding: 10px;
display: flex;
}
.leftColumn {
width: 200px;
}
.mainContent {
width: calc(90% - 200px);
}
.searchIcon {
vertical-align: middle;
font-size: 18px;
color: #ccc;
}
.searchBox {
padding: 3px;
border: 1px solid #ccc;
border-radius: 3px;
width: 90%;
display: flex;
}
.searchBoxInput {
border: none;
flex: 1;
font-size: 14px;
}
.searchBoxInput:focus {
outline: none;
}
.optionHeader {
font-size: 16px;
font-weight: 900;
margin-top: 15px;
}
.optionDetail {
font-size: 16px;
margin-top: 3px;
}
.streamsTable {
width: 100%;
}
.radio {
display: block;
}
.statusMenu {
border-radius: 3px;
width: 10em;
text-align: center;
float: right;
border: 1px solid #ccc;
color: #fff;
cursor: pointer;
}
.statusMenuOpen {
padding: 10px;
background-color: #4caf50;
}
.statusMenuIcon {
float: right;
}
.statusMenuClosed {
padding: 10px;
background-color: #000;
}
.hidden {
display: none;
}
@@ -0,0 +1,180 @@
import React, {Component} from 'react';
import styles from './Streams.css';
import {connect} from 'react-redux';
import I18n from 'coral-framework/modules/i18n/i18n';
import {fetchAssets, updateAssetState} from '../../actions/assets';
import translations from '../../translations.json';
import {
RadioGroup,
Radio,
Icon,
DataTable,
TableHeader
} from 'react-mdl';
import Pager from 'coral-ui/components/Pager';
const limit = 25;
class Streams extends Component {
state = {
search: '',
sort: 'desc',
filter: 'all',
statusMenus: {},
timer: null,
page: 0
}
componentDidMount () {
this.props.fetchAssets(0, limit, '', this.state.sortBy);
}
onSettingChange = (setting) => (e) => {
let options = this.state;
this.setState({[setting]: e.target.value});
options[setting] = e.target.value;
this.props.fetchAssets(0, limit, options.search, options.sort, options.filter);
}
onSearchChange = (e) => {
this.setState((prevState) => {
prevState.search = e.target.value;
clearTimeout(prevState.timer);
const fetchAssets = this.props.fetchAssets;
prevState.timer = setTimeout(() => {
fetchAssets(0, limit, e.target.value, this.state.sort, this.state.filter);
}, 350);
return prevState;
});
}
renderDate = (date) => {
const d = new Date(date);
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
}
onStatusClick = (closeStream, id, statusMenuOpen) => () => {
if (statusMenuOpen) {
this.setState(prev => {
prev.statusMenus[id] = false;
return prev;
});
this.props.updateAssetState(id, closeStream ? Date.now() : null)
.then(() => {
const {search, sort, filter, page} = this.state;
this.props.fetchAssets(page, limit, search, sort, filter);
});
} else {
this.setState(prev => {
prev.statusMenus[id] = true;
return prev;
});
}
}
renderStatus = (closedAt, {id}) => {
const closed = closedAt && new Date(closedAt).getTime() < Date.now();
const statusMenuOpen = this.state.statusMenus[id];
return <div className={styles.statusMenu}>
<div
className={closed ? styles.statusMenuClosed : styles.statusMenuOpen}
onClick={this.onStatusClick(closed, id, statusMenuOpen)}>
{closed ? lang.t('streams.closed') : lang.t('streams.open')}
{!statusMenuOpen && <Icon className={styles.statusMenuIcon} name='keyboard_arrow_down'/>}
</div>
{
statusMenuOpen &&
<div
className={!closed ? styles.statusMenuClosed : styles.statusMenuOpen}
onClick={this.onStatusClick(!closed, id, statusMenuOpen)}>
{!closed ? lang.t('streams.closed') : lang.t('streams.open')}
</div>
}
</div>;
}
onPageClick = (page) => {
this.setState({page});
const {search, sort, filter} = this.state;
this.props.fetchAssets((page - 1) * limit, limit, search, sort, filter);
}
render () {
const {search, sort, filter} = this.state;
const {assets} = this.props;
return <div className={styles.container}>
<div className={styles.leftColumn}>
<div className={styles.searchBox}>
<Icon name='search' className={styles.searchIcon}/>
<input
type='text'
value={search}
className={styles.searchBoxInput}
onChange={this.onSearchChange}
placeholder={lang.t('streams.search')}/>
</div>
<div className={styles.optionHeader}>{lang.t('streams.filter-streams')}</div>
<div className={styles.optionDetail}>{lang.t('streams.stream-status')}</div>
<RadioGroup
name='status filter'
value={filter}
childContainer='div'
onChange={this.onSettingChange('filter')}>
<Radio value='all'>{lang.t('streams.all')}</Radio>
<Radio value='open'>{lang.t('streams.open')}</Radio>
<Radio value='closed'>{lang.t('streams.closed')}</Radio>
</RadioGroup>
<div className={styles.optionHeader}>{lang.t('streams.sort-by')}</div>
<RadioGroup
name='sort by'
value={sort}
childContainer='div'
onChange={this.onSettingChange('sort')}>
<Radio value='desc'>{lang.t('streams.newest')}</Radio>
<Radio value='asc'>{lang.t('streams.oldest')}</Radio>
</RadioGroup>
</div>
<div className={styles.mainContent}>
<DataTable
className={styles.streamsTable}
rows={assets.ids.map((id) => assets.byId[id])}>
<TableHeader name="title">{lang.t('streams.article')}</TableHeader>
<TableHeader numeric name="publication_date" cellFormatter={this.renderDate}>
{lang.t('streams.pubdate')}
</TableHeader>
<TableHeader numeric name="closedAt" cellFormatter={this.renderStatus}>
{lang.t('streams.status')}
</TableHeader>
</DataTable>
<Pager
totalPages={Math.ceil((assets.count || 0) / limit)}
page={this.state.page}
onNewPageHandler={this.onPageClick}
/>
</div>
</div>;
}
}
const mapStateToProps = ({assets}) => {
return {
assets: assets.toJS()
};
};
const mapDispatchToProps = (dispatch) => {
return {
fetchAssets: (...args) => {
dispatch(fetchAssets.apply(this, args));
},
updateAssetState: (...args) => dispatch(updateAssetState.apply(this, args))
};
};
export default connect(mapStateToProps, mapDispatchToProps)(Streams);
const lang = new I18n(translations);
+24
View File
@@ -0,0 +1,24 @@
import {Map, List, fromJS} from 'immutable';
import {FETCH_ASSETS_SUCCESS, UPDATE_ASSET_STATE_REQUEST} from '../constants/assets';
const initialState = Map({
byId: Map(),
ids: List()
});
export default (state = initialState, action) => {
switch (action.type) {
case FETCH_ASSETS_SUCCESS:
return replaceAssets(action, state);
case UPDATE_ASSET_STATE_REQUEST:
return state.setIn(['byId', action.id, 'closedAt'], action.closedAt);
default: return state;
}
};
const replaceAssets = (action, state) => {
const assets = fromJS(action.assets.reduce((prev, curr) => { prev[curr.id] = curr; return prev; }, {}));
return state.set('byId', assets)
.set('count', action.count)
.set('ids', List(assets.keys()));
};
+10 -8
View File
@@ -1,4 +1,5 @@
import * as actions from '../constants/comments';
import * as userActions from '../constants/user';
import {Map, List, fromJS} from 'immutable';
/**
@@ -23,16 +24,17 @@ const initialState = Map({
// Handle the comment actions
export default (state = initialState, action) => {
switch (action.type) {
case 'COMMENTS_MODERATION_QUEUE_FETCH': return state.set('loading', true);
case 'COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS': return replaceComments(action, state);
case 'COMMENTS_MODERATION_QUEUE_FAILED': return state.set('loading', false);
case 'COMMENT_STATUS_UPDATE': return updateStatus(state, action);
case 'COMMENT_FLAG': return flag(state, action);
case 'COMMENT_CREATE_SUCCESS': return addComment(state, action);
case 'COMMENT_STREAM_FETCH_SUCCESS': return replaceComments(action, state);
case actions.COMMENTS_MODERATION_QUEUE_FETCH: return state.set('loading', true);
case actions.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS: return replaceComments(action, state);
case actions.COMMENTS_MODERATION_QUEUE_FAILED: return state.set('loading', false);
case actions.COMMENT_STATUS_UPDATE: return updateStatus(state, action);
case actions.COMMENT_FLAG: return flag(state, action);
case actions.COMMENT_CREATE_SUCCESS: return addComment(state, action);
case actions.COMMENT_STREAM_FETCH_SUCCESS: return replaceComments(action, state);
case actions.SHOW_BANUSER_DIALOG: return setBanUser(state, true, action);
case actions.HIDE_BANUSER_DIALOG: return setBanUser(state, false, action);
case actions.USER_BAN_SUCESS: return setBanUser(state, false, action);
case actions.USER_BAN_SUCCESS: return setBanUser(state, false, action);
case userActions.UPDATE_STATUS_SUCCESS: return setBanUser(state, false, action);
default: return state;
}
};
+3 -1
View File
@@ -4,6 +4,7 @@ import settings from 'reducers/settings';
import community from 'reducers/community';
import users from 'reducers/users';
import auth from 'reducers/auth';
import assets from 'reducers/assets';
// Combine all reducers into a main one
export default combineReducers({
@@ -11,5 +12,6 @@ export default combineReducers({
comments,
community,
auth,
users
users,
assets
});
+1 -2
View File
@@ -2,7 +2,6 @@
import {createStore, applyMiddleware} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from 'reducers';
import talkAdapter from 'services/talk-adapter';
/**
* Create the store by merging the app reducers with
@@ -14,5 +13,5 @@ import talkAdapter from 'services/talk-adapter';
export default createStore(
mainReducer,
window.devToolsExtension && window.devToolsExtension(),
applyMiddleware(thunk, talkAdapter)
applyMiddleware(thunk)
);
@@ -1,96 +0,0 @@
import coralApi from '../../../coral-framework/helpers/response';
/**
* The adapter is a redux middleware that interecepts the actions that need
* to interface with the backend, do the job and return the results.
* The idea is that if we expose the required actions to handle to devs, the
* moderation app can be platform agnostic. This same client could work not only
* for the coral but also for wordpress comments, disqus and many more.
*/
// Intercept redux actions and act over the ones we are interested
export default store => next => action => {
switch (action.type) {
case 'COMMENTS_MODERATION_QUEUE_FETCH':
fetchModerationQueueComments(store);
break;
case 'COMMENT_UPDATE':
updateComment(store, action.comment);
break;
case 'COMMENT_CREATE':
createComment(store, action.name, action.body);
break;
case 'USER_BAN':
userStatusUpdate(store, action.status, action.userId, action.commentId);
break;
}
next(action);
};
// Get comments to fill each of the three lists on the mod queue
const fetchModerationQueueComments = store =>
Promise.all([
coralApi('/queue/comments/pending'),
coralApi('/comments?status=rejected'),
coralApi('/comments?action_type=flag')
])
.then(([pending, rejected, flagged]) => {
/* Combine seperate calls into a single object */
let all = {};
all.comments = pending.comments
.concat(rejected.comments)
.concat(flagged.comments.map(comment => {
comment.flagged = true;
return comment;
}));
all.users = pending.users
.concat(rejected.users)
.concat(flagged.users);
all.actions = pending.actions
.concat(rejected.actions)
.concat(flagged.actions);
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});
store.dispatch({type: 'COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS',
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
const updateComment = (store, comment) => {
coralApi(`/comments/${comment.get('id')}/status`, {method: 'PUT', body: {status: comment.get('status')}})
.then(res => store.dispatch({type: 'COMMENT_UPDATE_SUCCESS', res}))
.catch(error => store.dispatch({type: 'COMMENT_UPDATE_FAILED', error}));
};
// Create a new comment
const createComment = (store, name, comment) => {
const body = {
status: 'Untouched',
body: comment,
name: name,
createdAt: Date.now()
};
return coralApi('/comments', {method: 'POST', body})
.then(res => store.dispatch({type: 'COMMENT_CREATE_SUCCESS', comment: res}))
.catch(error => store.dispatch({type: 'COMMENT_CREATE_FAILED', error}));
};
// Ban a user
const userStatusUpdate = (store, status, userId, commentId) => {
return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
.then(res => store.dispatch({type: 'USER_BAN_SUCESS', res}))
.catch(error => store.dispatch({type: 'USER_BAN_FAILED', error}));
};
+33
View File
@@ -56,6 +56,7 @@
"moderate": "Moderate",
"configure": "Configure",
"community": "Community",
"streams": "Streams",
"closed-comments-desc": "Write a message for closed threads",
"closed-comments-label": "Write a message...",
"hours": "Hours",
@@ -73,6 +74,22 @@
"note": "Note: Banning this user will also place this comment in the Rejected queue.",
"cancel": "Cancel",
"yes_ban_user": "Yes, Ban User"
},
"streams": {
"search": "Search",
"filter-streams": "Filter Streams",
"stream-status": "Stream Status",
"all": "All",
"open": "Open",
"closed": "Closed",
"newest": "Newest",
"oldest": "Oldest",
"sort-by": "Sort By",
"open": "Open",
"closed": "Closed",
"article": "Article",
"pubdate": "Publication Date",
"status": "Status"
}
},
"es": {
@@ -139,6 +156,22 @@
"note": "Nota: Suspender este usuario también va a colocar este comentario en la cola de Rechazados.",
"cancel": "Cancelar",
"yes_ban_user": "Si, Suspendan el usuario"
},
"streams": {
"search": "",
"filter-streams": "",
"stream-status": "",
"all": "",
"open": "",
"closed": "",
"newest": "",
"oldest": "",
"sort-by": "",
"open": "",
"closed": "",
"article": "",
"pubdate": "",
"status": ""
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
const name = 'coral-plugin-flags';
const name = 'coral-plugin-likes';
const LikeButton = ({like, id, postAction, deleteAction, addItem, showSignInDialog, updateItem, currentUser, banned}) => {
const liked = like && like.current_user;
@@ -9,10 +9,11 @@ import ForgotContent from './ForgotContent';
const SignDialog = ({open, view, handleClose, offset, ...props}) => (
<Dialog
className={styles.dialog}
id="signInDialog"
open={open}
style={{
position: 'relative',
top: offset !== 0 && offset
top: offset !== 0 && offset
}}>
<span className={styles.close} onClick={handleClose}>×</span>
{view === 'SIGNIN' && <SignInContent {...props} />}
+3 -1
View File
@@ -9,7 +9,9 @@ const UserBox = ({className, user, logout, ...props}) => (
className={`${styles.userBox} ${className ? className : ''}`}
{...props}
>
{lang.t('signIn.loggedInAs')} <a>{user.displayName}</a>. {lang.t('signIn.notYou')} <a onClick={logout}>{lang.t('signIn.logout')}</a>
{lang.t('signIn.loggedInAs')}
<a>{user.displayName}</a>. {lang.t('signIn.notYou')}
<a onClick={logout} id='logout'>{lang.t('signIn.logout')}</a>
</div>
);
@@ -113,6 +113,7 @@ input.error{
font-weight: bold;
cursor: pointer;
margin: 0px;
margin-left: 4px;
padding-bottom: 2px;
border-bottom: solid 1px black;
}
@@ -12,7 +12,7 @@ const Pager = ({totalPages, page, onNewPageHandler}) => (
<div className="pager">
<ul>
{
(totalPages > page) ?
(totalPages > page && totalPages > 1) ?
<li
className={`mdl-button mdl-js-button ${styles.li}`}
onClick={() => onNewPageHandler(page - 1)}>
@@ -23,7 +23,7 @@ const Pager = ({totalPages, page, onNewPageHandler}) => (
}
{Rows(page, totalPages, onNewPageHandler)}
{
(page < totalPages) ?
(page < totalPages && totalPages > 1) ?
<li
className={`mdl-button mdl-js-button ${styles.li}`}
onClick={() => onNewPageHandler(page + 1)}>
@@ -42,4 +42,3 @@ Pager.propTypes = {
};
export default Pager;
+17 -8
View File
@@ -19,6 +19,7 @@ module.exports = {
},
'test_settings': {
'default': {
'launch_url' : 'http://localhost:3000',
'selenium_port': 6666,
'selenium_host': 'localhost',
'silent': true,
@@ -26,19 +27,27 @@ module.exports = {
'browserName': 'chrome',
'javascriptEnabled': true,
'acceptSslCerts': true,
'webStorageEnabled' : true,
'databaseEnabled' : true,
'applicationCacheEnabled' : false,
'nativeEvents' : true
'webStorageEnabled': true,
'databaseEnabled': true,
'applicationCacheEnabled': false,
'nativeEvents': true
},
'screenshots' : {
'enabled' : true,
'on_failure' : true,
'on_error' : true,
'path' : './tests/e2e/reports'
'enabled': true,
'on_failure': true,
'on_error': true,
'path': './tests/e2e/reports'
},
'exclude': [
'./tests/e2e/tests/EmbedStreamTests.js'
]
},
'integration': {
'launch_url': 'http://localhost:3000'
}
}
};
// "chromeOptions" : {
// "args" : ["start-fullscreen"]
// }
+15 -12
View File
@@ -5,15 +5,16 @@
"main": "app.js",
"scripts": {
"start": "./bin/cli serve --jobs",
"build": "NODE_ENV=production ./node_modules/.bin/webpack --config webpack.config.js --bail",
"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 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",
"embed-start": "NODE_ENV=development npm run build && ./bin/cli serve --jobs"
"build": "NODE_ENV=production webpack --config webpack.config.js --bail",
"build-watch": "NODE_ENV=development webpack --config webpack.config.dev.js --watch",
"lint": "eslint bin/* .",
"lint-fix": "eslint bin/* . --fix",
"test": "NODE_ENV=test mocha --compilers js:babel-core/register tests/helpers/*.js --require ignore-styles --recursive tests",
"test-watch": "NODE_ENV=test mocha --compilers js:babel-core/register --recursive -w tests",
"pree2e": "NODE_ENV=test scripts/pree2e.sh",
"e2e": "NODE_ENV=test nightwatch",
"embed-start": "NODE_ENV=development npm run build && ./bin/cli serve --jobs",
"postinstall": "npm run build"
},
"config": {
"pre-git": {
@@ -53,6 +54,7 @@
"connect-redis": "^3.1.0",
"debug": "^2.2.0",
"ejs": "^2.5.2",
"env-rewrite": "^1.0.2",
"express": "^4.14.0",
"express-session": "^1.14.2",
"helmet": "^3.1.0",
@@ -74,10 +76,11 @@
},
"devDependencies": {
"autoprefixer": "^6.5.2",
"babel-core": "^6.18.2",
"babel-core": "^6.21.0",
"babel-eslint": "^7.1.0",
"babel-jest": "^15.0.0",
"babel-loader": "^6.2.7",
"babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-transform-async-to-generator": "^6.16.0",
"babel-plugin-transform-class-properties": "^6.18.0",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
@@ -115,7 +118,7 @@
"material-design-lite": "^1.2.1",
"mocha": "^3.1.2",
"mocha-junit-reporter": "^1.12.1",
"nightwatch": "^0.9.9",
"nightwatch": "^0.9.11",
"node-fetch": "^1.6.3",
"postcss-loader": "^1.1.0",
"postcss-modules": "^0.5.2",
@@ -136,7 +139,7 @@
"redux-mock-store": "^1.2.1",
"redux-thunk": "^2.1.0",
"regenerator": "^0.8.46",
"selenium-standalone": "^5.8.0",
"selenium-standalone": "latest",
"style-loader": "^0.13.1",
"supertest": "^2.0.1",
"timeago.js": "^2.0.3",
+35 -7
View File
@@ -12,18 +12,47 @@ router.get('/', (req, res, next) => {
skip = 0,
sort = 'asc',
field = 'created_at',
filter = 'all',
search = ''
} = req.query;
const FilterOpenAssets = (query, filter) => {
switch(filter) {
case 'open':
return query.merge({
$or: [
{
closedAt: null
},
{
closedAt: {
$gt: Date.now()
}
}
]
});
case 'closed':
return query.merge({
closedAt: {
$lt: Date.now()
}
});
default:
return query;
}
};
// Find all the assets.
Promise.all([
Asset
.search(search)
// Find the actuall assets.
FilterOpenAssets(Asset.search(search), filter)
.sort({[field]: (sort === 'asc') ? 1 : -1})
.skip(skip)
.limit(limit),
Asset
.search(search)
.skip(parseInt(skip))
.limit(parseInt(limit)),
// Get the count of actual assets.
FilterOpenAssets(Asset.search(search), filter)
.count()
])
.then(([result, count]) => {
@@ -107,7 +136,6 @@ router.put('/:asset_id/status', (req, res, next) => {
}
})
.then(() => {
res.status(204).json();
})
.catch((err) => {
+11 -3
View File
@@ -1,7 +1,15 @@
#!/bin/bash
# install selenium
../node_modules/selenium-standalone/bin/selenium-standalone install
selenium-standalone install
# start the app server
npm start &
# Creating Admin Test User
{ echo admin@test.com; echo test; echo test; echo Admin Test User; echo admin;} | dotenv ./bin/cli-users create
# Creating Moderator Test User
{ echo moderator@test.com; echo test; echo test; echo Moderator Test User; echo moderator;} | dotenv ./bin/cli-users create
# Creating Commenter Test User
{ echo commenter@test.com; echo test; echo test; echo Commenter Test User; echo ;} | dotenv ./bin/cli-users create
npm start
@@ -0,0 +1,89 @@
import 'react';
import 'redux';
import {expect} from 'chai';
import fetchMock from 'fetch-mock';
import * as actions from '../../../../client/coral-admin/src/actions/assets';
import {Map} from 'immutable';
import configureStore from 'redux-mock-store';
const mockStore = configureStore();
describe('Asset actions', () => {
let store;
const assets = [
{
url: 'http://test.com',
id: '123',
status: 'closed'
},
{
url: 'http://test.org',
id: '456',
status: 'open'
}
];
beforeEach(() => {
store = mockStore(new Map({}));
fetchMock.restore();
});
describe('FETCH_ASSETS_REQUEST', () => {
it('should fetch a list of assets', () => {
fetchMock.get('*', JSON.stringify({
result: assets,
count: 2
}));
return actions.fetchAssets(2, 20)(store.dispatch)
.then(() => {
expect(store.getActions()[0]).to.have.property('type', 'FETCH_ASSETS_REQUEST');
expect(store.getActions()[1]).to.have.property('type', 'FETCH_ASSETS_SUCCESS');
expect(store.getActions()[1]).to.have.property('count', 2);
expect(store.getActions()[1]).to.have.property('assets').
and.to.deep.equal(assets);
});
});
it('should return an error appropriatly', () => {
fetchMock.get('*', 404);
return actions.fetchAssets(2, 20)(store.dispatch)
.then(() => {
expect(store.getActions()[0]).to.have.property('type', 'FETCH_ASSETS_REQUEST');
expect(store.getActions()[1]).to.have.property('type', 'FETCH_ASSETS_FAILURE');
});
});
});
describe('UPDATE_ASSET_STATE_REQUEST', () => {
it('should update an asset', () => {
fetchMock.put('*', JSON.stringify(assets[0]));
return actions.updateAssetState('123', 'status', 'open')(store.dispatch)
.then(() => {
expect(store.getActions()[0]).to.have.property('type', 'UPDATE_ASSET_STATE_REQUEST');
expect(store.getActions()[1]).to.have.property('type', 'UPDATE_ASSET_STATE_SUCCESS');
});
});
it('should return an error appropriately', () => {
fetchMock.put('*', 404);
return actions.updateAssetState('123', 'status', 'open')(store.dispatch)
.then(() => {
expect(store.getActions()[0]).to.have.property('type', 'UPDATE_ASSET_STATE_REQUEST');
expect(store.getActions()[1]).to.have.property('type', 'UPDATE_ASSET_STATE_FAILURE');
});
});
});
});
@@ -0,0 +1,64 @@
import {Map, fromJS} from 'immutable';
import {expect} from 'chai';
import assetsReducer from '../../../../client/coral-admin/src/reducers/assets';
describe ('assetsReducer', () => {
describe('FETCH_ASSETS_SUCCESS', () => {
it('should replace the existing assets', () => {
const action = {
type: 'FETCH_ASSETS_SUCCESS',
count: 200,
assets: [
{
id: '123',
url: 'http://test.com',
closedAt: 'tomorrow'
},
{
id: '456',
url: 'http://test2.com',
closedAt: 'thursday'
},
]
};
const store = new Map({});
const result = assetsReducer(store, action);
expect(result.getIn(['byId', '123']).toJS()).to.deep.equal({
url: 'http://test.com',
closedAt: 'tomorrow',
id: '123'
});
expect(result.getIn(['ids']).toJS()).to.deep.equal([
'123',
'456'
]);
expect(result.getIn(['count'])).to.equal(200);
});
});
describe('UPDATE_ASSET_STATE_REQUEST', () => {
it('should update the state of a particular asset', () => {
const action = {
type: 'UPDATE_ASSET_STATE_REQUEST',
id: '123',
closedAt: null
};
const store = new fromJS({
byId: {
'123': {
id: '123',
url: 'http://test.com',
closedAt: Date.now()
},
'456': {
id: '456',
url: 'http://test2.com',
closedAt: 'thursday'
}
}
});
const result = assetsReducer(store, action);
expect(result.getIn(['byId', '123', 'closedAt'])).to.equal.null;
});
});
});
+16 -2
View File
@@ -1,4 +1,18 @@
module.exports = {
waitForConditionTimeout: 20000,
baseUrl: 'localhost:3011/'
waitForConditionTimeout: 8000,
baseUrl: 'http://localhost:3000',
users: {
admin: {
email: 'admin@test.com',
pass: 'test'
},
moderator: {
email: 'moderator@test.com',
pass: 'test'
},
commenter: {
email: 'commenter@test.com',
pass: 'test'
}
},
};
+33
View File
@@ -0,0 +1,33 @@
const embedStreamCommands = {
url: function () {
return `${this.api.launchUrl}/admin`;
},
ready() {
return this
.waitForElementVisible('body', 2000);
},
approveComment() {
return this
.waitForElementVisible('@commentList')
.waitForElementVisible('@approveButton')
.click('@approveButton');
}
};
module.exports = {
commands: [embedStreamCommands],
elements: {
commentList: {
selector: '#commentList'
},
banButton: {
selector: '#commentList .actions:first-child .ban'
},
rejectButton: {
selector: '#commentList .actions:first-child .reject'
},
approveButton: {
selector: '#commentList .actions:first-child .approve'
}
}
};
-45
View File
@@ -1,45 +0,0 @@
const fetch = require('node-fetch');
const embedCommands = {
ready() {
return this.resizeWindow(1200, 800)
.url(client.globals.baseUrl)
.waitForElementVisible('body', 2000)
.frame('coralStreamIframe');
},
setConfig(config, baseUrl) {
return fetch(`${baseUrl}/api/v1/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(config)
});
},
enterComment() {
const comment = 'This is a test comment';
return this
.waitForElementVisible('@commentBox')
.setValue('@commentBox', comment)
.click('@postButton')
.waitForElementVisible('.comment', 1000);
},
validateComment(comment) {
return this
.assert.equal(comment, '.comment');
}
};
module.exports = {
commands: [embedCommands],
elements: {
commentBox: {
selector: '#commentBox'
},
postButton: {
selector: '#commentBox .coral-plugin-commentbox-button'
}
}
};
+160
View File
@@ -0,0 +1,160 @@
const embedStreamCommands = {
url: function () {
return this
.api.launchUrl;
},
ready() {
return this
.waitForElementVisible('body', 4000)
.waitForElementVisible('iframe#coralStreamIframe')
.api.frame('coralStreamIframe');
},
signUp(user) {
return this
.waitForElementVisible('@signInButton', 2000)
.click('@signInButton')
.waitForElementVisible('@signInDialog')
.waitForElementVisible('@registerButton')
.click('@registerButton')
.setValue('@signInDialogEmail', user.email)
.setValue('@signInDialogPassword', user.pass)
.setValue('@signUpDialogConfirmPassword', user.pass)
.setValue('@signUpDialogDisplayName', user.displayName)
.waitForElementVisible('@signUpButton')
.click('@signUpButton')
.waitForElementVisible('@logInButton')
.click('@logInButton')
.waitForElementVisible('@logoutButton', 5000);
},
login(user) {
return this
.waitForElementVisible('@signInButton', 2000)
.click('@signInButton')
.waitForElementVisible('@signInDialog')
.waitForElementVisible('@signInDialogEmail')
.waitForElementVisible('@signInDialogPassword')
.setValue('@signInDialogEmail', user.email)
.setValue('@signInDialogPassword', user.pass)
.waitForElementVisible('@logInButton')
.click('@logInButton')
.waitForElementVisible('@logoutButton', 5000);
},
logout() {
return this
.waitForElementVisible('@logoutButton')
.click('@logoutButton')
.waitForElementVisible('@signInButton', 2000);
},
postComment(comment = 'Test Comment') {
return this
.waitForElementVisible('@commentBox', 2000)
.setValue('@commentBox', comment)
.click('@postButton');
},
likeComment() {
return this
.waitForElementVisible('@likeButton')
.click('@likeButton');
},
flagComment() {
return this
.waitForElementVisible('@flagButton')
.click('@flagButton');
},
flagUsername() {
return this
.waitForElementVisible('@flagButton')
.click('@flagButton');
},
getPermalink(fn) {
return this
.waitForElementVisible('@permalinkButton')
.click('@permalinkButton')
.waitForElementVisible('@permalinkPopUp')
.getValue('@permalinkInput', result => fn(result.value));
}
};
module.exports = {
commands: [embedStreamCommands],
elements: {
signInButton: {
selector: '#coralSignInButton'
},
signInDialog:{
selector: '#signInDialog'
},
signInDialogEmail: {
selector: '#signInDialog #email'
},
signInDialogPassword: {
selector: '#signInDialog #password'
},
signUpDialogConfirmPassword: {
selector: '#signInDialog #confirmPassword'
},
signUpDialogDisplayName: {
selector: '#signInDialog #displayName'
},
logInButton: {
selector: '#coralLogInButton'
},
signUpButton: {
selector: '#coralSignUpButton'
},
logoutButton: {
selector: '.commentStream #logout'
},
commentBox: {
selector: '.coral-plugin-commentbox-textarea'
},
postButton: {
selector: '#commentBox .coral-plugin-commentbox-button'
},
likeButton: {
selector: '.comment .coral-plugin-likes-container .coral-plugin-likes-button'
},
likeText: {
selector: '.comment .coral-plugin-likes-container .coral-plugin-likes-button .coral-plugin-likes-button-text'
},
likesCount: {
selector: '.comment .coral-plugin-likes-container .coral-plugin-likes-button .coral-plugin-likes-like-count'
},
flagButton: {
selector: '.comment .coral-plugin-flags-container .coral-plugin-flags-button'
},
flagPopUp: {
selector: '.comment .coral-plugin-flags-popup'
},
flagCommentOption: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-radio-label[for="comments"]'
},
flagUsernameOption: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-radio-label[for="user"]'
},
flagOtherOption: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-radio-label[for="other"]'
},
flagHeaderMessage: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-header'
},
flagButtonText: {
selector: '.comment .coral-plugin-flags-button-text'
},
flagDoneButton: {
selector: '.comment .coral-plugin-flags-popup .coral-plugin-flags-popup-button'
},
permalinkButton: {
selector: '.comment .coral-plugin-permalinks-button'
},
permalinkPopUp: {
selector: '.comment .coral-plugin-permalinks-popover.active'
},
permalinkInput: {
selector: '.comment .coral-plugin-permalinks-popover.active input'
},
registerButton: {
selector: '#signInDialog #coralRegister'
}
}
};
@@ -6,8 +6,5 @@ module.exports = {
.url(baseUrl)
.assert.title('Coral Talk')
.waitForElementPresent('body', 1000);
},
after: client => {
client.end();
}
};
+44
View File
@@ -0,0 +1,44 @@
module.exports = {
'@tags': ['embedStream'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.navigate()
.ready();
},
'Login as commenter': client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.login(users.commenter);
},
'Add test comment': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.postComment('Test Comment');
},
'Logout': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.logout();
},
'Login as admin': client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.login(users.admin);
},
'Approve test comment': client => {
const adminPage = client.page.adminPage();
adminPage
.navigate()
.ready();
adminPage
.approveComment();
},
after: client => {
client.end();
}
};
+23
View File
@@ -0,0 +1,23 @@
module.exports = {
'@tags': ['login', 'admin'],
before(client) {
const embedStreamPage = client.page.embedStreamPage();
const {launchUrl} = client;
client
.url(launchUrl);
embedStreamPage
.ready();
},
'Admin logs in': client => {
const {users} = client.globals;
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.login(users.admin);
},
after: client => {
client.end();
}
};
@@ -0,0 +1,34 @@
module.exports = {
'@tags': ['flag', 'comments', 'commenter'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.navigate()
.ready();
embedStreamPage
.login(users.commenter);
},
'Commenter flags a comment': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.flagComment()
.waitForElementVisible('@flagPopUp')
.waitForElementVisible('@flagCommentOption')
.click('@flagCommentOption')
.waitForElementVisible('@flagDoneButton')
.click('@flagDoneButton')
.waitForElementVisible('@flagOtherOption')
.click('@flagOtherOption')
.waitForElementVisible('@flagDoneButton')
.click('@flagDoneButton')
.click('@flagDoneButton')
.expect.element('@flagButtonText').text.to.equal('Reported');
},
after: client => {
client.end();
}
};
@@ -0,0 +1,33 @@
module.exports = {
'@tags': ['flag', 'commenter'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.navigate()
.ready();
embedStreamPage
.login(users.commenter);
},
'Commenter flags a username': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.flagUsername()
.waitForElementVisible('@flagPopUp')
.waitForElementVisible('@flagUsernameOption')
.click('@flagUsernameOption')
.waitForElementVisible('@flagDoneButton')
.click('@flagDoneButton')
.waitForElementVisible('@flagOtherOption')
.click('@flagOtherOption')
.waitForElementVisible('@flagDoneButton')
.click('@flagDoneButton')
.click('@flagDoneButton');
},
after: client => {
client.end();
}
};
@@ -0,0 +1,26 @@
module.exports = {
'@tags': ['like', 'comments', 'commenter'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.navigate()
.ready();
embedStreamPage
.login(users.commenter);
},
'Commenter likes a comment': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.likeComment()
.waitForElementVisible('@likesCount', 2000)
.expect.element('@likeText').text.to.equal('Liked');
},
after: client => {
client.end();
}
};
+20
View File
@@ -0,0 +1,20 @@
module.exports = {
'@tags': ['login', 'commenter'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.navigate()
.ready();
},
'Commenter logs in': client => {
const {users} = client.globals;
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.login(users.commenter);
},
after: client => {
client.end();
}
};
@@ -0,0 +1,34 @@
let permalink = '';
module.exports = {
'@tags': ['permalink', 'commenter'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.navigate()
.ready();
embedStreamPage
.login(users.commenter);
},
'Commenter gets the permalink of a comment': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.getPermalink(value => {
permalink = value;
});
},
'Commenter navigates to the permalink': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.navigate(permalink);
client.assert.urlContains(permalink);
},
after: client => {
client.end();
}
};
+38
View File
@@ -0,0 +1,38 @@
module.exports = {
'@tags': ['write', 'commenter'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.navigate()
.ready();
embedStreamPage
.login(users.commenter);
},
'Commenter posts a comment': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.postComment('I read the comments');
},
after: client => {
const adminPage = client.page.adminPage();
const embedStreamPage = client.page.embedStreamPage();
const {users} = client.globals;
embedStreamPage
.logout()
.login(users.admin);
adminPage
.navigate()
.ready();
adminPage
.approveComment();
client.end();
}
};
+23
View File
@@ -0,0 +1,23 @@
module.exports = {
'@tags': ['login', 'moderator'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
const {launchUrl} = client;
client
.url(launchUrl);
embedStreamPage
.ready();
},
'Moderator logs in': client => {
const {users} = client.globals;
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.login(users.moderator);
},
after: client => {
client.end();
}
};
@@ -0,0 +1,20 @@
module.exports = {
'@tags': ['flag', 'comments', 'visitor'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.navigate()
.ready();
},
'Visitor tries to flag a comment': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.flagComment()
.waitForElementVisible('@signInDialog', 2000);
},
after: client => {
client.end();
}
};
@@ -0,0 +1,20 @@
module.exports = {
'@tags': ['like', 'comments', 'visitor'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.navigate()
.ready();
},
'Visitor tries to like a comment': client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.likeComment()
.waitForElementVisible('@signInDialog', 2000);
},
after: client => {
client.end();
}
};
+24
View File
@@ -0,0 +1,24 @@
module.exports = {
'@tags': ['signup', 'visitor'],
before: client => {
const embedStreamPage = client.page.embedStreamPage();
embedStreamPage
.navigate()
.ready();
},
'Visitor signs up': client => {
const embedStreamPage = client.page.embedStreamPage();
const hash = Math.floor(Math.random() * (999 - 0));
embedStreamPage
.signUp({
email: `visitor_${hash}@test.com`,
displayName: 'Visitor',
pass: 'testtest'
});
},
after: client => {
client.end();
}
};
+35 -2
View File
@@ -18,12 +18,13 @@ describe('/api/v1/assets', () => {
url: 'https://coralproject.net/news/asset1',
title: 'Asset 1',
description: 'term1',
id: '1'
closedAt: Date.now()
},
{
url: 'https://coralproject.net/news/asset2',
title: 'Asset 2',
description: 'term2'
description: 'term2',
closedAt: null
}
]);
});
@@ -81,6 +82,38 @@ describe('/api/v1/assets', () => {
});
});
it('should return only closed assets', () => {
return chai.request(app)
.get('/api/v1/assets?filter=closed')
.set(passport.inject({roles: ['admin']}))
.then((res) => {
const body = res.body;
expect(body).to.have.property('count', 1);
expect(body).to.have.property('result');
const assets = body.result;
expect(assets[0]).to.have.property('title', 'Asset 1');
});
});
it('should return only opened assets', () => {
return chai.request(app)
.get('/api/v1/assets?filter=open')
.set(passport.inject({roles: ['admin']}))
.then((res) => {
const body = res.body;
expect(body).to.have.property('count', 1);
expect(body).to.have.property('result');
const assets = body.result;
expect(assets[0]).to.have.property('title', 'Asset 2');
});
});
});
describe('#put', () => {
+1 -1
View File
@@ -36,7 +36,7 @@
<script type='text/javascript' src='<%= basePath %>/pym.v1.min.js'></script>
<script>
var ready = false;
var pymParent = new pym.Parent('coralStreamEmbed', '/embed/stream', {title: 'Talk Comments', id:'coralStreamIframe'});
var pymParent = new pym.Parent('coralStreamEmbed', '/embed/stream', {title: 'Talk Comments', id:'coralStreamIframe', name: 'coralStreamIframe'});
pymParent.onMessage('height', function(height) {document.querySelector('#coralStreamEmbed iframe').height = height + 'px'})
pymParent.onMessage('childReady', function () {
var interval = setInterval(function () {