From 79dcac5725662ef1f03565a8cfdffcbd581d0b42 Mon Sep 17 00:00:00 2001 From: David Jay Date: Tue, 1 Nov 2016 13:50:49 -0700 Subject: [PATCH] Adding coral-embed-stream and dependencies --- .babelrc | 9 +- client/coral-embed-stream/.gitignore | 0 client/coral-embed-stream/dev-server.js | 53 +++++ client/coral-embed-stream/index.html | 13 ++ .../public/talk.config.json | 6 + .../coral-embed-stream/src/CommentStream.js | 168 ++++++++++++++ client/coral-embed-stream/src/app.js | 13 ++ client/coral-embed-stream/style/default.css | 113 +++++++++ .../coral-embed-stream/webpack.config.dev.js | 58 +++++ client/coral-embed-stream/webpack.config.js | 71 ++++++ .../DynamicContainer.spec.js | 184 +++++++++++++++ .../dynamic-containers/MapContainer.spec.js | 54 +++++ .../dynamic-containers/RootContainer.spec.js | 50 ++++ .../__tests__/store/authReducer.js | 31 +++ .../__tests__/store/itemActions.spec.js | 152 +++++++++++++ .../__tests__/store/itemReducer.spec.js | 149 ++++++++++++ .../store/notificationReducer.spec.js | 35 +++ .../dynamic-containers/DynamicContainer.js | 156 +++++++++++++ .../dynamic-containers/MapContainer.js | 42 ++++ .../dynamic-containers/README.md | 171 ++++++++++++++ .../dynamic-containers/RootContainer.js | 44 ++++ client/coral-framework/i18n/i18n.js | 74 ++++++ client/coral-framework/index.js | 23 ++ .../notification/Notification.js | 19 ++ client/coral-framework/store/actions/auth.js | 17 ++ .../coral-framework/store/actions/config.js | 27 +++ client/coral-framework/store/actions/items.js | 215 ++++++++++++++++++ .../store/actions/notification.js | 18 ++ client/coral-framework/store/reducers/auth.js | 17 ++ .../coral-framework/store/reducers/config.js | 25 ++ .../coral-framework/store/reducers/index.js | 18 ++ .../coral-framework/store/reducers/items.js | 29 +++ .../store/reducers/notification.js | 17 ++ client/coral-framework/store/store.js | 10 + .../CommentCount.js | 21 ++ client/coral-plugin-commentbox/CommentBox.js | 84 +++++++ .../__tests__/commentBox.spec.js | 26 +++ .../CommentContent.js | 10 + .../__tests__/commentContent.spec.js | 11 + client/coral-plugin-flags/FlagButton.js | 35 +++ client/coral-plugin-pubdate/PubDate.js | 11 + client/coral-plugin-replies/ReplyBox.js | 21 ++ client/coral-plugin-replies/ReplyButton.js | 23 ++ client/coral-plugin-replies/index.js | 7 + package.json | 31 +++ 45 files changed, 2359 insertions(+), 2 deletions(-) create mode 100644 client/coral-embed-stream/.gitignore create mode 100644 client/coral-embed-stream/dev-server.js create mode 100644 client/coral-embed-stream/index.html create mode 100644 client/coral-embed-stream/public/talk.config.json create mode 100644 client/coral-embed-stream/src/CommentStream.js create mode 100644 client/coral-embed-stream/src/app.js create mode 100644 client/coral-embed-stream/style/default.css create mode 100644 client/coral-embed-stream/webpack.config.dev.js create mode 100644 client/coral-embed-stream/webpack.config.js create mode 100644 client/coral-framework/__tests__/dynamic-containers/DynamicContainer.spec.js create mode 100644 client/coral-framework/__tests__/dynamic-containers/MapContainer.spec.js create mode 100644 client/coral-framework/__tests__/dynamic-containers/RootContainer.spec.js create mode 100644 client/coral-framework/__tests__/store/authReducer.js create mode 100644 client/coral-framework/__tests__/store/itemActions.spec.js create mode 100644 client/coral-framework/__tests__/store/itemReducer.spec.js create mode 100644 client/coral-framework/__tests__/store/notificationReducer.spec.js create mode 100644 client/coral-framework/dynamic-containers/DynamicContainer.js create mode 100644 client/coral-framework/dynamic-containers/MapContainer.js create mode 100644 client/coral-framework/dynamic-containers/README.md create mode 100644 client/coral-framework/dynamic-containers/RootContainer.js create mode 100644 client/coral-framework/i18n/i18n.js create mode 100644 client/coral-framework/index.js create mode 100644 client/coral-framework/notification/Notification.js create mode 100644 client/coral-framework/store/actions/auth.js create mode 100644 client/coral-framework/store/actions/config.js create mode 100644 client/coral-framework/store/actions/items.js create mode 100644 client/coral-framework/store/actions/notification.js create mode 100644 client/coral-framework/store/reducers/auth.js create mode 100644 client/coral-framework/store/reducers/config.js create mode 100644 client/coral-framework/store/reducers/index.js create mode 100644 client/coral-framework/store/reducers/items.js create mode 100644 client/coral-framework/store/reducers/notification.js create mode 100644 client/coral-framework/store/store.js create mode 100644 client/coral-plugin-comment-count/CommentCount.js create mode 100644 client/coral-plugin-commentbox/CommentBox.js create mode 100644 client/coral-plugin-commentbox/__tests__/commentBox.spec.js create mode 100644 client/coral-plugin-commentcontent/CommentContent.js create mode 100644 client/coral-plugin-commentcontent/__tests__/commentContent.spec.js create mode 100644 client/coral-plugin-flags/FlagButton.js create mode 100644 client/coral-plugin-pubdate/PubDate.js create mode 100644 client/coral-plugin-replies/ReplyBox.js create mode 100644 client/coral-plugin-replies/ReplyButton.js create mode 100644 client/coral-plugin-replies/index.js diff --git a/.babelrc b/.babelrc index f8b877ec3..69a84ba05 100644 --- a/.babelrc +++ b/.babelrc @@ -6,7 +6,12 @@ ], "plugins": [ ["transform-decorators-legacy"], - ["transform-react-jsx", { "pragma": "h" }], - ["transform-object-assign"] + ["transform-react-jsx"], + ["transform-object-assign"], + ["transform-class-properties"], + ["transform-flow-strip-types"], + ["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..b33d633fc --- /dev/null +++ b/client/coral-embed-stream/dev-server.js @@ -0,0 +1,53 @@ +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('/talk.config.json', function (req, res) { + res.sendFile(path.join(__dirname, 'talk.config.json')) +}) + +app.get('/default.css', function (req, res) { + res.sendFile(path.join(__dirname, '/style/default.css')) +}) + +app.get('/translations/en.json', function (req, res) { + res.sendFile(path.join(__dirname, '..', 'coral-framework', 'i18n', 'translations', 'en.json')) +}) + +app.get('/translations/es.json', function (req, res) { + res.sendFile(path.join(__dirname, '..', 'coral-framework', 'i18n', 'translations', 'es.json')) +}) + +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/public/talk.config.json b/client/coral-embed-stream/public/talk.config.json new file mode 100644 index 000000000..e88592f55 --- /dev/null +++ b/client/coral-embed-stream/public/talk.config.json @@ -0,0 +1,6 @@ +{ + "coralHost": "http://localhost:16180", + "notifLength": 4500, + "view": "comment_stream", + "query": "all" +} diff --git a/client/coral-embed-stream/src/CommentStream.js b/client/coral-embed-stream/src/CommentStream.js new file mode 100644 index 000000000..88ccd9faa --- /dev/null +++ b/client/coral-embed-stream/src/CommentStream.js @@ -0,0 +1,168 @@ +import React, {Component, PropTypes} from 'react' +import { + RootContainer, + Container, + MapContainer, + 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' + +console.log(authActions); +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: (host) => (data, type, id) => { + return dispatch(postItem(data, type, id, host)) + }, + getItemsQuery: (host) => (query, rootId, view) => { + return dispatch(getItemsQuery(query, rootId, view, host)) + }, + addNotification: (type, text) => { + return dispatch(addNotification(type, text)) + }, + clearNotification: () => { + return dispatch(clearNotification()) + }, + setLoggedInUser: (user_id) => { + return dispatch(setLoggedInUser(user_id)) + }, + postAction: (host) => (item, action, user) => { + return dispatch(postAction(item, action, user, host)) + }, + 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 }) + } + + render () { + if (this.props.config.coralHost) { + const host = this.props.config.coralHost + if (Object.keys(this.props.items).length === 0) { + // Loading mock asset + this.props.postItem(host)({ + comments: [], + url: 'http://coralproject.net' + }, 'asset', 'assetTest') + + // Loading mock user + this.props.postItem(host)({name: 'Ban Ki-Moon'}, 'user', 'user_8989') + .then((id) => { + this.props.setLoggedInUser(id) + }) + } + + + // TODO: Replace teststream id with id from params + + return + + + + + + +
+ + + + + + + + + +
+ + + + + + +
+
+
+
+ +
+ } else { + 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..95ed258cd --- /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', + './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..2b1d8ab8b --- /dev/null +++ b/client/coral-embed-stream/webpack.config.js @@ -0,0 +1,71 @@ +const path = require('path') +const webpack = require('webpack') +const Copy = require('copy-webpack-plugin') + +module.exports = { + devtool: 'source-map', + entry: [ + 'babel-polyfill', + './src/app' + ], + output: { + path: path.join(__dirname, 'dist'), + 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__/dynamic-containers/DynamicContainer.spec.js b/client/coral-framework/__tests__/dynamic-containers/DynamicContainer.spec.js new file mode 100644 index 000000000..55b6f32e1 --- /dev/null +++ b/client/coral-framework/__tests__/dynamic-containers/DynamicContainer.spec.js @@ -0,0 +1,184 @@ +import {expect} from 'chai' +import React from 'react' +import DynamicContainer from '../../dynamic-containers/DynamicContainer' +import {shallow, mount} from 'enzyme' + +describe('DynamicContainer', () => { + let props + beforeEach(() => { + props = { + item_id: '1', + items: { + '1': { + item_id: '1', + type: 'comment', + data: { + content: 'stuff' + }, + related: { + author: '2', + likes: ['4', '5'] + } + + }, + '2': { + item_id: '2', + type: 'user', + data: { + name: 'Janice' + }, + related: { + likes: ['4', '5'], + employer: '3' + } + }, + '3': { + item_id: '3', + type: 'employer', + data: { + name: 'Coral' + } + }, + '4': { + item_id: '4', + type: 'like', + data: { + name: 'Regina' + } + }, + '5': { + item_id: '5', + type: 'like', + data: { + name: 'Fatima' + } + } + }, + name: 'test' + } + }) + describe('mapPropsFromItems', () => { + it('should retrieve objects based on a simple graphQL query', () => { + let query = '(type: \'comment\'){content}' + let output = new DynamicContainer(props).getPropsFromItems(query, 1) + expect(output).to.deep.equal({ + content: 'stuff' + }) + }) + it('should traverse the graph and return an appropriately formatted set of properties', () => { + let query = '(type: \'comment\'){content,author(type: \'user\'){name}}' + let output = new DynamicContainer(props).getPropsFromItems(query, 1) + expect(output).to.deep.equal({ + content: 'stuff', + author: { + name: 'Janice' + } + }) + }) + it('should traverse a deeply nested query', () => { + let query = '(type: \'comment\'){content,author(type:"user"){employer(type:"employer"){name}}}' + let output = new DynamicContainer(props).getPropsFromItems(query, 1) + expect(output).to.deep.equal({ + content: 'stuff', + author: { + employer: { + name: 'Coral' + } + } + }) + }) + it('should traverse a one to many relationship', () => { + const query = '(type: \'comment\'){author(type: \'user\'){likes(type: \'like\'){name}},content}' + const output = new DynamicContainer(props).getPropsFromItems(query, 1) + expect(output).to.deep.equal({ + content: 'stuff', + author: { + likes: [ + { + name: 'Regina' + }, + { + name: 'Fatima' + } + ] + } + }) + }) + it('should traverse complex one to many relationships', () => { + const query = '(type: "comment"){author(type: "user"){employer(type: "employer"){name},likes(type: "likes"){name},name},content,likes(type: "likes"){item_id}}' + const output = new DynamicContainer(props).getPropsFromItems(query, 1) + expect(output).to.deep.equal({ + author: { + employer: { + name: 'Coral' + }, + likes: [ + { + name: 'Regina' + }, + { + name: 'Fatima' + } + ], + name: 'Janice' + }, + content: 'stuff', + likes: [ + { + item_id: '4' + }, + { + item_id: '5' + } + ] + }) + }) + + it('should traverse complex relationships efficiently', () => { + let query = '(type: \'comment\'){content}' + const start = new Date().getTime() + for (var i = 0; i < 1000; i++) { + new DynamicContainer(props).getPropsFromItems(query, 1) + } + const end = new Date().getTime() + expect(end-start).to.be.below(100) + }) + + it('should not require a type declaration at the beginning of a query', () => { + let query = '{content}' + const output = new DynamicContainer(props).getPropsFromItems(query, 1) + expect(output).to.deep.equal({ + content: 'stuff' + }) + }) + + it('should return an undefined object if a relationship traversal is undefined', () => { + let query = '{unicorns(type:"notExist"){rainbows}}' + const output = new DynamicContainer(props).getPropsFromItems(query, 1) + expect(output).to.deep.equal({ + unicorns: undefined + }) + }) + + it('should return an undefined object if a parameter is undefined', () => { + let query = '{does_not_exist}' + const output = new DynamicContainer(props).getPropsFromItems(query, 1) + expect(output).to.deep.equal({ + does_not_exist: undefined + }) + }) + }) + + describe('render', () => { + it('should render a set of child components with the appropriate data', () => { + const render = shallow( +
+
+ ) + expect(render.node.props.children[0].props).to.have.property('content') + .and.to.equal('stuff') + expect(render.node.props.children[1].props).to.have.property('author') + .and.to.deep.equal({name: 'Janice'}) + }) + }) +}) diff --git a/client/coral-framework/__tests__/dynamic-containers/MapContainer.spec.js b/client/coral-framework/__tests__/dynamic-containers/MapContainer.spec.js new file mode 100644 index 000000000..f6bb29477 --- /dev/null +++ b/client/coral-framework/__tests__/dynamic-containers/MapContainer.spec.js @@ -0,0 +1,54 @@ +import React from 'react' +import {shallow} from 'enzyme' +import {expect} from 'chai' +import MapContainer from '../../dynamic-containers/MapContainer' + +describe('', () => { + let items + beforeEach (() => { + items = { + a: { + item_id: 'a', + type: 'stream', + data: { + url: "http://a.site" + }, + related: { + comment: ['b', 'c'] + } + } + } + }) + it('should map children and pass them the appropriate item ids', () => { + const map = shallow( +
+
+ ) + expect(map.node.props.className).to.equal('mapcomment') + expect(map.node.props.children.length).to.equal(2) + expect(map.node.props.children[0]).to.have.property('key') + .and.to.equal('b') + expect(map.node.props.children[0].props.children[0].props).to.have.property('item_id') + .and.to.equal('b') + expect(map.node.props.children[1]).to.have.property('key') + .and.to.equal('c') + expect(map.node.props.children[1].props.children[0].props).to.have.property('item_id') + .and.to.equal('c') + }) + it('should pass its items and config objects on to its children', () => { + const map = shallow( +
+
+ ) + expect(map.node.props.children[0].props.children[0].props).to.have.property('items') + .and.to.deep.equal(items) + expect(map.node.props.children[1].props.children[0].props).to.have.property('items') + .and.to.deep.equal(items) + }) +}) diff --git a/client/coral-framework/__tests__/dynamic-containers/RootContainer.spec.js b/client/coral-framework/__tests__/dynamic-containers/RootContainer.spec.js new file mode 100644 index 000000000..346d13dd7 --- /dev/null +++ b/client/coral-framework/__tests__/dynamic-containers/RootContainer.spec.js @@ -0,0 +1,50 @@ +import React from 'react' +import {shallow, mount} from 'enzyme' +import {expect} from 'chai' + +import RootContainer from '../../dynamic-containers/RootContainer' + +describe('', () => { + let items + beforeEach(() => { + items = { + 'a': { + type: 'stream', + data: {comments: ['b', 'c']} + } + } + }) + describe('render', () => { + it('should render child containers with the appropriate id', () => { + const render = shallow( {}}> +
+
+ ) + expect(render.hasClass('rootContainer')).to.be.true + expect(render.props().children[0].props).to.have.property('item_id') + .and.to.equal('a') + expect(render.props().children[1].props).to.have.property('item_id') + .and.to.equal('a') + }) + it('should render child containers with the appropriate items', () => { + const render = shallow( {}}> +
+
+ ) + expect(render.props().children[0].props).to.have.property('items') + .and.to.deep.equal(items) + expect(render.props().children[1].props).to.have.property('items') + .and.to.deep.equal(items) + }) + }) +}) 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/dynamic-containers/DynamicContainer.js b/client/coral-framework/dynamic-containers/DynamicContainer.js new file mode 100644 index 000000000..1f9e470c9 --- /dev/null +++ b/client/coral-framework/dynamic-containers/DynamicContainer.js @@ -0,0 +1,156 @@ +import React, {Component, PropTypes, Children, cloneElement} from 'react' + +class DynamicContainer extends Component { + + constructor (props) { + super(props) + this.getPropsFromItems = this.getPropsFromItems.bind(this) + } + + static propTypes = { + name: PropTypes.string, + items: PropTypes.object, + item_id: PropTypes.string + } + + traverseEdges (edges, index, query) { + let bracketCount = 0 + let subqueryLength = 0 + let subquery = query.slice(index).reduce((subquery, char, i) => { + if (bracketCount === 0 && i !== 0) { + return subquery + } + subqueryLength++ + switch (char) { + case '{': + bracketCount++ + break + case '}': + bracketCount-- + break + } + const result = subquery += char + return result + }, '') + query.splice(index, subqueryLength-1) + return edges ? edges.reduce((array, edge) => { + array.push(this.getPropsFromItems(subquery, edge)) + return array + }, []) : undefined + } + + getPropsFromItems (query, id) { + let idStack = [id.toString()] + let relationshipStack = [] + let result = {} + + query.split('').reduce((string, char, i, q) => { + const id = idStack[idStack.length - 1] + if (!this.props.items[id]) { + return '' + } + let object = relationshipStack.reduce((object, relationship) => { + return object[relationship] + }, result) + if (/[^{},]/i.test(char)) { + return string + char + } + // Ignore spaces + if (/\s/.test(char)) { + return string + } + switch (char) { + case '{': + if (!string) { + return string + } + const rgx = /(.*)\(type:\s?('|")(.+)('|")\)|^{/.exec(string) + if (!rgx) { + console.warn('Invalid graphQL: ' + string + ' in ' + query) + console.warn('Expecting format {relationship(type:"itemType"{prop1, prop2}') + return '' + } + + const edge = rgx[1] + const type = rgx[3] + let typetest + if (edge && this.props.items[id].related) { + idStack.push(this.props.items[id].related[edge]) + relationshipStack.push(edge) + let val = this.props.items[id].related[edge] + if (!val || val.constructor === Array) { + object[edge] = this.traverseEdges(val, i, q) + idStack.pop() + relationshipStack.pop() + } else { + object[edge] = {} + } + typetest = this.props.items[val] + } else { + typetest = this.props.items[id] + } + if (typetest && typetest.type !== type) { + console.warn('Received unexpected type when getting props, expected ' + edge + ' of type ' + type + ' but found ' + typetest.type + '.') + } + break + case '}': + idStack.pop() + relationshipStack.pop() + if (!string) { + return string + } + if (string === 'item_id' || + string === 'type' || + string === 'created_at' || + string === 'updated_at') { + object[string] = this.props.items[id][string] + } else { + object[string] = this.props.items[id].data[string] + } + break + case ',': + if (!string) { + return string + } + if (string === 'item_id' || + string === 'type' || + string === 'created_at' || + string === 'updated_at') { + object[string] = this.props.items[id][string] + } else { + object[string] = this.props.items[id].data[string] + } + break + } + return '' + }, '') + return result + } + + render () { + return
+ { + Children.map(this.props.children, (child) => { + if (child.type.name === 'DynamicContainer' || child.type.name === 'MapContainer') { + return cloneElement(child, { + item_id: this.props.item_id, + items: this.props.items + }) + } + if (!child.props.data) { + return child + } + const props = this.getPropsFromItems(child.props.data, this.props.item_id) + return cloneElement( + child, + { + data: undefined, + ...props + }) + }) + } +
+ } +} + +export default DynamicContainer diff --git a/client/coral-framework/dynamic-containers/MapContainer.js b/client/coral-framework/dynamic-containers/MapContainer.js new file mode 100644 index 000000000..44b915b2e --- /dev/null +++ b/client/coral-framework/dynamic-containers/MapContainer.js @@ -0,0 +1,42 @@ +import React, {Children, cloneElement} from 'react' + +/* +* Maps a set of children onto an array of item ids +* e.g. Displaying a stream of comments +* +* @props +* id- The id of the item with the property to be mapped. +* mapOver- The property to be mapped. Should be an array of ids. +* items- All items in the redux store. +*/ + +const MapContainer = ({items, item_id, mapOver, children}) => { + if (!items[item_id] || !items[item_id].related) { + return null + } + const itemArray = items[item_id].related[mapOver] + if (!itemArray) { + return null + } + return
+ { + itemArray.map((item) => { + return
+ { + Children.map(children, (ChildComponent) => { + let elem = cloneElement( + ChildComponent, + { + item_id: item, + items: items + }) + return elem + }) + } +
+ }) + } +
+} + +export default MapContainer diff --git a/client/coral-framework/dynamic-containers/README.md b/client/coral-framework/dynamic-containers/README.md new file mode 100644 index 000000000..6f77ac8e6 --- /dev/null +++ b/client/coral-framework/dynamic-containers/README.md @@ -0,0 +1,171 @@ +# Dynamic React Containers + +Dynamic inject React components in the spot that they're needed with the data that they need. In combination with Coral's Shelf API, they also handle all configuration of and communication with the backend. With Dynamic Containers you can write a component, say what data it needs, say where you want it to show up and the rest is handled magically. All of this is accomplished by placing your components in containers and passing them a `data` variable: + +**app.js** +``` +import React, {Component} from 'react'; +import ReactDOM from 'react-dom'; +import {RootContainer, Container} from 'dynamic-react-components' +import {Title} from 'components' + +class App extends Component { + + render() { + return + + + </Container> + </RootContainer> + } +} + +ReactDOM.render(<App/>, document.getElementById('app')); +``` + +This allows you to use simple react components which get exactly the data they expect. + +**Title.js** +``` +import React from 'react'; + +const Title = (props) => { + return <h1>{props.title}</h1> +}; + +export default Title; +``` + +## What's going on here?? + +Data in this application is stored in a graph of items. The root container defines a root item, a starting place in that graph. Each container looks at the graphql in the "data" props of its children, then walks the graph and delivers that child the data that it needs. This is a little more complex than simply passing props, but has some big advantages. + +1) It's easy to pass arbitrary data to an arbitrary component in your application, no more worrying about context or needing to pass props down a hierarchy. + +2) Flux store and dispatch is handled for you. You get a few CRUD functions for items that handle the majority of use cases, and can defined custom actions for any other cases that pop up. + +3) No need to configure a server. By adding up the graphQL statements in your config files the server knows exactly the data structure your application needs and how to optimize that data structure for the kinds of traversal you'll be doing. + +Let's review the concepts describes in these files: + +**app.js** +- *RootContainer*: Wraps all other containers and provides an id of a root item. +- *Container*: A div where an array of components can be injected. +- *data*: A graphQL string describing the data used by this application. + +## Traversing the Graph + +A blog that just shows a title isn't very interesting, let's add some more information: + +**app.js** +``` +import React, {Component} from 'react' +import ReactDOM from 'react-dom' +import {RootContainer, Container} from 'dynamic-react-components' +import {Author, Title, Content} from 'components' + +class App extends Component { + + render() { + return <RootContainer rootId={this.props.params.post} type="blogpost"> + <Container name="content"> + <Author data='{author(type:"user"){name}}'/> + <Title data='{title}'/> + <Content data='{content}'/> + </Container> + </RootContainer> + } +} + +ReactDOM.render(<App/>, document.getElementById('app')) +``` + +Now we've added an author and content to our blog post. Note that for the Author component we used graphQL to traverse the graph from `blogpost` to `author` in order to get the author's name. When making these traversals it's important to include a "type", this allows the back end to optimize using these query statements. + +If three seperate components seems like overkill for this task, we can combine them like so: + +**app.js** +``` +import React, {Component} from 'react' +import ReactDOM from 'react-dom' +import {RootContainer, Container} from 'dynamic-react-components' +import {Blogpost} from 'components' + +class App extends Component { + + render() { + return <RootContainer rootId={this.props.params.post} type="blogpost"> + <Container name="content"> + <Blogpost data='{title,content,author(type:"user"){name}}'/> + </Container> + </RootContainer> + } +} + +ReactDOM.render(<App/>, document.getElementById('app')) +``` + +Dynamic components lets you pull data from an arbitrary set of items in the graph into a single component for display. + +## GraphQL + +Currently, dynamic components only support basic graphQL and the `type` argument. Alias, mutations, fragments, and variables are not currently supported. When traversing a graph, as in `{author(type:"user"){name}}`, the "type" argument is required. This allows for validation and provides needed information to the back end. + +## Iterating over arrays + +What if I want to display multiple blog posts? Enter the **MapContainer** component: + +**app.js** +``` +import React, {Component} from 'react'; +import ReactDOM from 'react-dom'; +import {RootContainer, Container} from 'dynamic-react-components' +import {Blogpost} from 'components' + +class App extends Component { + + render() { + return <RootContainer rootId='homepage' type="contentStream"> + <MapContainer mapOver='posts' type="blogpost"/> + <Container name="content"> + <Blogpost data='{title,content,author(type:"user"){name}}'/> + </Container> + </MapContainer> + </RootContainer> + } +} + +ReactDOM.render(<App/>, document.getElementById('app')); +``` + +Now our RootContainer is passed a different kind of item, a contentStream with an array of blogpost ids that looks something like this: + +``` +{ + type: 'contentStream', + posts: ['id1','id2'] +} +``` + + MapContainer iterates over these ids, passing its children an id of type blogpost (*not* type contentstream). The resulting page would look something like this: + +``` + <div id="app"> + <div class="rootContainer"> + <div class='contentContainer'> + <div class='mapPostsContainer'> + <div> + <h1>Title 1</h1> + <div class="author">Author 1</div> + <div class="content">Content 1</div> + </div> + <div> + <h1>Title 2</h1> + <div class="author">Author 2</div> + <div class="content">Content 2</div> + </div> + </div> + </div> + </div> + </div> + ``` diff --git a/client/coral-framework/dynamic-containers/RootContainer.js b/client/coral-framework/dynamic-containers/RootContainer.js new file mode 100644 index 000000000..bfdc58d3d --- /dev/null +++ b/client/coral-framework/dynamic-containers/RootContainer.js @@ -0,0 +1,44 @@ +import React, {Component, PropTypes, Children, cloneElement} from 'react' + +/* +* Renders a set of dynamic components bases on a root id +* +*/ + +class RootContainer extends Component { + + static propTypes = { + rootId: PropTypes.string.isRequired, + items: PropTypes.object.isRequired, + type: PropTypes.string.isRequired, + query: PropTypes.string.isRequired, + getItemsQuery: PropTypes.func.isRequired + } + + componentDidMount () { + const {query, getItemsQuery, rootId, view} = this.props + getItemsQuery(query, rootId, view) + } + + render () { + const {items, rootId, type, children} = this.props + if (items[rootId] && items[rootId].type !== type) { + console.warn('Id passed to RootContainer gets an object of an unexpected type. Expected ' + type + ' but got ' + items[rootId].type) + } + return <div className='rootContainer'> + { + items[rootId] && + Children.map(children, (ChildComponent) => { + return cloneElement( + ChildComponent, + { + item_id: rootId, + items: items + }) + }) + } + </div> + } +} + +export default RootContainer 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..e642c5d3b --- /dev/null +++ b/client/coral-framework/index.js @@ -0,0 +1,23 @@ +import RootContainer from './dynamic-containers/RootContainer' +import MapContainer from './dynamic-containers/MapContainer' +import Container from './dynamic-containers/DynamicContainer' +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 { + RootContainer, + MapContainer, + Container, + Notification, + store, + fetchConfig, + itemActions, + I18n, + notificationActions, + authActions +} 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 <div> + { + props.notification.text && + <dialog open id='coral-notif' className={'coral-notif-' + props.notification.type}> + {props.notification.text} + </dialog> + } + </div> +} + +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..fc312a7e8 --- /dev/null +++ b/client/coral-framework/store/actions/config.js @@ -0,0 +1,27 @@ +/* @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 { + const response = await fetch(`./talk.config.json`) + const json = await response.json() + dispatch({ type: FETCH_CONFIG_SUCCESS, config: fromJS(json) }) + } 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..75c631a03 --- /dev/null +++ b/client/coral-framework/store/actions/items.js @@ -0,0 +1,215 @@ +/* Item Actions */ + +import { fromJS } from 'immutable' + +/** + * 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 (query, rootId, view, host) { + return (dispatch) => { + return fetch(host + '/v1/exec/' + query + '/view/' + 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) + }) + } +} + +/* +* 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, host) { + return (dispatch) => { + return fetch(host + '/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, host) { + return (dispatch) => { + let item = { + type, + data, + version: 1 + } + if (id) { + item.item_id = id + } + let options = { + method: 'POST', + body: JSON.stringify(item) + } + return fetch(host + '/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, host) { + return (dispatch) => { + let options = { + method: 'POST' + } + dispatch(appendItemArray(item, action, user)) + return fetch(host + '/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..a3f63b7cd --- /dev/null +++ b/client/coral-plugin-comment-count/CommentCount.js @@ -0,0 +1,21 @@ +import React from 'react' + +const name = 'coral-plugin-comment-count' + +const CommentCount = ({comment}) => { + let count = 0 + if (comment) { + count += comment.length + for (var i=0; i < comment.length; i++) { + if (comment[i].child) { + count += comment[i].child.length + } + } + } + + return <div className={name + '-text'}> + {count + ' ' + (count === 1 ? 'Comment':'Comments')} + </div> +} + +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 <div> + <div + className={name + '-container'} + style={styles && styles.container}> + <label + htmlFor={ reply ? 'replyText' : 'commentText'} + className="screen-reader-text" + aria-hidden={true}> + {reply ? 'Reply': 'Comment'} + </label> + <textarea + className={name + '-textarea'} + style={styles && styles.textarea} + value={this.state.content} + id={reply ? 'replyText' : 'commentText'} + onChange={(e) => this.setState({content: e.target.value})} + rows={3}/> + </div> + <div className={name + '-button-container'}> + <button + className={name + '-button'} + style={styles && styles.button} + onClick={this.postComment}> + {lang.t('post')} + </button> + </div> + </div> + } +} + +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(<CommentBox + postItem={postItem} + updateItem={(e) => comment.text=e.target.value} + item_id={'1'} + comments={['1', '2', '3']}/>) + }) + + it('should render the CommentBox appropriately', () => { + expect(render.contains('<div class="CommentBox"')).to.be.truthy + expect(render.contains('<button class="postCommentButton"')).to.be.truthy + }) +}) diff --git a/client/coral-plugin-commentcontent/CommentContent.js b/client/coral-plugin-commentcontent/CommentContent.js new file mode 100644 index 000000000..ed3865f8a --- /dev/null +++ b/client/coral-plugin-commentcontent/CommentContent.js @@ -0,0 +1,10 @@ +import React from 'react' +const name = 'coral-plugin-replies' + +const Content = (props) => <div + className={name + '-text'} + style={props.styles && props.styles.text}> + {props.content} +</div> + +export default Content diff --git a/client/coral-plugin-commentcontent/__tests__/commentContent.spec.js b/client/coral-plugin-commentcontent/__tests__/commentContent.spec.js new file mode 100644 index 000000000..56cc233db --- /dev/null +++ b/client/coral-plugin-commentcontent/__tests__/commentContent.spec.js @@ -0,0 +1,11 @@ +import React from 'react' +import {shallow, mount} from 'enzyme' +import {expect} from 'chai' +import CommentContent from '../CommentContent' + +describe('CommentContent', () => { + it('should render content', () => { + const render = shallow(<CommentContent content="test"/>) + expect(render.contains('test')).to.be.truthy + }) +}) diff --git a/client/coral-plugin-flags/FlagButton.js b/client/coral-plugin-flags/FlagButton.js new file mode 100644 index 000000000..0c5bbcbc5 --- /dev/null +++ b/client/coral-plugin-flags/FlagButton.js @@ -0,0 +1,35 @@ +import React from 'react' + +const name='coral-plugin-flags' + +const FlagButton = ({flag, item_id, postAction, currentUser, addNotification}) => { + const flagged = flag && flag.includes(currentUser) + const onFlagClick = () => { + postAction(item_id, 'flag', currentUser) + addNotification('success', 'Thank you for reporting this comment. Our moderation team has been notified and will review it shortly.') + + } + return <div className={name + '-container'}> + <button onClick={onFlagClick} className={name + '-button'}> + <i className={name + '-icon material-icons'} + style={flagged ? styles.flaggedIcon : styles.unflaggedIcon} + aria-hidden={true}>flag</i> + { + flagged + ? <span className={name + '-button-text'}>Flagged</span> + : <span className={name + '-button-text'}>Flag</span> + } + </button> + </div> +} + +export default FlagButton + +const styles = { + flaggedIcon: { + color: '#F00' + }, + unflaggedIcon: { + color: 'inherit' + } +} diff --git a/client/coral-plugin-pubdate/PubDate.js b/client/coral-plugin-pubdate/PubDate.js new file mode 100644 index 000000000..bc96c9601 --- /dev/null +++ b/client/coral-plugin-pubdate/PubDate.js @@ -0,0 +1,11 @@ +import React from 'react' +import {I18n} from '../coral-framework' + +const lang = new I18n() +const name = 'coral-plugin-pubdate' + +const PubDate = ({created_at}) => <div className={name + '-text'}> + {lang.timeago(created_at)} +</div> + +export default PubDate diff --git a/client/coral-plugin-replies/ReplyBox.js b/client/coral-plugin-replies/ReplyBox.js new file mode 100644 index 000000000..7a89bdeeb --- /dev/null +++ b/client/coral-plugin-replies/ReplyBox.js @@ -0,0 +1,21 @@ +import React from 'react' +import CommentBox from '../coral-plugin-commentbox/CommentBox' + +const name = 'coral-plugin-replies' + +const ReplyBox = (props) => <div + className={name + '-textarea'} + style={props.styles && props.styles.container}> + { + props.showReply && <CommentBox + item_id = {props.item_id} + postItem = {props.postItem} + addNotification = {props.addNotification} + appendItemRelated = {props.appendItemRelated} + updateItem = {props.updateItem} + comments = {props.child} + reply = {true}/> + } + </div> + +export default ReplyBox diff --git a/client/coral-plugin-replies/ReplyButton.js b/client/coral-plugin-replies/ReplyButton.js new file mode 100644 index 000000000..9a786e223 --- /dev/null +++ b/client/coral-plugin-replies/ReplyButton.js @@ -0,0 +1,23 @@ +import React from 'react' +import {I18n} from '../coral-framework' + +const name = 'coral-plugin-replies' + +const ReplyButton = (props) => <button + className={name + '-reply-button'} + onClick={(e) => props.updateItem(props.item_id || props.parent_id, 'showReply', true)}> + <i className={name + '-icon material-icons'} + aria-hidden={true}>{lang.t('reply')}</i> + Reply +</button> + +export default ReplyButton + +const lang = new I18n({ + en: { + 'reply': 'Reply' + }, + es: { + 'reply': '¡traduceme!' + } +}) diff --git a/client/coral-plugin-replies/index.js b/client/coral-plugin-replies/index.js new file mode 100644 index 000000000..ce2446ab0 --- /dev/null +++ b/client/coral-plugin-replies/index.js @@ -0,0 +1,7 @@ +import ReplyBox from './ReplyBox' +import ReplyButton from './ReplyButton' + +export { + ReplyBox, + ReplyButton +} diff --git a/package.json b/package.json index e3e3b0676..ab8a0c865 100644 --- a/package.json +++ b/package.json @@ -27,5 +27,36 @@ "debug": "^2.2.0", "express": "^4.14.0", "mongoose": "^4.6.5" + }, + "devDependencies": { + "babel-core": "6.14.0", + "babel-jest": "^15.0.0", + "babel-loader": "6.2.5", + "babel-plugin-transform-async-to-generator": "^6.8.0", + "babel-plugin-transform-decorators-legacy": "^1.3.4", + "babel-plugin-transform-object-assign": "^6.8.0", + "babel-plugin-transform-react-jsx": "^6.8.0", + "babel-polyfill": "^6.13.0", + "babel-preset-es2015": "6.13.0", + "babel-preset-es2015-minimal": "^2.1.0", + "babel-preset-stage-0": "^6.16.0", + "copy-webpack-plugin": "^3.0.1", + "exports-loader": "^0.6.3", + "immutable": "^3.8.1", + "imports-loader": "^0.6.5", + "pym.js": "^1.1.1", + "react": "15.3.2", + "react-dom": "15.3.2", + "react-redux": "^4.4.5", + "redux": "^3.6.0", + "redux-thunk": "^2.1.0", + "regenerator": "^0.8.46", + "timeago.js": "^2.0.3", + "webpack": "^1.13.2", + "webpack-dashboard": "^0.2.0", + "webpack-dev-middleware": "^1.8.3", + "webpack-hot-middleware": "^2.12.2", + "webpack-module-hot-accept": "^1.0.4", + "whatwg-fetch": "^1.0.0" } }