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 && + + {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
+
+ +