diff --git a/.babelrc b/.babelrc
index f8b877ec3..cf99c0639 100644
--- a/.babelrc
+++ b/.babelrc
@@ -6,7 +6,11 @@
],
"plugins": [
["transform-decorators-legacy"],
- ["transform-react-jsx", { "pragma": "h" }],
- ["transform-object-assign"]
+ ["transform-react-jsx"],
+ ["transform-object-assign"],
+ ["transform-class-properties"],
+ ["transform-async-to-generator"],
+ ["transform-object-rest-spread"],
+ ["transform-class-properties"]
]
}
diff --git a/client/coral-embed-stream/.gitignore b/client/coral-embed-stream/.gitignore
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/coral-embed-stream/dev-server.js b/client/coral-embed-stream/dev-server.js
new file mode 100644
index 000000000..926033fda
--- /dev/null
+++ b/client/coral-embed-stream/dev-server.js
@@ -0,0 +1,41 @@
+var path = require('path')
+var express = require('express')
+var http = require('http')
+var webpack = require('webpack')
+var config = require('./webpack.config.dev')
+var Dashboard = require('webpack-dashboard')
+var DashboardPlugin = require('webpack-dashboard/plugin')
+
+var app = express()
+var server = http.Server(app)
+
+var compiler = webpack(config)
+var dashboard = new Dashboard()
+compiler.apply(new DashboardPlugin(dashboard.setData))
+
+app.use(express.static('public'))
+
+app.use(require('webpack-dev-middleware')(compiler, {
+ noInfo: true,
+ quiet: true,
+ publicPath: config.output.publicPath
+}))
+
+app.use(require('webpack-hot-middleware')(compiler, {log: () => {}}))
+
+app.get('/default.css', function (req, res) {
+ res.sendFile(path.join(__dirname, '/style/default.css'))
+})
+
+app.get('*', function (req, res) {
+ res.sendFile(path.join(__dirname, 'index.html'))
+})
+
+server.listen(6182, 'localhost', function (err) {
+ if (err) {
+ console.log(err)
+ return
+ }
+
+ console.log('Listening at http://localhost:6182')
+})
diff --git a/client/coral-embed-stream/index.html b/client/coral-embed-stream/index.html
new file mode 100644
index 000000000..6e15cba9f
--- /dev/null
+++ b/client/coral-embed-stream/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/coral-embed-stream/src/CommentStream.js b/client/coral-embed-stream/src/CommentStream.js
new file mode 100644
index 000000000..28d35860d
--- /dev/null
+++ b/client/coral-embed-stream/src/CommentStream.js
@@ -0,0 +1,180 @@
+import React, {Component, PropTypes} from 'react'
+import {
+ itemActions,
+ Notification,
+ notificationActions,
+ authActions
+} from '../../coral-framework'
+import {connect} from 'react-redux'
+import CommentBox from '../../coral-plugin-commentbox/CommentBox'
+import Content from '../../coral-plugin-commentcontent/CommentContent'
+import PubDate from '../../coral-plugin-pubdate/PubDate'
+import Count from '../../coral-plugin-comment-count/CommentCount'
+import Flag from '../../coral-plugin-flags/FlagButton'
+import {ReplyBox, ReplyButton} from '../../coral-plugin-replies'
+import Pym from 'pym.js'
+
+const {addItem, updateItem, postItem, getItemsQuery, postAction, appendItemRelated} = itemActions
+const {addNotification, clearNotification} = notificationActions
+const {setLoggedInUser} = authActions
+
+@connect(
+ (state) => {
+ return {
+ config: state.config.toJS(),
+ items: state.items.toJS(),
+ notification: state.notification.toJS(),
+ auth: state.auth.toJS()
+ }
+ },
+ (dispatch) => {
+ return {
+ addItem: (item) => {
+ return dispatch(addItem(item))
+ },
+ updateItem: (id, property, value) => {
+ return dispatch(updateItem(id, property, value))
+ },
+ postItem: (data, type, id) => {
+ return dispatch(postItem(data, type, id))
+ },
+ getItemsQuery: (rootId) => {
+ return dispatch(getItemsQuery(rootId))
+ },
+ addNotification: (type, text) => {
+ return dispatch(addNotification(type, text))
+ },
+ clearNotification: () => {
+ return dispatch(clearNotification())
+ },
+ setLoggedInUser: (user_id) => {
+ return dispatch(setLoggedInUser(user_id))
+ },
+ postAction: (item, action, user) => {
+ return dispatch(postAction(item, action, user))
+ },
+ appendItemRelated: (item, property, value) => {
+ return dispatch(appendItemRelated(item, property, value))
+ }
+ }
+ }
+)
+
+class CommentStream extends Component {
+
+ static propTypes = {
+ items: PropTypes.object.isRequired,
+ addItem: PropTypes.func.isRequired,
+ updateItem: PropTypes.func.isRequired
+ }
+
+ componentDidMount () {
+ // Set up messaging between embedded Iframe an parent component
+ // Using recommended Pym init code which violates .eslint standards
+ new Pym.Child({ polling: 500 })
+ this.props.getItemsQuery('assetTest')
+ }
+
+ render () {
+ if (Object.keys(this.props.items).length === 0) {
+ // Loading mock asset
+ this.props.postItem({
+ comments: [],
+ url: 'http://coralproject.net'
+ }, 'asset', 'assetTest')
+
+ // Loading mock user
+ this.props.postItem({name: 'Ban Ki-Moon'}, 'user', 'user_8989')
+ .then((id) => {
+ this.props.setLoggedInUser(id)
+ })
+ }
+
+
+ // TODO: Replace teststream id with id from params
+
+
+
+ const rootItemId = 'assetTest'
+ const rootItem = this.props.items[rootItemId]
+ console.log(this.props.items);
+ return
+ {
+ rootItem ?
+
+
+ {
+ rootItem.related.comment.map((commentId) => {
+ const comment = this.props.items[commentId]
+ return
+
+
+
+
+
+
+
+
+ {
+ comment.related &&
+ comment.related.child &&
+ comment.related.child.map((replyId) => {
+ let reply = this.props.items[replyId]
+ return
+ })
+ }
+
+ })
+ }
+
+
+ :'Loading'
+ }
+
+
+
+ }
+}
+
+export default CommentStream
diff --git a/client/coral-embed-stream/src/app.js b/client/coral-embed-stream/src/app.js
new file mode 100644
index 000000000..189427e23
--- /dev/null
+++ b/client/coral-embed-stream/src/app.js
@@ -0,0 +1,13 @@
+import React from 'react'
+import { render } from 'react-dom'
+import CommentStream from './CommentStream'
+import { Provider } from 'react-redux'
+import { fetchConfig, store } from '../../coral-framework'
+
+store.dispatch(fetchConfig())
+
+render(
+
+
+
+ , document.querySelector('#coralStream'))
diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css
new file mode 100644
index 000000000..219a7aef5
--- /dev/null
+++ b/client/coral-embed-stream/style/default.css
@@ -0,0 +1,113 @@
+body {
+ font-family: 'Lato', sans-serif;
+ font-family: 'Open Sans', sans-serif;
+ width: 100%;
+ font-size: 12px;
+ margin: 0;
+}
+
+button {
+ padding: 5px 10px;
+ margin: 5px;
+ background: none;
+ border: none;
+}
+
+button:hover {
+ border-radius: 2px;
+ color: #FFF;
+ background-color: rgb(155, 155, 155);
+}
+
+button i {
+ margin-right: 3px;
+}
+
+hr {
+ border: 0;
+ height: 0;
+ border-top: 1px solid rgba(0, 0, 0, 0.1);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.3);
+}
+
+.screen-reader-text {
+ clip: rect(1px, 1px, 1px, 1px);
+ height: 1px;
+ width: 1px;
+ overflow: hidden;
+ position: absolute !important;
+}
+
+/* Notification styles */
+#coral-notif {
+ position: fixed;
+ bottom: 0;
+ border: 0;
+ background: rgb(105,105,105);
+ color: white;
+ border-radius: 2px;
+ font-weight: bold;
+}
+
+/* Comment Box Styles */
+.coral-plugin-commentbox-container {
+ display: flex;
+}
+
+.coral-plugin-commentbox-textarea {
+ flex: 1;
+ padding: 10px;
+}
+
+.coral-plugin-commentbox-button-container {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 10px;
+}
+
+#coralStream .coral-plugin-commentbox-button {
+ float: right;
+ margin-top: 10px;
+ padding: 5px 10px;
+ background: rgb(105, 105, 105);
+ color: #FFF;
+ border: none;
+ border-radius: 2px;
+}
+
+/* Comment styles */
+.comment {
+ margin-bottom: 10px;
+}
+
+.coral-plugin-commentcontent-text {
+ margin-bottom: 10px;
+}
+
+
+/* Reply styles */
+
+.comment .reply {
+ margin: 0px 0px 10px 20px;
+}
+
+/* Comment Action Styles */
+
+.commentActions, .replyActions {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.commentActions .material-icons, .replyActions .material-icons {
+ font-size: 12px;
+ vertical-align: middle;
+}
+
+/* Comment count styles */
+.coral-plugin-comment-count-text {
+ margin-bottom: 15px;
+}
+
+.coral-plugin-pubdate-text {
+ color: #CCC;
+}
diff --git a/client/coral-embed-stream/webpack.config.dev.js b/client/coral-embed-stream/webpack.config.dev.js
new file mode 100644
index 000000000..977300ecd
--- /dev/null
+++ b/client/coral-embed-stream/webpack.config.dev.js
@@ -0,0 +1,58 @@
+var path = require('path')
+var webpack = require('webpack')
+
+module.exports = {
+ devtool: 'eval',
+ entry: [
+ 'babel-polyfill',
+ 'webpack-hot-middleware/client',
+ path.join(__dirname, 'src', 'app')
+ ],
+ output: {
+ path: path.join(__dirname, 'dist'),
+ filename: 'bundle.js',
+ publicPath: '/'
+ },
+ resolve: {
+ root: [
+ path.resolve(__dirname, 'src')
+ ],
+ extensions: ['', '.js', '.jsx']
+ },
+ plugins: [
+ new webpack.DefinePlugin({
+ 'process.env': {
+ 'NODE_ENV': JSON.stringify('development')
+ }
+ }),
+ new webpack.HotModuleReplacementPlugin(),
+ new webpack.ProvidePlugin({
+ 'fetch': 'imports?this=>global!exports?global.fetch!whatwg-fetch'
+ }),
+ new webpack.NoErrorsPlugin()
+ ],
+ module: {
+ loaders: [{
+ test: /\.(js|jsx)$/,
+
+ loaders: ['babel'],
+ exclude: /node_modules/,
+ include: path.join(__dirname, '../')
+ }, {
+ test: /\.css$/,
+ loader: 'style-loader!css-loader'
+ }, {
+ test: /\.png$/,
+ loader: 'url-loader?limit=100000'
+ }, {
+ test: /\.(jpg|png|gif|svg)$/,
+ loader: 'file-loader'
+ }, {
+ test: /\.json$/,
+ loader: 'json-loader'
+ }, {
+ test: /\.woff$/,
+ loader: 'url?limit=100000'
+ }]
+ }
+}
diff --git a/client/coral-embed-stream/webpack.config.js b/client/coral-embed-stream/webpack.config.js
new file mode 100644
index 000000000..10076a159
--- /dev/null
+++ b/client/coral-embed-stream/webpack.config.js
@@ -0,0 +1,73 @@
+const path = require('path')
+const webpack = require('webpack')
+const Copy = require('copy-webpack-plugin')
+
+//Keeping this file for reference, it should move to a global webpack.
+
+module.exports = {
+ devtool: 'source-map',
+ entry: [
+ 'babel-polyfill',
+ './src/app'
+ ],
+ output: {
+ path: path.join(__dirname, '..', '..','dist', 'coral-embed-stream'),
+ filename: 'bundle.js',
+ publicPath: '/dist/'
+ },
+ resolve: {
+ root: [
+ path.resolve(__dirname, 'src')
+ ],
+ extensions: ['', '.js', '.jsx']
+ },
+ plugins: [
+ new Copy([{
+ from: './index.html'
+ },
+ {
+ from: './style/default.css'
+ },
+ {
+ from: './public/',
+ to: './'
+ },
+ {
+ from: path.resolve(__dirname, '..', 'coral-framework', 'i18n', 'translations'),
+ to: './translations'
+ }]),
+ new webpack.optimize.OccurenceOrderPlugin(),
+ new webpack.DefinePlugin({
+ 'process.env': {
+ 'NODE_ENV': JSON.stringify('production')
+ }
+ }),
+ new webpack.ProvidePlugin({
+ 'fetch': 'imports?this=>global!exports?global.fetch!whatwg-fetch'
+ }),
+ new webpack.ExtendedAPIPlugin()
+ ],
+ module: {
+ loaders: [{
+ test: /\.(js|jsx)$/,
+ loaders: ['babel'],
+ exclude: /node_modules/,
+ include: path.join(__dirname, '../')
+ }, {
+ test: /\.css$/,
+ loader: 'style-loader!css-loader'
+ }, {
+ test: /\.png$/,
+ loader: 'url-loader?limit=100000'
+ }, {
+ test: /\.jpg$/,
+ loader: 'file-loader'
+ }, {
+ test: /\.json$/,
+ loader: 'json-loader'
+ }, {
+ test: /\.woff$/,
+ loader: 'url?limit=100000'
+ }]
+ }
+}
diff --git a/client/coral-framework/__tests__/store/authReducer.js b/client/coral-framework/__tests__/store/authReducer.js
new file mode 100644
index 000000000..dd9f58501
--- /dev/null
+++ b/client/coral-framework/__tests__/store/authReducer.js
@@ -0,0 +1,31 @@
+import { Map } from 'immutable'
+import {expect} from 'chai'
+import authReducer from '../../store/reducers/auth'
+import * as actions from '../../store/actions/auth'
+
+describe ('authReducer', () => {
+ describe('SET_LOGGED_IN_USER', () => {
+ it('should set a logged in user', () => {
+ const action = {
+ type: actions.SET_LOGGED_IN_USER,
+ user_id: '123'
+ }
+ const store = new Map({})
+ const result = authReducer(store, action)
+ expect(result.get('user')).to.equal(action.user_id)
+ })
+ })
+
+ describe('LOG_OUT_USER', () => {
+ it('should clear the user store', () => {
+ const action = {
+ type: actions.LOG_OUT_USER
+ }
+ const store = new Map({
+ user: '123'
+ })
+ const result = authReducer(store, action)
+ expect(result.get('user')).to.equal(undefined)
+ })
+ })
+})
diff --git a/client/coral-framework/__tests__/store/itemActions.spec.js b/client/coral-framework/__tests__/store/itemActions.spec.js
new file mode 100644
index 000000000..948aee32d
--- /dev/null
+++ b/client/coral-framework/__tests__/store/itemActions.spec.js
@@ -0,0 +1,152 @@
+import 'react'
+import 'redux'
+import {expect} from 'chai'
+import fetchMock from 'fetch-mock'
+import * as actions from '../../store/actions/items'
+import {Map} from 'immutable'
+
+import configureStore from 'redux-mock-store'
+
+const mockStore = configureStore()
+
+describe('itemActions', () => {
+ let store
+ const host = 'http://test.host'
+
+ beforeEach(() => {
+ store = mockStore(new Map({}))
+ fetchMock.restore()
+ })
+
+ describe('getItemsQuery', () => {
+ const query = 'all'
+ const rootId = '1234'
+ const view = 'testView'
+ const response = {results: [
+ {Docs: [
+ {type: 'comment', data: {content: 'stuff'}, item_id: '123'},
+ {type: 'comment', data: {content: 'morestuff'}, item_id: '456'}
+ ]}
+ ]}
+
+ it('should get an item from a query and send the appropriate dispatches', () => {
+ fetchMock.get('*', JSON.stringify(response))
+ return actions.getItemsQuery(query, rootId, view, host)(store.dispatch)
+ .then((res) => {
+ expect(fetchMock.calls().matched[0][0]).to.equal('http://test.host/v1/exec/all/view/testView/1234')
+ expect(res).to.deep.equal(response.results[0].Docs)
+ expect(store.getActions()[0]).to.deep.equal({
+ type: actions.ADD_ITEM,
+ item: response.results[0].Docs[0],
+ item_id: '123'
+ })
+ expect(store.getActions()[1]).to.deep.equal({
+ type: actions.ADD_ITEM,
+ item: response.results[0].Docs[1],
+ item_id: '456'
+ })
+ })
+ })
+ it('should handle an error', () => {
+ fetchMock.get('*', 404)
+ return actions.getItemsQuery(query, rootId, view, host)(store.dispatch)
+ .catch((err) => {
+ expect(err).to.be.truthy
+ })
+ })
+ })
+
+ describe('getItemsArray', () => {
+ const response = {items: [{type: 'comment', item_id: '123'}, {type: 'comment', item_id: '456'}]}
+ const ids = [1, 2]
+
+ it('should get an item from an array of ids and send the appropriate dispatches', () => {
+ fetchMock.get('*', JSON.stringify(response))
+ return actions.getItemsArray(ids, host)(store.dispatch)
+ .then((res) => {
+ expect(res).to.deep.equal(response.items)
+ expect(store.getActions()[0]).to.deep.equal({
+ type: actions.ADD_ITEM,
+ item: {
+ type: 'comment',
+ item_id: '123'
+ },
+ item_id: '123'
+ })
+ expect(store.getActions()[1]).to.deep.equal({
+ type: actions.ADD_ITEM,
+ item: {
+ type: 'comment', item_id: '456'
+ },
+ item_id: '456'
+ })
+ })
+ })
+ it('should handle an error', () => {
+ fetchMock.get('*', 404)
+ return actions.getItemsArray(ids, host)(store.dispatch)
+ .catch((err) => {
+ expect(err).to.be.truthy
+ })
+ })
+ })
+
+ describe('postItem', () => {
+ const item = {
+ type: 'comment',
+ data:{content: 'stuff'}
+ }
+
+ it ('should post an item, return an id, then dispatch that item to the store', () => {
+ fetchMock.post('*', {item_id: '123', type: 'comment', data: {content: 'stuff'}})
+ return actions.postItem(item.data, item.type, undefined, host)(store.dispatch)
+ .then((id) => {
+ expect(fetchMock.calls().matched[0][1]).to.deep.equal(
+ {
+ method: 'POST',
+ body: JSON.stringify({...item, version: 1})
+ }
+ )
+ expect(id).to.equal('123')
+ expect(store.getActions()[0]).to.deep.equal({
+ type: actions.ADD_ITEM,
+ item: {
+ type: 'comment',
+ data: {
+ content: 'stuff'
+ },
+ item_id: '123'
+ },
+ item_id: '123'
+ })
+ })
+ })
+ it('should handle an error', () => {
+ fetchMock.post('*', 404)
+ return actions.postItem(item, host)(store.dispatch)
+ .catch((err) => {
+ expect(err).to.be.truthy
+ })
+ })
+ })
+
+ describe('postAction', () => {
+ it ('should post an action', () => {
+ fetchMock.post('*', 200)
+ return actions.postAction('abc', 'flag', '123', host)(store.dispatch)
+ .then(response => {
+ expect(fetchMock.calls().matched[0][0]).to.equal('http://test.host/v1/action/flag/user/123/on/item/abc')
+ expect(response).to.equal('')
+ })
+ })
+
+ it('should handle an error', () => {
+ fetchMock.post('*', 404)
+ return actions.postItem('abc', 'flag', '123', host)(store.dispatch)
+ .catch((err) => {
+ expect(err).to.be.truthy
+ })
+ })
+
+ })
+})
diff --git a/client/coral-framework/__tests__/store/itemReducer.spec.js b/client/coral-framework/__tests__/store/itemReducer.spec.js
new file mode 100644
index 000000000..8a664ac8c
--- /dev/null
+++ b/client/coral-framework/__tests__/store/itemReducer.spec.js
@@ -0,0 +1,149 @@
+import { Map, fromJS } from 'immutable'
+import {expect} from 'chai'
+import itemsReducer from '../../store/reducers/items'
+import * as actions from '../../store/actions/items'
+
+describe ('itemsReducer', () => {
+ describe('ADD_ITEM', () => {
+ it('should add an item', () => {
+ const action = {
+ type: 'ADD_ITEM',
+ item: {
+ type: 'comment',
+ data: {
+ content: 'stuff'
+ },
+ item_id: '123'
+ },
+ item_id: '123'
+ }
+ const store = new Map({})
+ const result = itemsReducer(store, action)
+ expect(result.get('123').toJS()).to.deep.equal({
+ type: 'comment',
+ data: {
+ content: 'stuff'
+ },
+ item_id: '123'
+ })
+ })
+ })
+
+ describe ('UPDATE_ITEM', () => {
+ it ('should update an item', () => {
+ const action = {
+ type: 'UPDATE_ITEM',
+ property: 'stuff',
+ value: 'things',
+ item_id: '123'
+ }
+ const store = fromJS({
+ '123': {
+ item_id: '123',
+ data: {
+ stuff: 'morestuff'
+ }
+ }
+ })
+ const result = itemsReducer(store, action)
+ expect(result.get('123').toJS()).to.deep.equal({
+ item_id: '123',
+ data: {
+ stuff: 'things'
+ }
+ })
+ })
+ })
+
+ describe('APPEND_ITEM_ARRAY', () => {
+ let action
+ let store
+
+ beforeEach (() => {
+ action = {
+ type: 'APPEND_ITEM_ARRAY',
+ property: 'stuff',
+ value: 'things',
+ item_id: '123'
+ }
+ store = fromJS({
+ '123': {
+ item_id: '123',
+ data: {
+ stuff: ['morestuff']
+ }
+ }
+ })
+ })
+ it ('should append to an existing array', () => {
+ const result = itemsReducer(store, action)
+ expect(result.get('123').toJS()).to.deep.equal({
+ item_id: '123',
+ data: {
+ stuff: ['morestuff', 'things']
+ }
+ })
+ })
+ it ('should create a new array', () => {
+ store = fromJS({
+ '123': {
+ item_id: '123',
+ data: {}
+ }
+ })
+ const result = itemsReducer(store, action)
+ expect(result.get('123').toJS()).to.deep.equal({
+ item_id: '123',
+ data: {
+ stuff: ['things']
+ }
+ })
+ })
+ })
+
+ describe('APPEND_ITEM_RELATED', () => {
+ let action
+ let store
+
+ beforeEach (() => {
+ action = {
+ type: 'APPEND_ITEM_RELATED',
+ property: 'stuff',
+ value: 'things',
+ item_id: '123'
+ }
+ store = fromJS({
+ '123': {
+ item_id: '123',
+ related: {
+ stuff: ['morestuff']
+ }
+ }
+ })
+ })
+ it ('should append to an existing array', () => {
+ const result = itemsReducer(store, action)
+ expect(result.get('123').toJS()).to.deep.equal({
+ item_id: '123',
+ related: {
+ stuff: ['morestuff', 'things']
+ }
+ })
+ })
+ it ('should create a new array', () => {
+ store = fromJS({
+ '123': {
+ item_id: '123',
+ related: {}
+ }
+ })
+ const result = itemsReducer(store, action)
+ expect(result.get('123').toJS()).to.deep.equal({
+ item_id: '123',
+ related: {
+ stuff: ['things']
+ }
+ })
+ })
+ })
+})
diff --git a/client/coral-framework/__tests__/store/notificationReducer.spec.js b/client/coral-framework/__tests__/store/notificationReducer.spec.js
new file mode 100644
index 000000000..4f37034a6
--- /dev/null
+++ b/client/coral-framework/__tests__/store/notificationReducer.spec.js
@@ -0,0 +1,35 @@
+import { Map } from 'immutable'
+import {expect} from 'chai'
+import notificationReducer from '../../store/reducers/notification'
+import * as actions from '../../store/actions/notification'
+
+describe ('notificationsReducer', () => {
+ describe('ADD_NOTIFICATION', () => {
+ it('should add a notification', () => {
+ const action = {
+ type: actions.ADD_NOTIFICATION,
+ text: 'Test notification',
+ notifType: 'test'
+ }
+ const store = new Map({})
+ const result = notificationReducer(store, action)
+ expect(result.get('text')).to.equal(action.text)
+ expect(result.get('type')).to.equal(action.notifType)
+ })
+ })
+
+ describe('CLEAR_NOTIFICATION', () => {
+ it('should clear a notification', () => {
+ const action = {
+ type: actions.CLEAR_NOTIFICATION
+ }
+ const store = new Map({
+ text: 'Test notification',
+ type: 'test'
+ })
+ const result = notificationReducer(store, action)
+ expect(result.get('text')).to.equal(undefined)
+ expect(result.get('type')).to.equal(undefined)
+ })
+ })
+})
diff --git a/client/coral-framework/i18n/i18n.js b/client/coral-framework/i18n/i18n.js
new file mode 100644
index 000000000..d3e7d92b8
--- /dev/null
+++ b/client/coral-framework/i18n/i18n.js
@@ -0,0 +1,74 @@
+import timeago from 'timeago.js'
+import esTA from 'timeago.js/locales/es'
+
+/**
+ * Default locales, this should be overriden by config file
+ */
+
+class i18n {
+ constructor (translations) {
+ /**
+ * Register locales
+ */
+
+ this.locales = {'en': 'en', 'es': 'es'}
+ timeago.register('es_ES', esTA)
+ this.timeagoInstance = new timeago()
+ /**
+ * Load translations
+ */
+ let trans = translations || { en: {} }
+
+ try {
+ const locale = localStorage.getItem('locale') || navigator.language
+ localStorage.setItem('locale', locale)
+ const lang = this.locales[locale.split('-')[0]] || 'en'
+ this.translations = trans[lang]
+ } catch (err) {
+ this.translations = trans['en']
+ }
+
+ this.setLocale = (locale) => {
+ try {
+ localStorage.setItem('locale', locale)
+ } catch (err) {}
+ }
+
+ this.getLocale = () => (
+ localStorage.getItem('locale') || navigator.locale || 'en-US'
+ )
+
+ /**
+ * Expose the translation function
+ *
+ * it takes a string with the translation key and returns
+ * the translation value or the key itself if not found
+ * it works with nested translations (my.page.title)
+ */
+
+ this.t = (key) => {
+ const arr = key.split('.')
+ let translation = this.translations
+ try {
+ for (var i = 0; i < arr.length; i++) translation = translation[arr[i]]
+ } catch (error) {
+ console.warn(`${key} language key not set`)
+ return key
+ }
+
+ const val = String(translation)
+ if (val) {
+ return val
+ } else {
+ console.warn(`${key} language key not set`)
+ return key
+ }
+ }
+
+ this.timeago = (time) => {
+ return this.timeagoInstance.format(time)
+ }
+ }
+}
+
+export default i18n
diff --git a/client/coral-framework/index.js b/client/coral-framework/index.js
new file mode 100644
index 000000000..537693d97
--- /dev/null
+++ b/client/coral-framework/index.js
@@ -0,0 +1,17 @@
+import Notification from './notification/Notification'
+import store from './store/store'
+import {fetchConfig} from './store/actions/config'
+import * as itemActions from './store/actions/items'
+import I18n from './i18n/i18n'
+import * as notificationActions from './store/actions/notification'
+import * as authActions from './store/actions/auth'
+
+export {
+ Notification,
+ store,
+ fetchConfig,
+ itemActions,
+ I18n,
+ notificationActions,
+ authActions
+}
diff --git a/client/coral-framework/mocks.json b/client/coral-framework/mocks.json
new file mode 100644
index 000000000..ce455e922
--- /dev/null
+++ b/client/coral-framework/mocks.json
@@ -0,0 +1,43 @@
+{
+ "query": [
+ {
+ "item_id": "assetTest",
+ "type": "asset",
+ "data": {
+ "url": "http://coralproject.net"
+ },
+ "related": {
+ "comment": [
+ "abc",
+ "def"
+ ]
+ }
+ },
+ {
+ "item_id": "abc",
+ "type": "comment",
+ "data": {
+ "content": "Sample comment"
+ },
+ "related":{
+ "child": [
+ "xyz"
+ ]
+ }
+ },
+ {
+ "item_id": "xyz",
+ "type": "comment",
+ "data": {
+ "content": "Sample reply"
+ }
+ },
+ {
+ "item_id": "def",
+ "type": "comment",
+ "data": {
+ "content": "Another reply"
+ }
+ }
+ ]
+}
diff --git a/client/coral-framework/notification/Notification.js b/client/coral-framework/notification/Notification.js
new file mode 100644
index 000000000..5d26e3dbf
--- /dev/null
+++ b/client/coral-framework/notification/Notification.js
@@ -0,0 +1,19 @@
+import React from 'react'
+
+const Notification = (props) => {
+ if (props.notification.text) {
+ setTimeout(() => {
+ props.clearNotification()
+ }, props.notifLength)
+ }
+ return
+ {
+ props.notification.text &&
+
+ }
+
+}
+
+export default Notification
diff --git a/client/coral-framework/store/actions/auth.js b/client/coral-framework/store/actions/auth.js
new file mode 100644
index 000000000..dfdf2c7cd
--- /dev/null
+++ b/client/coral-framework/store/actions/auth.js
@@ -0,0 +1,17 @@
+/* Auth Actions */
+
+export const SET_LOGGED_IN_USER = 'SET_LOGGED_IN_USER'
+export const LOG_OUT_USER = 'LOG_OUT_USER'
+
+export const setLoggedInUser = (user_id) => {
+ return {
+ type: SET_LOGGED_IN_USER,
+ user_id
+ }
+}
+
+export const LogOutUser = () => {
+ return {
+ type: LOG_OUT_USER
+ }
+}
diff --git a/client/coral-framework/store/actions/config.js b/client/coral-framework/store/actions/config.js
new file mode 100644
index 000000000..243cb9049
--- /dev/null
+++ b/client/coral-framework/store/actions/config.js
@@ -0,0 +1,28 @@
+/* @flow */
+
+import { fromJS } from 'immutable'
+
+/**
+ * Action name constants
+ */
+
+export const FETCH_CONFIG_REQUEST = 'FETCH_CONFIG_REQUEST'
+export const FETCH_CONFIG_FAILED = 'FETCH_CONFIG_FAILED'
+export const FETCH_CONFIG_SUCCESS = 'FETCH_CONFIG_SUCCESS'
+
+/**
+ * Action creators
+ */
+
+export const fetchConfig = () => async (dispatch) => {
+ dispatch({ type: FETCH_CONFIG_REQUEST })
+
+ try {
+ //TODO: Replace with fetching config from backend
+ // const response = await fetch(`./talk.config.json`)
+ // const json = await response.json()
+ dispatch({ type: FETCH_CONFIG_SUCCESS, config: fromJS({}) })
+ } catch (error) {
+ dispatch({ type: FETCH_CONFIG_FAILED })
+ }
+}
diff --git a/client/coral-framework/store/actions/items.js b/client/coral-framework/store/actions/items.js
new file mode 100644
index 000000000..fe45feb0c
--- /dev/null
+++ b/client/coral-framework/store/actions/items.js
@@ -0,0 +1,221 @@
+/* Item Actions */
+
+import { fromJS } from 'immutable'
+import mocks from '../../mocks.json'
+
+/**
+ * Action name constants
+ */
+
+export const ADD_ITEM = 'ADD_ITEM'
+export const UPDATE_ITEM = 'UPDATE_ITEM'
+export const APPEND_ITEM_ARRAY = 'APPEND_ITEM_ARRAY'
+export const APPEND_ITEM_RELATED = 'APPEND_ITEM_RELATED'
+
+/**
+ * Action creators
+ */
+
+ /*
+ * Adds an item to the local store without posting it to the server
+ * Useful for optimistic posting, etc.
+ *
+ * @params
+ * item - the item to be posted
+ *
+ */
+
+export const addItem = (item) => {
+ if (!item.item_id) {
+ console.warn('addItem called without an item id.')
+ }
+ return {
+ type: ADD_ITEM,
+ item: item,
+ item_id: item.item_id
+ }
+}
+
+/*
+* Updates an item in the local store without posting it to the server
+* Useful for item-level toggles, etc.
+*
+* @params
+* item_id - the id of the item to be posted
+* property - the property to be updated
+* value - the value that the property should be set to
+*
+*/
+
+
+export const updateItem = (item_id, property, value) => {
+ return {
+ type: UPDATE_ITEM,
+ item_id,
+ property,
+ value
+ }
+}
+
+export const appendItemArray = (item_id, property, value) => {
+ return {
+ type: APPEND_ITEM_ARRAY,
+ item_id,
+ property,
+ value
+ }
+}
+
+
+export const appendItemRelated = (item_id, property, value) => {
+ return {
+ type: APPEND_ITEM_RELATED,
+ item_id,
+ property,
+ value
+ }
+}
+
+/*
+* Get Items from Query
+* Gets a set of items from a predefined query
+*
+* @params
+* Query - a predefiend query for retreiving items
+*
+* @returns
+* A promise resolving to a set of items
+*
+* @dispatches
+* A set of items to the item store
+*/
+export function getItemsQuery (rootId) {
+ return (dispatch) => {
+ // return fetch('/v1/exec/view/' + rootId)
+ // .then(
+ // response => {
+ // return response.ok ? response.json() : Promise.reject(response.status + ' ' + response.statusText)
+ // }
+ // )
+ // .then((json) => {
+ // let items = json.results[0].Docs
+ // for (var i = 0; i < items.length; i++) {
+ // dispatch(addItem(items[i]))
+ // }
+ // return (items)
+ // })
+ console.log('Loading mock data', mocks);
+ let mockData = mocks.query
+ for (var i=0; i < mockData.length; i++ ) {
+ dispatch(addItem(mockData[i]))
+ }
+ }
+}
+
+/*
+* Get Items Array
+* Gets a set of items from an array of item ids
+*
+* @params
+* Query - a predefiend query for retreiving items
+*
+* @returns
+* A promise resolving to a set of items
+*
+* @dispatches
+* A set of items to the item store
+*/
+
+export function getItemsArray (ids) {
+ return (dispatch) => {
+ return fetch('/v1/item/' + ids)
+ .then(
+ response => {
+ return response.ok ? response.json()
+ : Promise.reject(response.status + ' ' + response.statusText)
+ }
+ )
+ .then((json) => {
+ for (var i = 0; i < json.items.length; i++) {
+ dispatch(addItem(json.items[i]))
+ }
+ return json.items
+ })
+ }
+}
+
+/*
+* PutItem
+* Puts an item
+*
+* @params
+* Item - the item to be put
+*
+* @returns
+* A promise resolving to an item is
+*
+* @dispatches
+* The newly put item to the item store
+*/
+
+export function postItem (data, type, id) {
+ return (dispatch) => {
+ let item = {
+ type,
+ data,
+ version: 1
+ }
+ if (id) {
+ item.item_id = id
+ }
+ let options = {
+ method: 'POST',
+ body: JSON.stringify(item)
+ }
+ return fetch('/v1/item', options)
+ .then(
+ response => {
+ return response.ok ? response.json()
+ : Promise.reject(response.status + ' ' + response.statusText)
+ }
+ )
+ .then((json) => {
+ // Patch until ID is returned from backend
+ dispatch(addItem(json))
+ return json.item_id
+ })
+ }
+}
+
+//http://localhost:16180/v1/action/flag/user/user_89654/on/item/87e418c5-aafb-4eb7-9ce4-78f28793782a
+
+/*
+* PostAction
+* Posts an action to an item
+*
+* @params
+* item_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 postAction (item, action, user) {
+ return (dispatch) => {
+ let options = {
+ method: 'POST'
+ }
+ dispatch(appendItemArray(item, action, user))
+ return fetch('/v1/action/' + action + '/user/' + user + '/on/item/' + item, options)
+ .then(
+ response => {
+ return response.ok ? response.text()
+ : Promise.reject(response.status + ' ' + response.statusText)
+ }
+ )
+ }
+}
diff --git a/client/coral-framework/store/actions/notification.js b/client/coral-framework/store/actions/notification.js
new file mode 100644
index 000000000..0503c2a64
--- /dev/null
+++ b/client/coral-framework/store/actions/notification.js
@@ -0,0 +1,18 @@
+/* Notification Actions */
+
+export const ADD_NOTIFICATION = 'ADD_NOTIFICATION'
+export const CLEAR_NOTIFICATION = 'CLEAR_NOTIFICATION'
+
+export const addNotification = (notifType, text) => {
+ return {
+ type: ADD_NOTIFICATION,
+ notifType,
+ text
+ }
+}
+
+export const clearNotification = () => {
+ return {
+ type: CLEAR_NOTIFICATION
+ }
+}
diff --git a/client/coral-framework/store/reducers/auth.js b/client/coral-framework/store/reducers/auth.js
new file mode 100644
index 000000000..f25c88749
--- /dev/null
+++ b/client/coral-framework/store/reducers/auth.js
@@ -0,0 +1,17 @@
+/* Auth */
+
+import * as actions from '../actions/auth'
+import { fromJS } from 'immutable'
+
+const initialState = fromJS({})
+
+export default (state = initialState, action) => {
+ switch (action.type) {
+ case actions.SET_LOGGED_IN_USER:
+ return state.set('user', action.user_id)
+ case actions.LOG_OUT_USER:
+ return initialState
+ default:
+ return state
+ }
+}
diff --git a/client/coral-framework/store/reducers/config.js b/client/coral-framework/store/reducers/config.js
new file mode 100644
index 000000000..d9ee5e763
--- /dev/null
+++ b/client/coral-framework/store/reducers/config.js
@@ -0,0 +1,25 @@
+/* @flow */
+
+import { Map, fromJS } from 'immutable'
+import * as actions from '../actions/config'
+
+const initialState = Map({
+ features: Map({})
+})
+
+export default (state = initialState, action) => {
+ switch(action.type) {
+ case actions.FETCH_CONFIG_REQUEST:
+ return state.set('loading', true)
+
+ case actions.FETCH_CONFIG_FAILED:
+ return state.set('loading', false)
+
+ // Override config if worked
+ case actions.FETCH_CONFIG_SUCCESS:
+ return action.config.set('loading', false)
+
+ default:
+ return state
+ }
+}
diff --git a/client/coral-framework/store/reducers/index.js b/client/coral-framework/store/reducers/index.js
new file mode 100644
index 000000000..5774a4d9d
--- /dev/null
+++ b/client/coral-framework/store/reducers/index.js
@@ -0,0 +1,18 @@
+/* @flow */
+
+import { combineReducers } from 'redux'
+import config from './config'
+import items from './items'
+import notification from './notification'
+import auth from './auth'
+
+/**
+ * Expose the combined main reducer
+ */
+
+export default combineReducers({
+ config,
+ items,
+ notification,
+ auth
+})
diff --git a/client/coral-framework/store/reducers/items.js b/client/coral-framework/store/reducers/items.js
new file mode 100644
index 000000000..e8f0bbbdf
--- /dev/null
+++ b/client/coral-framework/store/reducers/items.js
@@ -0,0 +1,29 @@
+/* Items Reducer */
+
+import { Map, fromJS } from 'immutable'
+import * as actions from '../actions/items'
+
+const initialState = fromJS({})
+
+export default (state = initialState, action) => {
+ switch (action.type) {
+ case actions.ADD_ITEM:
+ return state.set(action.item_id, fromJS(action.item))
+ case actions.UPDATE_ITEM:
+ return state.updateIn([action.item_id, 'data', action.property], () =>
+ fromJS(action.value)
+ )
+ case actions.APPEND_ITEM_ARRAY:
+ return state.updateIn([action.item_id, 'data', action.property], (prop) => {
+ return prop ? prop.push(action.value) : fromJS([action.value])
+ }
+ )
+ case actions.APPEND_ITEM_RELATED:
+ return state.updateIn([action.item_id, 'related', action.property], (prop) => {
+ return prop ? prop.push(action.value) : fromJS([action.value])
+ }
+ )
+ default:
+ return state
+ }
+}
diff --git a/client/coral-framework/store/reducers/notification.js b/client/coral-framework/store/reducers/notification.js
new file mode 100644
index 000000000..86c65c9e7
--- /dev/null
+++ b/client/coral-framework/store/reducers/notification.js
@@ -0,0 +1,17 @@
+/* Items Notifications */
+
+import * as actions from '../actions/notification'
+import { fromJS } from 'immutable'
+
+const initialState = fromJS({})
+
+export default (state = initialState, action) => {
+ switch (action.type) {
+ case actions.ADD_NOTIFICATION:
+ return state.set('text', action.text).set('type', action.notifType)
+ case actions.CLEAR_NOTIFICATION:
+ return initialState
+ default:
+ return state
+ }
+}
diff --git a/client/coral-framework/store/store.js b/client/coral-framework/store/store.js
new file mode 100644
index 000000000..42678cddd
--- /dev/null
+++ b/client/coral-framework/store/store.js
@@ -0,0 +1,10 @@
+
+import { createStore, applyMiddleware } from 'redux'
+import thunk from 'redux-thunk'
+import mainReducer from './reducers'
+
+export default createStore(
+ mainReducer,
+ window.devToolsExtension && window.devToolsExtension(),
+ applyMiddleware(thunk)
+)
diff --git a/client/coral-plugin-comment-count/CommentCount.js b/client/coral-plugin-comment-count/CommentCount.js
new file mode 100644
index 000000000..861fc5df3
--- /dev/null
+++ b/client/coral-plugin-comment-count/CommentCount.js
@@ -0,0 +1,23 @@
+import React from 'react'
+
+const name = 'coral-plugin-comment-count'
+
+const CommentCount = ({items, item_id}) => {
+ let count = 0
+ if (items[item_id]) {
+ count += items[item_id].related.comment.length
+ }
+ const itemKeys = Object.keys(items)
+ for (var i=0; i < itemKeys.length; i++) {
+ const item = items[itemKeys[i]]
+ if (item.type === 'comment' && item.related && item.related.child) {
+ count += item.related.child.length
+ }
+ }
+
+ return
+ {count + ' ' + (count === 1 ? 'Comment':'Comments')}
+
+}
+
+export default CommentCount
diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js
new file mode 100644
index 000000000..a32144407
--- /dev/null
+++ b/client/coral-plugin-commentbox/CommentBox.js
@@ -0,0 +1,84 @@
+import React, {Component, PropTypes} from 'react'
+import {I18n} from '../coral-framework'
+
+const name='coral-plugin-commentbox'
+
+class CommentBox extends Component {
+
+ static propTypes = {
+ postItem: PropTypes.func,
+ updateItem: PropTypes.func,
+ item_id: PropTypes.string,
+ comments: PropTypes.array,
+ reply: PropTypes.bool
+ }
+
+ state = {
+ content: ''
+ }
+
+ postComment = () => {
+ const {postItem, updateItem, item_id, reply, addNotification, appendItemRelated} = this.props
+ let comment = {
+ content: this.state.content
+ }
+ let related
+ if (reply) {
+ comment.parent_id = item_id
+ related = 'child'
+ } else {
+ comment.asset_id = item_id
+ related = 'comment'
+ }
+ updateItem(item_id, 'showReply', false)
+ postItem(comment, 'comment')
+ .then((id) => {
+ appendItemRelated(item_id, related, id)
+ addNotification('success', 'Your comment has been posted.')
+ }).catch((err) => console.error(err))
+ this.setState({content: ''})
+ }
+
+ render () {
+ const {styles, reply} = this.props
+ // How to handle language in plugins? Should we have a dependency on our central translation file?
+ return
+
+
+
+
+
+
+
+ }
+}
+
+export default CommentBox
+
+const lang = new I18n({
+ en: {
+ post: 'Post'
+ },
+ es: {
+ post: 'Publicar'
+ }
+})
diff --git a/client/coral-plugin-commentbox/__tests__/commentBox.spec.js b/client/coral-plugin-commentbox/__tests__/commentBox.spec.js
new file mode 100644
index 000000000..4a1b5b397
--- /dev/null
+++ b/client/coral-plugin-commentbox/__tests__/commentBox.spec.js
@@ -0,0 +1,26 @@
+import React from 'react'
+import {shallow, mount} from 'enzyme'
+import {expect} from 'chai'
+import CommentBox from '../CommentBox'
+
+describe('CommentBox', () => {
+ let comment
+ let render
+ beforeEach(() => {
+ comment = {}
+ const postItem = (item) => {
+ comment.posted=item
+ return Promise.resolve(4)
+ }
+ render = shallow( comment.text=e.target.value}
+ item_id={'1'}
+ comments={['1', '2', '3']}/>)
+ })
+
+ it('should render the CommentBox appropriately', () => {
+ expect(render.contains('