Adding coral-embed-stream and dependencies

This commit is contained in:
David Jay
2016-11-01 13:50:49 -07:00
parent 98e0a404eb
commit 79dcac5725
45 changed files with 2359 additions and 2 deletions
+7 -2
View File
@@ -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"]
]
}
+53
View File
@@ -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')
})
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" type="text/css" href="default.css">
<link href="https://fonts.googleapis.com/css?family=Lato|Open+Sans" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet">
</head>
<body>
<div id="coralStream"/>
<script src="./bundle.js"></script>
</body>
</html>
@@ -0,0 +1,6 @@
{
"coralHost": "http://localhost:16180",
"notifLength": 4500,
"view": "comment_stream",
"query": "all"
}
@@ -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 <RootContainer
items={this.props.items}
view={this.props.config.view}
rootId='assetTest'
type='asset'
query={this.props.config.query}
getItemsQuery={this.props.getItemsQuery(host)}>
<Container name="commentBox">
<Count data="{item_id,comment(type:'comment'){item_id,child(type:'comment'){item_id}}}"/>
<CommentBox
addNotification={this.props.addNotification}
postItem={this.props.postItem(host)}
appendItemRelated={this.props.appendItemRelated}
updateItem={this.props.updateItem}
data="{item_id}"/>
</Container>
<MapContainer mapOver="comment" itemType="comment">
<Container name="comment">
<hr aria-hidden={true}/>
<PubDate data="{created_at}"/>
<Content data="{content}"/>
<Container name="commentActions">
<Flag
addNotification={this.props.addNotification}
data="{item_id,flag}"
postAction={this.props.postAction(host)}
currentUser={this.props.auth.user}/>
<ReplyButton
updateItem={this.props.updateItem}
data="{item_id}"/>
</Container>
<ReplyBox
addNotification={this.props.addNotification}
postItem={this.props.postItem(host)}
appendItemRelated={this.props.appendItemRelated}
updateItem={this.props.updateItem}
data="{item_id,showReply}"/>
<MapContainer mapOver="child" itemType="comment">
<Container name="reply">
<hr aria-hidden={true}/>
<PubDate data="{created_at}"/>
<Content data="{content}"/>
<Container name="replyActions">
<Flag
addNotificiation={this.props.addNotification}
data="{item_id,flag}"
postAction={this.props.postAction(host)}
currentUser={this.props.auth.user}/>
<ReplyButton
updateItem={this.props.updateItem}
data="{parent_id}"/>
</Container>
</Container>
</MapContainer>
</Container>
</MapContainer>
<Notification
notifLength={this.props.config.notifLength}
clearNotification={this.props.clearNotification}
notification={this.props.notification}/>
</RootContainer>
} else {
return <div>Loading</div>
}
}
}
export default CommentStream
+13
View File
@@ -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(
<Provider store={store}>
<CommentStream />
</Provider>
, document.querySelector('#coralStream'))
+113
View File
@@ -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;
}
@@ -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'
}]
}
}
@@ -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'
}]
}
}
@@ -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(<DynamicContainer {...props}>
<div data='{content}'/>
<div data='{author(type:"user"){name}}'/>
</DynamicContainer>)
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'})
})
})
})
@@ -0,0 +1,54 @@
import React from 'react'
import {shallow} from 'enzyme'
import {expect} from 'chai'
import MapContainer from '../../dynamic-containers/MapContainer'
describe('<MapContainer/>', () => {
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(<MapContainer
item_id={'a'}
mapOver="comment"
items={items}>
<div/>
<div/>
</MapContainer>)
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(<MapContainer
item_id={'a'}
mapOver="comment"
items={items}>
<div/>
<div/>
</MapContainer>)
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)
})
})
@@ -0,0 +1,50 @@
import React from 'react'
import {shallow, mount} from 'enzyme'
import {expect} from 'chai'
import RootContainer from '../../dynamic-containers/RootContainer'
describe('<RootContainer/>', () => {
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(<RootContainer
rootId='a'
type='stream'
items={items}
query='all'
getItemsQuery={() => {}}>
<div/>
<div/>
</RootContainer>)
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(<RootContainer
rootId='a'
type='stream'
items={items}
query='all'
getItemsQuery={() => {}}>
<div/>
<div/>
</RootContainer>)
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)
})
})
})
@@ -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)
})
})
})
@@ -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
})
})
})
})
@@ -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']
}
})
})
})
})
@@ -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)
})
})
})
@@ -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 <div className={this.props.name}>
{
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
})
})
}
</div>
}
}
export default DynamicContainer
@@ -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 <div className={'map' + mapOver}>
{
itemArray.map((item) => {
return <div key={item}>
{
Children.map(children, (ChildComponent) => {
let elem = cloneElement(
ChildComponent,
{
item_id: item,
items: items
})
return elem
})
}
</div>
})
}
</div>
}
export default MapContainer
@@ -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 <RootContainer rootId={this.props.params.post} type="blogpost">
<Container name="content">
<Title data='{title}'/>
</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>
```
@@ -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
+74
View File
@@ -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
+23
View File
@@ -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
}
@@ -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
@@ -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
}
}
@@ -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 })
}
}
@@ -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)
}
)
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
})
@@ -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
}
}
@@ -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
}
}
+10
View File
@@ -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)
)
@@ -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
@@ -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'
}
})
@@ -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
})
})
@@ -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
@@ -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
})
})
+35
View File
@@ -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'
}
}
+11
View File
@@ -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
+21
View File
@@ -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
@@ -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!'
}
})
+7
View File
@@ -0,0 +1,7 @@
import ReplyBox from './ReplyBox'
import ReplyButton from './ReplyButton'
export {
ReplyBox,
ReplyButton
}
+31
View File
@@ -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"
}
}