Merge branch 'master' into add-flag-option

This commit is contained in:
Riley Davis
2016-12-22 10:38:34 -07:00
committed by GitHub
21 changed files with 596 additions and 7318 deletions
+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}));
};
@@ -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';
@@ -1,6 +1,5 @@
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';
@@ -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);
@@ -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()));
};
@@ -33,6 +33,7 @@ export default (state = initialState, 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_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
});
+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": ""
}
}
}
@@ -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;
+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) => {
@@ -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;
});
});
});
+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', () => {
-7303
View File
File diff suppressed because it is too large Load Diff