diff --git a/client/coral-embed-stream/src/CommentStream.js b/client/coral-embed-stream/src/CommentStream.js index bfccf7cfc..9a1910105 100644 --- a/client/coral-embed-stream/src/CommentStream.js +++ b/client/coral-embed-stream/src/CommentStream.js @@ -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 { -
+
+ + +
+
- - +
-
- - - -
+
+ + +
+
+ + +
; }) } diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index e1c730bfc..f68e2a0e3 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -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; diff --git a/client/coral-framework/store/actions/items.js b/client/coral-framework/store/actions/items.js index 07f8e54af..2e7dca70f 100644 --- a/client/coral-framework/store/actions/items.js +++ b/client/coral-framework/store/actions/items.js @@ -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); }; } diff --git a/client/coral-plugin-comment-count/CommentCount.js b/client/coral-plugin-comment-count/CommentCount.js index 47f00ffb5..6786d5c74 100644 --- a/client/coral-plugin-comment-count/CommentCount.js +++ b/client/coral-plugin-comment-count/CommentCount.js @@ -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; } diff --git a/client/coral-plugin-flags/FlagButton.js b/client/coral-plugin-flags/FlagButton.js index 4f90dfb7c..a4869c486 100644 --- a/client/coral-plugin-flags/FlagButton.js +++ b/client/coral-plugin-flags/FlagButton.js @@ -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
-
; }; diff --git a/client/coral-plugin-flags/translations.json b/client/coral-plugin-flags/translations.json index 28eb9f8cb..fb2daa883 100644 --- a/client/coral-plugin-flags/translations.json +++ b/client/coral-plugin-flags/translations.json @@ -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!" } } diff --git a/client/coral-plugin-likes/LikeButton.js b/client/coral-plugin-likes/LikeButton.js new file mode 100644 index 000000000..9bdf7b84d --- /dev/null +++ b/client/coral-plugin-likes/LikeButton.js @@ -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
+ +
; +}; + +export default LikeButton; + +const lang = new I18n(translations); diff --git a/client/coral-plugin-likes/translations.json b/client/coral-plugin-likes/translations.json new file mode 100644 index 000000000..93d73d3a2 --- /dev/null +++ b/client/coral-plugin-likes/translations.json @@ -0,0 +1,10 @@ +{ + "en": { + "like": "Like", + "liked": "Liked" + }, + "es": { + "like": "Me Gusta", + "liked": "Me Gustó" + } +} diff --git a/client/coral-plugin-replies/ReplyButton.js b/client/coral-plugin-replies/ReplyButton.js index 44213c2f7..4fbfd5f60 100644 --- a/client/coral-plugin-replies/ReplyButton.js +++ b/client/coral-plugin-replies/ReplyButton.js @@ -7,9 +7,9 @@ const name = 'coral-plugin-replies'; const ReplyButton = (props) => ; export default ReplyButton; diff --git a/models/asset.js b/models/asset.js index de8155131..1092cd3e0 100644 --- a/models/asset.js +++ b/models/asset.js @@ -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(); diff --git a/models/comment.js b/models/comment.js index 8eab6aff4..d17e98860 100644 --- a/models/comment.js +++ b/models/comment.js @@ -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; diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index ea142c35d..99957e987 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -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); diff --git a/tests/client/coral-framework/store/itemActions.spec.js b/tests/client/coral-framework/store/itemActions.spec.js index d45212794..4dbba5e82 100644 --- a/tests/client/coral-framework/store/itemActions.spec.js +++ b/tests/client/coral-framework/store/itemActions.spec.js @@ -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; + }); + }); }); }); diff --git a/tests/models/asset.js b/tests/models/asset.js index b4d950a93..d0c3d3429 100644 --- a/tests/models/asset.js +++ b/tests/models/asset.js @@ -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; + }); + }); + }); }); diff --git a/tests/models/comment.js b/tests/models/comment.js index 94e45625a..f8dd7f9f5 100644 --- a/tests/models/comment.js +++ b/tests/models/comment.js @@ -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); + }); + }); + }); }); diff --git a/tests/routes/api/assets/index.js b/tests/routes/api/assets/index.js new file mode 100644 index 000000000..aa764e214 --- /dev/null +++ b/tests/routes/api/assets/index.js @@ -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 + +});