Merged master into password-reset

This commit is contained in:
riley
2016-11-15 13:11:02 -07:00
16 changed files with 470 additions and 181 deletions
+53 -25
View File
@@ -14,9 +14,10 @@ import AuthorName from '../../coral-plugin-author-name/AuthorName';
import {ReplyBox, ReplyButton} from '../../coral-plugin-replies';
import Pym from 'pym.js';
import FlagButton from '../../coral-plugin-flags/FlagButton';
import LikeButton from '../../coral-plugin-likes/LikeButton';
import PermalinkButton from '../../coral-plugin-permalinks/PermalinkButton';
const {addItem, updateItem, postItem, getStream, postAction, appendItemArray} = itemActions;
const {addItem, updateItem, postItem, getStream, postAction, deleteAction, appendItemArray} = itemActions;
const {addNotification, clearNotification} = notificationActions;
const {setLoggedInUser} = authActions;
@@ -55,6 +56,9 @@ const mapDispatchToProps = (dispatch) => {
postAction: (item, action, user, itemType) => {
return dispatch(postAction(item, action, user, itemType));
},
deleteAction: (item, action, user, itemType) => {
return dispatch(deleteAction(item, action, user, itemType));
},
appendItemArray: (item, property, value, addToFront, itemType) => {
return dispatch(appendItemArray(item, property, value, addToFront, itemType));
}
@@ -120,21 +124,33 @@ class CommentStream extends Component {
<AuthorName name={comment.username}/>
<PubDate created_at={comment.created_at}/>
<Content body={comment.body}/>
<div className="commentActions">
<div className="commentActionsLeft">
<ReplyButton
updateItem={this.props.updateItem}
id={commentId}/>
<LikeButton
addNotification={this.props.addNotification}
id={commentId}
like={this.props.items.actions[comment.like]}
postAction={this.props.postAction}
deleteAction={this.props.deleteAction}
addItem={this.props.addItem}
updateItem={this.props.updateItem}
currentUser={this.props.auth.user}/>
</div>
<div className="commentActionsRight">
<FlagButton
addNotification={this.props.addNotification}
id={commentId}
flag={this.props.items.actions[comment.flag]}
postAction={this.props.postAction}
deleteAction={this.props.deleteAction}
addItem={this.props.addItem}
updateItem={this.props.updateItem}
currentUser={this.props.auth.user}/>
<ReplyButton
updateItem={this.props.updateItem}
id={commentId}/>
<PermalinkButton
comment_id={commentId}
asset_id={comment.asset_id}/>
<PermalinkButton
comment_id={commentId}
asset_id={comment.asset_id}/>
</div>
<ReplyBox
addNotification={this.props.addNotification}
@@ -154,23 +170,35 @@ class CommentStream extends Component {
<AuthorName name={reply.username}/>
<PubDate created_at={reply.created_at}/>
<Content body={reply.body}/>
<div className="replyActions">
<FlagButton
addNotification={this.props.addNotification}
id={replyId}
flag={this.props.items.actions[reply.flag]}
postAction={this.props.postAction}
addItem={this.props.addItem}
updateItem={this.props.updateItem}
currentUser={this.props.auth.user}/>
<ReplyButton
updateItem={this.props.updateItem}
parent_id={reply.parent_id}/>
<PermalinkButton
comment_id={reply.comment_id}
asset_id={reply.comment_id}
/>
</div>
<div className="replyActionsLeft">
<ReplyButton
updateItem={this.props.updateItem}
id={replyId}/>
<LikeButton
addNotification={this.props.addNotification}
id={replyId}
like={this.props.items.actions[reply.like]}
postAction={this.props.postAction}
deleteAction={this.props.deleteAction}
addItem={this.props.addItem}
updateItem={this.props.updateItem}
currentUser={this.props.auth.user}/>
</div>
<div className="replyActionsRight">
<FlagButton
addNotification={this.props.addNotification}
id={replyId}
flag={this.props.items.actions[reply.flag]}
postAction={this.props.postAction}
deleteAction={this.props.deleteAction}
addItem={this.props.addItem}
updateItem={this.props.updateItem}
currentUser={this.props.auth.user}/>
<PermalinkButton
comment_id={reply.comment_id}
asset_id={reply.comment_id}
/>
</div>
</div>;
})
}
+19 -2
View File
@@ -105,16 +105,33 @@ hr {
/* Comment Action Styles */
.commentActions, .replyActions {
.commentActionsRight, .replyActionsRight {
display: flex;
justify-content: flex-end;
width: 50%;
}
.commentActionsLeft, .replyActionsLeft {
display: flex;
justify-content: flex-start;
float: left;
width: 50%;
}
.commentActions .material-icons, .replyActions .material-icons {
.commentActionsLeft .material-icons,.commentActionsRight .material-icons,
.replyActionsLeft .material-icons, .replyActionsRight .material-icons {
font-size: 12px;
margin-left: 3px;
vertical-align: middle;
}
.likedButton {
color: rgb(0,134,227);
}
.flaggedIcon {
color: #F00;
}
/* Comment count styles */
.coral-plugin-comment-count-text {
margin-bottom: 15px;
+52 -44
View File
@@ -8,6 +8,23 @@ export const ADD_ITEM = 'ADD_ITEM';
export const UPDATE_ITEM = 'UPDATE_ITEM';
export const APPEND_ITEM_ARRAY = 'APPEND_ITEM_ARRAY';
const getInit = (method, body) => {
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
};
const init = {method, headers};
if (method.toLowerCase() !== 'get') {
init.body = JSON.stringify(body);
}
return init;
};
const responseHandler = response => {
return response.ok ? response.json() : Promise.reject(`${response.status} ${response.statusText}`);
};
/**
* Action creators
*/
@@ -79,12 +96,8 @@ export const appendItemArray = (id, property, value, add_to_front, item_type) =>
*/
export function getStream (assetId) {
return (dispatch) => {
return fetch(`/api/v1/stream?asset_id=${assetId}`)
.then(
response => {
return response.ok ? response.json() : Promise.reject(`${response.status} ${response.statusText}`);
}
)
return fetch(`/api/v1/stream?asset_id=${assetId}`, getInit('GET'))
.then(responseHandler)
.then((json) => {
/* Add items to the store */
@@ -148,13 +161,8 @@ export function getStream (assetId) {
export function getItemsArray (ids) {
return (dispatch) => {
return fetch(`/v1/item/${ids}`)
.then(
response => {
return response.ok ? response.json()
: Promise.reject(`${response.status } ${ response.statusText}`);
}
)
return fetch(`/v1/item/${ids}`, getInit('GET'))
.then(responseHandler)
.then((json) => {
for (let i = 0; i < json.items.length; i++) {
dispatch(addItem(json.items[i]));
@@ -183,20 +191,8 @@ export function postItem (item, type, id) {
if (id) {
item.id = id;
}
let options = {
method: 'POST',
body: JSON.stringify(item),
headers: {
'Content-Type':'application/json'
}
};
return fetch(`/api/v1/${type}`, options)
.then(
response => {
return response.ok ? response.json()
: Promise.reject(`${response.status} ${response.statusText}`);
}
)
return fetch(`/api/v1/${type}`, getInit('POST', item))
.then(responseHandler)
.then((json) => {
dispatch(addItem({...item, id:json.id}, type));
return json.id;
@@ -227,23 +223,35 @@ export function postAction (item_id, action_type, user_id, item_type) {
action_type,
user_id
};
const options = {
method: 'POST',
headers: {
'Content-Type':'application/json'
},
body: JSON.stringify(action)
};
return fetch(`/api/v1/${item_type}/${item_id}/actions`, options)
.then(
response => {
return response.ok ? response.json()
: Promise.reject(`${response.status} ${response.statusText}`);
}
)
.then((json)=>{
return json;
});
return fetch(`/api/v1/${item_type}/${item_id}/actions`, getInit('POST', action))
.then(responseHandler);
};
}
/*
* DeleteAction
* Deletes an action to an item
*
* @params
* id - the id of the item on which the action is taking place
* action - the name of the action
* user - the user performing the action
* host - the coral host
*
* @returns
* A promise resolving to null or an error
*
*/
export function deleteAction (item_id, action_type, user_id, item_type) {
return () => {
const action = {
action_type,
user_id
};
return fetch(`/api/v1/${item_type}/${item_id}/actions`, getInit('DELETE', action))
.then(responseHandler);
};
}
@@ -5,12 +5,12 @@ const name = 'coral-plugin-comment-count';
const CommentCount = ({items, id}) => {
let count = 0;
if (items[id]) {
count += items[id].comments.length;
if (items.assets[id]) {
count += items.assets[id].comments.length;
}
const itemKeys = Object.keys(items);
const itemKeys = Object.keys(items.comments);
for (let i = 0; i < itemKeys.length; i++) {
const item = items[itemKeys[i]];
const item = items.comments[itemKeys[i]];
if (item.children) {
count += item.children.length;
}
+22 -14
View File
@@ -4,27 +4,35 @@ import translations from './translations.json';
const name = 'coral-plugin-flags';
const FlagButton = ({flag, id, postAction, addItem, updateItem, addNotification}) => {
const FlagButton = ({flag, id, postAction, deleteAction, addItem, updateItem, addNotification}) => {
const flagged = flag && flag.current_user;
const onFlagClick = () => {
postAction(id, 'flag', '123', 'comments')
.then((action) => {
addItem({...action, current_user:true}, 'actions');
updateItem(action.item_id, action.action_type, action.id, 'comments');
});
addNotification('success', lang.t('flag-notif'));
if (!flagged) {
postAction(id, 'flag', '123', 'comments')
.then((action) => {
addItem({...action, current_user:true}, 'actions');
updateItem(action.item_id, action.action_type, action.id, 'comments');
});
addNotification('success', lang.t('flag-notif'));
} else {
deleteAction(id, 'flag', '123', 'comments')
.then(() => {
updateItem(id, 'flag', '', 'comments');
});
addNotification('success', lang.t('flag-notif-remove'));
}
};
return <div className={`${name }-container`}>
<button onClick={onFlagClick} className={`${name }-button`}>
<i className={`${name }-icon material-icons`}
style={flagged ? styles.flaggedIcon : styles.unflaggedIcon}
aria-hidden={true}>flag</i>
return <div className={`${name}-container`}>
<button onClick={onFlagClick} className={`${name}-button`}>
{
flagged
? <span className={`${name}-button-text`}>{lang.t('flag')}</span>
: <span className={`${name}-button-text`}>{lang.t('flagged')}</span>
? <span className={`${name}-button-text`}>{lang.t('flagged')}</span>
: <span className={`${name}-button-text`}>{lang.t('flag')}</span>
}
<i className={`${name}-icon material-icons ${flagged && 'flaggedIcon'}`}
style={flagged && styles.flaggedIcon}
aria-hidden={true}>flag</i>
</button>
</div>;
};
+4 -2
View File
@@ -2,11 +2,13 @@
"en": {
"flag": "Flag",
"flagged": "Flagged",
"flag-notif": "Thank you for reporting this comment. Our moderation team has been notified and will review it shortly."
"flag-notif": "Thank you for reporting this comment. Our moderation team has been notified and will review it shortly.",
"flag-notif-remove": "Your flag has been removed."
},
"es": {
"flag": "Marcar",
"flagged": "Marcado",
"flag-notif": "Gracias por marcar este comentario. Nuestro equipo de moderación ha sido notificado y muy pronto lo va a revisar."
"flag-notif": "Gracias por marcar este comentario. Nuestro equipo de moderación ha sido notificado y muy pronto lo va a revisar.",
"flag-notif-remove": "¡traduceme!"
}
}
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
const name = 'coral-plugin-flags';
const LikeButton = ({like, id, postAction, deleteAction, addItem, updateItem}) => {
const liked = like && like.current_user;
const onLikeClick = () => {
if (!liked) {
postAction(id, 'like', '123', 'comments')
.then((action) => {
addItem({id: action.id, current_user:true, count: like ? like.count + 1 : 1}, 'actions');
updateItem(action.item_id, action.action_type, action.id, 'comments');
});
} else {
deleteAction(id, 'like', '123', 'comments')
.then(() => {
updateItem(like.id, 'count', like.count - 1, 'actions');
updateItem(like.id, 'current_user', false, 'actions');
});
}
};
return <div className={`${name}-container`}>
<button onClick={onLikeClick} className={`${name}-button ${liked && 'likedButton'}`}>
{
liked
? <span className={`${name}-button-text`}>{lang.t('liked')}</span>
: <span className={`${name}-button-text`}>{lang.t('like')}</span>
}
<i className={`${name}-icon material-icons`}
aria-hidden={true}>thumb_up</i>
<span className={`${name}-like-count`}>{like && like.count > 0 && like.count}</span>
</button>
</div>;
};
export default LikeButton;
const lang = new I18n(translations);
@@ -0,0 +1,10 @@
{
"en": {
"like": "Like",
"liked": "Liked"
},
"es": {
"like": "Me Gusta",
"liked": "Me Gustó"
}
}
+1 -1
View File
@@ -7,9 +7,9 @@ const name = 'coral-plugin-replies';
const ReplyButton = (props) => <button
className={`${name}-reply-button`}
onClick={() => props.updateItem(props.id || props.parent_id, 'showReply', true, 'comments')}>
{lang.t('reply')}
<i className={`${name}-icon material-icons`}
aria-hidden={true}>reply</i>
{lang.t('reply')}
</button>;
export default ReplyButton;
+11 -1
View File
@@ -62,11 +62,21 @@ AssetSchema.statics.findByUrl = function(url) {
};
/**
* Finds a asset by its url.
* @param {String} url identifier of the asset (uuid).
*/
AssetSchema.statics.findOrCreateByUrl = function(url) {
return Asset.findOne({url})
.then((asset) => asset ? asset
: Asset.upsert({url}));
};
/**
* Upserts an asset.
*/
AssetSchema.statics.upsert = function(data) {
// If an id is not sent, create one.
if (typeof data.id === 'undefined') {
data.id = uuid.v4();
+18 -3
View File
@@ -104,14 +104,14 @@ CommentSchema.statics.findByStatusByActionType = function(status, action_type) {
return Action
.findCommentsIdByActionType(action_type, 'comment')
.then((actions) => {
return Comment.find({
'status': status,
'status': status,
'id': {
'$in': actions.map(a => {
return a.item_id;
})
}
}
});
});
@@ -188,6 +188,21 @@ CommentSchema.statics.removeById = function(id) {
return Comment.remove({'id': id});
};
/**
* Remove an action from the comment.
* @param {String} id identifier of the comment (uuid)
* @param {String} action_type the type of the action to be removed
* @param {String} user_id the id of the user performing the action
*/
CommentSchema.statics.removeAction = function(item_id, user_id, action_type) {
return Action.remove({
action_type,
item_type: 'comment',
item_id,
user_id
});
};
const Comment = mongoose.model('Comment', CommentSchema);
module.exports = Comment;
+13 -1
View File
@@ -126,7 +126,19 @@ router.delete('/:comment_id', (req, res, next) => {
Comment
.removeById(req.params.comment_id)
.then(() => {
res.status(201).send('OK. Removed');
res.status(201).send({});
})
.catch(error => {
next(error);
});
});
router.delete('/:comment_id/actions', (req, res, next) => {
console.log(req.params);
Comment
.removeAction(req.params.comment_id, req.body.user_id, req.body.action_type)
.then(() => {
res.status(201).send({});
})
.catch(error => {
next(error);
@@ -119,6 +119,7 @@ describe('itemActions', () => {
{
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type':'application/json'
},
body: JSON.stringify(item.data)
@@ -162,6 +163,24 @@ describe('itemActions', () => {
expect(err).to.be.truthy;
});
});
});
describe('deleteAction', () => {
it ('should remove an action', () => {
fetchMock.delete('*', {});
return actions.deleteAction('abc', 'flag', '123', 'comments')(store.dispatch)
.then(response => {
expect(fetchMock.calls().matched[0][0]).to.equal('/api/v1/comments/abc/actions');
expect(response).to.deep.equal({});
});
});
it('should handle an error', () => {
fetchMock.post('*', 404);
return actions.postAction('abc', 'flag', '123')(store.dispatch)
.catch((err) => {
expect(err).to.be.truthy;
});
});
});
});
+97 -84
View File
@@ -1,95 +1,108 @@
/* eslint-env node, mocha */
require('../utils/mongoose');
const chai = require('chai');
const expect = chai.expect;
const server = require('../../app');
const Asset = require('../../models/asset');
const expect = require('chai').expect;
// Setup chai.
chai.should();
chai.use(require('chai-http'));
describe('Asset: model', () => {
let fixture = {
'url': 'http://hhgg.com/total-perspective-vortex',
'type': 'article',
'headline': 'The Total Perspective Vortex',
'summary': 'You are an insignificant dot on an insignificant dot.',
'section': 'Everything',
'authors': ['Ford Prefect']
};
beforeEach(() => {
const defaults = {url:'http://test.com'};
return Asset.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true});
});
describe('Asset: models', () => {
describe('/GET Asset', () => {
describe('#get', () => {
it('It should get an empty array when there are no assets.', (done) => {
chai.request(server)
.get('/api/v1/asset')
.end((err, res) => {
if (err) {
throw new Error(err);
}
res.should.have.status(200);
res.body.should.be.a('array');
res.body.length.should.be.eql(0);
done();
});
});
describe('#findById', ()=> {
it('should find an asset by the id', () => {
return Asset.findById(1)
.then((asset) => {
expect(asset).to.have.property('url')
.and.to.equal('http://test.com');
});
});
});
// This test checks PUT and read
describe('/PUT Asset', () => {
describe('#put', () => {
it('It should save an asset and load it again.', (done) => {
chai.request(server)
.put('/api/v1/asset')
.send(fixture)
.end((err, res) => {
if (err) {
throw new Error(err);
}
res.should.have.status(200);
res.body.should.be.a('object');
// Id should be generated by the model if absent.
res.body.should.have.property('id');
// Save the asset id to compare with GET result.
let assetId = res.body.id;
// Load the asset to make sure it's really there.
chai.request(server)
.get(`/api/v1/asset?url=${encodeURIComponent(fixture.url)}`)
.end((err, res) => {
if (err) {
throw new Error(err);
}
res.should.have.status(200);
res.body.should.be.an('array');
let asset = res.body[0];
expect(asset).to.have.property('id');
// Ensure the asset has the same id as above.
// This tests the single url per Id concept.
expect(assetId).to.equal(asset.id);
done();
});
});
});
describe('#findByUrl', ()=> {
it('should find an asset by a url', () => {
return Asset.findByUrl('http://test.com')
.then((asset) => {
expect(asset).to.have.property('url')
.and.to.equal('http://test.com');
});
});
}); // End describe /PUT Asset
it('should return null when a url does not exist', () => {
return Asset.findByUrl('http://new.test.com')
.then((asset) => {
expect(asset).to.be.null;
});
});
});
describe('#findOrCreateByUrl', ()=> {
it('should find an asset by a url', () => {
return Asset.findOrCreateByUrl('http://test.com')
.then((asset) => {
expect(asset).to.have.property('url')
.and.to.equal('http://test.com');
});
});
it('should return a new asset when the url does not exist', () => {
return Asset.findOrCreateByUrl('http://new.test.com')
.then((asset) => {
expect(asset).to.have.property('id')
.and.to.not.equal(1);
});
});
});
describe('#findOrCreateByUrl', ()=> {
it('should find an asset by a url', () => {
return Asset.findOrCreateByUrl('http://test.com')
.then((asset) => {
expect(asset).to.have.property('url')
.and.to.equal('http://test.com');
});
});
it('should return a new asset when the url does not exist', () => {
return Asset.findOrCreateByUrl('http://new.test.com')
.then((asset) => {
expect(asset).to.have.property('id')
.and.to.not.equal(1);
});
});
});
describe('#upsert', ()=> {
it('should insert an asset with no id', () => {
return Asset.upsert({url: 'http://newasset.test.com'})
.then((asset) => {
expect(asset).to.have.property('id');
});
});
it('should update an asset when the id exists', () => {
return Asset.upsert({id: 1, url: 'http://new.test.com'})
.then((asset) => {
expect(asset).to.have.property('id')
.and.to.equal('1');
expect(asset).to.have.property('url')
.and.to.equal('http://new.test.com');
});
});
});
describe('#removeAll', ()=> {
it('should insert an asset with no id', () => {
return Asset.removeAll({id:1})
.then(() => {
return Asset.findById(1);
})
.then((result) => {
expect(result).to.be.null;
});
});
});
});
+11
View File
@@ -134,4 +134,15 @@ describe('Comment: models', () => {
// });
// });
});
describe('#removeAction', () => {
it('should remove an action', () => {
return Comment.removeAction('3', '123', 'flag').then(() => {
return Action.findByItemIdArray(['123']);
})
.then((actions) => {
expect(actions.length).to.equal(0);
});
});
});
});
+95
View File
@@ -0,0 +1,95 @@
require('../../../utils/mongoose');
const chai = require('chai');
const expect = chai.expect;
const server = require('../../../../app');
// Setup chai.
chai.should();
chai.use(require('chai-http'));
let fixture = {
'url': 'http://hhgg.com/total-perspective-vortex',
'type': 'article',
'headline': 'The Total Perspective Vortex',
'summary': 'You are an insignificant dot on an insignificant dot.',
'section': 'Everything',
'authors': ['Ford Prefect']
};
describe('Asset: routes', () => {
describe('/GET Asset', () => {
describe('#get', () => {
it('It should get an empty array when there are no assets.', (done) => {
chai.request(server)
.get('/api/v1/asset')
.end((err, res) => {
if (err) {
throw new Error(err);
}
res.should.have.status(200);
res.body.should.be.a('array');
res.body.length.should.be.eql(0);
done();
});
});
});
});
// This test checks PUT and read
describe('/PUT Asset', () => {
describe('#put', () => {
it('It should save an asset and load it again.', (done) => {
chai.request(server)
.put('/api/v1/asset')
.send(fixture)
.end((err, res) => {
if (err) {
throw new Error(err);
}
res.should.have.status(200);
res.body.should.be.a('object');
// Id should be generated by the model if absent.
res.body.should.have.property('id');
// Save the asset id to compare with GET result.
let assetId = res.body.id;
// Load the asset to make sure it's really there.
chai.request(server)
.get(`/api/v1/asset?url=${encodeURIComponent(fixture.url)}`)
.end((err, res) => {
if (err) {
throw new Error(err);
}
res.should.have.status(200);
res.body.should.be.an('array');
let asset = res.body[0];
expect(asset).to.have.property('id');
// Ensure the asset has the same id as above.
// This tests the single url per Id concept.
expect(assetId).to.equal(asset.id);
done();
});
});
});
});
}); // End describe /PUT Asset
});