diff --git a/.gitignore b/.gitignore
index d41d351f8..6ab7cf84b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ public/*
dist
coverage/
.nyc_output/
+yarn.lock
\ No newline at end of file
diff --git a/client/modules/Post/CommentActions.js b/client/modules/Post/CommentActions.js
new file mode 100644
index 000000000..b585dc192
--- /dev/null
+++ b/client/modules/Post/CommentActions.js
@@ -0,0 +1,93 @@
+import callApi from "../../util/apiCaller";
+
+export const ADD_COMMENTS = "ADD_COMMENTS";
+export const ADD_COMMENT = "ADD_COMMENT";
+export const EDIT_COMMENT = "EDIT_COMMENT";
+export const EDIT_COMMENT_MODE = "EDIT_COMMENT_MODE";
+export const DELETE_COMMENT = "DELETE_COMMENT";
+export const CANCEL_EDIT_COMMENT = "CANCEL_EDIT_COMMENT";
+export const CLEAR_COMMENTS = "CLEAR_COMMENTS";
+
+export function addComments(comments) {
+ return {
+ type: ADD_COMMENTS,
+ comments
+ };
+}
+
+export function fetchComments(cuid) {
+ return dispatch => {
+ return callApi(`posts/${cuid}/comments`).then(res => {
+ dispatch(addComments(res.comments));
+ });
+ };
+}
+
+export function deleteComment(cuid) {
+ return {
+ type: DELETE_COMMENT,
+ cuid
+ };
+}
+
+export function deleteCommentRequest(cuid) {
+ return dispatch => {
+ return callApi(`comments/${cuid}`, "delete").then(() =>
+ dispatch(deleteComment(cuid))
+ );
+ };
+}
+
+export function addComment(comment) {
+ return {
+ type: ADD_COMMENT,
+ comment
+ };
+}
+
+export function addCommentRequest(comment, ownerId) {
+ return dispatch => {
+ return callApi(`posts/${ownerId}/comments`, "post", {
+ comment: {
+ author: comment.author,
+ body: comment.body
+ }
+ }).then(res => dispatch(addComment(res.comment)));
+ };
+}
+
+export function enableEditMode(comment) {
+ return {
+ type: EDIT_COMMENT_MODE,
+ comment
+ };
+}
+
+export function cancelEditMode() {
+ return {
+ type: CANCEL_EDIT_COMMENT
+ };
+}
+
+export function saveEdit(comment) {
+ return {
+ type: EDIT_COMMENT,
+ comment
+ };
+}
+
+export function updateCommentRequest(commentBody, cuid) {
+ return dispatch => {
+ return callApi(`comments/${cuid}`, "put", {
+ comment: {
+ body: commentBody
+ }
+ }).then(res => dispatch(saveEdit(res.comment)));
+ };
+}
+
+export function clearComments() {
+ return {
+ type: CLEAR_COMMENTS
+ };
+}
diff --git a/client/modules/Post/CommentReducer.js b/client/modules/Post/CommentReducer.js
new file mode 100644
index 000000000..fafc6d0ad
--- /dev/null
+++ b/client/modules/Post/CommentReducer.js
@@ -0,0 +1,64 @@
+import {
+ ADD_COMMENTS,
+ ADD_COMMENT,
+ EDIT_COMMENT,
+ EDIT_COMMENT_MODE,
+ CANCEL_EDIT_COMMENT,
+ DELETE_COMMENT,
+ CLEAR_COMMENTS
+} from "./CommentActions";
+
+const initialState = {
+ comments: [],
+ editComment: null
+};
+
+const CommentReducer = (state = initialState, action) => {
+ switch (action.type) {
+ case ADD_COMMENTS:
+ return { ...state, comments: action.comments };
+
+ case ADD_COMMENT: {
+ return { ...state, comments: [...state.comments, action.comment] };
+ }
+
+ case EDIT_COMMENT: {
+ let index = state.comments.findIndex(comment => {
+ if (comment.cuid === action.comment.cuid) {
+ return true;
+ }
+ return false;
+ });
+
+ let startCommentArray = state.comments.slice(0, index);
+ let endCommentArray = state.comments.slice(
+ index + 1,
+ state.comments.length
+ );
+ return {
+ ...state,
+ comments: [...startCommentArray, action.comment, ...endCommentArray]
+ };
+ }
+ case EDIT_COMMENT_MODE:
+ return { ...state, editComment: action.comment };
+ case CANCEL_EDIT_COMMENT:
+ return { ...state, editComment: null };
+ case DELETE_COMMENT:
+ return {
+ editComment: null,
+ comments: state.comments.filter(comment => comment.cuid !== action.cuid)
+ };
+ case CLEAR_COMMENTS:
+ return initialState;
+ default:
+ return state;
+ }
+};
+
+/* Selectors */
+
+// Get corresponding comments
+export const getComments = state => state.comments.comments;
+
+export default CommentReducer;
diff --git a/client/modules/Post/components/Comments/CommentCreateWidget.css b/client/modules/Post/components/Comments/CommentCreateWidget.css
new file mode 100644
index 000000000..02cd84e18
--- /dev/null
+++ b/client/modules/Post/components/Comments/CommentCreateWidget.css
@@ -0,0 +1,66 @@
+.form {
+ background: #fafafa;
+ padding: 32px 0;
+ border: 1px solid #eee;
+ border-radius: 4px;
+}
+
+.form-content {
+ width: 100%;
+ max-width: 600px;
+ margin: auto;
+ font-size: 14px;
+}
+
+.form-title {
+ font-size: 16px;
+ font-weight: 700;
+ margin-bottom: 16px;
+ color: #757575;
+}
+
+.form-field {
+ width: 100%;
+ margin-bottom: 16px;
+ font-family: "Lato", sans-serif;
+ font-size: 16px;
+ line-height: normal;
+ padding: 12px 16px;
+ border-radius: 4px;
+ border: 1px solid #ddd;
+ outline: none;
+ color: #212121;
+}
+
+textarea {
+ min-height: 200px;
+}
+
+.comment-cancel-button {
+ display: inline-block;
+ padding: 8px 16px;
+ font-size: 18px;
+ color: #fff;
+ background: #f4c842;
+ text-decoration: none;
+ border-radius: 4px;
+}
+
+.comment-submit-button {
+ display: inline-block;
+ padding: 8px 16px;
+ margin-right: 10px;
+ font-size: 18px;
+ color: #fff;
+ background: #03a9f4;
+ text-decoration: none;
+ border-radius: 4px;
+}
+
+.comment-author {
+ margin-bottom: 20px;
+}
+
+.appear {
+ display: block;
+}
diff --git a/client/modules/Post/components/Comments/CommentCreateWidget.js b/client/modules/Post/components/Comments/CommentCreateWidget.js
new file mode 100644
index 000000000..2a8658c20
--- /dev/null
+++ b/client/modules/Post/components/Comments/CommentCreateWidget.js
@@ -0,0 +1,149 @@
+import React, { Component, PropTypes } from "react";
+import { connect } from "react-redux";
+
+import styles from "./CommentCreateWidget.css";
+import {
+ addCommentRequest,
+ cancelEditMode,
+ updateCommentRequest
+} from "../../CommentActions";
+
+class CommentCreateWidget extends Component {
+ state = {
+ author: "",
+ body: ""
+ };
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.editComment) {
+ this.setState({
+ author: nextProps.editComment.author,
+ body: nextProps.editComment.body
+ });
+ }
+ }
+
+ createComment = e => {
+ e.preventDefault();
+ if (this.state.author && this.state.body) {
+ this.props.dispatch(
+ addCommentRequest(
+ { author: this.state.author, body: this.state.body },
+ this.props.ownerId
+ )
+ );
+ this.setState({ author: "", body: "" });
+ }
+ };
+
+ saveEditComment = e => {
+ e.preventDefault();
+ if (!this.state.body) {
+ return;
+ }
+ if (this.props.editComment.body === this.state.body) {
+ this.cancelEditComment(e);
+ return;
+ }
+
+ this.props.dispatch(
+ updateCommentRequest(this.state.body, this.props.editComment.cuid)
+ );
+ this.cancelEditComment(e);
+ };
+
+ cancelEditComment = e => {
+ e.preventDefault();
+ this.props.dispatch(cancelEditMode());
+ this.setState({ author: "", body: "" });
+ };
+
+ renderCreateForm() {
+ return (
+
-
-
-
{props.post.title}
-
{props.post.name}
-
{props.post.content}
+import { getPost } from "../../PostReducer";
+import { getComments } from "../../CommentReducer";
+
+export class PostDetailPage extends Component {
+ componentDidMount() {
+ // fetch comments
+ this.props.dispatch(fetchComments(this.props.post.cuid));
+ }
+ render() {
+ return (
+
+
+
+
{this.props.post.title}
+
+ {this.props.post.name}
+
+
{this.props.post.content}
+
+ {this.props.comments ? (
+
+ ) : null}
+
-
- );
+ );
+ }
+
+ componentWillUnmount() {
+ // clear comment
+ this.props.dispatch(clearComments());
+ }
}
// Actions required to provide data for this component to render in sever side.
-PostDetailPage.need = [params => {
- return fetchPost(params.cuid);
-}];
+PostDetailPage.need = [
+ params => {
+ return fetchPost(params.cuid);
+ },
+ params => {
+ return fetchComments(params.cuid);
+ }
+];
// Retrieve data from store as props
function mapStateToProps(state, props) {
return {
post: getPost(state, props.params.cuid),
+ comments: getComments(state)
};
}
@@ -43,8 +71,16 @@ PostDetailPage.propTypes = {
title: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
slug: PropTypes.string.isRequired,
- cuid: PropTypes.string.isRequired,
+ cuid: PropTypes.string.isRequired
}).isRequired,
+ dispatch: PropTypes.func.isRequired,
+ comments: PropTypes.arrayOf(
+ PropTypes.shape({
+ author: PropTypes.string.isRequired,
+ cuid: PropTypes.string.isRequired,
+ body: PropTypes.string.isRequired
+ })
+ )
};
export default connect(mapStateToProps)(PostDetailPage);
diff --git a/client/reducers.js b/client/reducers.js
index 2aa143142..bcafb8d5e 100644
--- a/client/reducers.js
+++ b/client/reducers.js
@@ -1,16 +1,18 @@
/**
* Root Reducer
*/
-import { combineReducers } from 'redux';
+import { combineReducers } from "redux";
// Import Reducers
-import app from './modules/App/AppReducer';
-import posts from './modules/Post/PostReducer';
-import intl from './modules/Intl/IntlReducer';
+import app from "./modules/App/AppReducer";
+import posts from "./modules/Post/PostReducer";
+import comments from "./modules/Post/CommentReducer";
+import intl from "./modules/Intl/IntlReducer";
// Combine all reducers into one root reducer
export default combineReducers({
app,
posts,
intl,
+ comments
});
diff --git a/server/controllers/comment.controller.js b/server/controllers/comment.controller.js
new file mode 100644
index 000000000..fe270a2f5
--- /dev/null
+++ b/server/controllers/comment.controller.js
@@ -0,0 +1,92 @@
+import Comment from "../models/comment";
+import Post from "../models/post";
+import cuid from "cuid";
+import sanitizeHtml from "sanitize-html";
+
+export function getComments(req, res) {
+ Post.findOne({ cuid: req.params.cuid }).exec((err, post) => {
+ if (err) {
+ return res.status(500).send(err);
+ }
+
+ if (!post) {
+ return res.status(400).end();
+ }
+
+ Comment.find({ owner: post._id }).exec((commentErr, comments) => {
+ if (commentErr) {
+ return res.status(500).send(commentErr);
+ }
+ return res.json({ comments });
+ });
+ });
+}
+
+export function addComment(req, res) {
+ if (!req.body.comment || !req.body.comment.author || !req.body.comment.body) {
+ return res.status(403).end();
+ }
+
+ Post.findOne({ cuid: req.params.cuid }).exec((err, post) => {
+ if (err) {
+ return res.status(500).send(err);
+ }
+
+ if (!post) {
+ return res.status(400).end();
+ }
+
+ const newComment = new Comment(req.body.comment);
+
+ newComment.author = sanitizeHtml(newComment.author);
+ newComment.body = sanitizeHtml(newComment.body);
+ newComment.cuid = cuid();
+ newComment.owner = post;
+ newComment.save((err, saved) => {
+ if (err) {
+ return res.status(500).send(err);
+ }
+ return res.json({ comment: saved });
+ });
+ });
+}
+
+export function deleteComment(req, res) {
+ Comment.findOne({ cuid: req.params.cuid }).exec((err, comment) => {
+ if (err) {
+ return res.status(500).send(err);
+ }
+
+ if (!comment) {
+ return res.status(400).end();
+ }
+
+ comment.remove(() => {
+ return res.status(200).end();
+ });
+ });
+}
+
+export function updateComment(req, res) {
+ if (!req.body.comment || !req.body.comment.body) {
+ return res.status(403).end();
+ }
+ Comment.findOne({ cuid: req.params.cuid }).exec((err, comment) => {
+ if (err) {
+ return res.status(500).send(err);
+ }
+
+ if (!comment) {
+ return res.status(400).end();
+ }
+ comment.body = sanitizeHtml(req.body.comment.body);
+
+ comment.save((saveErr, saved) => {
+ if (saveErr) {
+ return res.status(500).send(saveErr);
+ }
+
+ res.json({ comment: saved });
+ });
+ });
+}
diff --git a/server/dummyData.js b/server/dummyData.js
index 7749aa13f..39f83da2c 100644
--- a/server/dummyData.js
+++ b/server/dummyData.js
@@ -1,6 +1,7 @@
-import Post from './models/post';
+import Post from "./models/post";
+import Comment from "./models/comment";
-export default function () {
+export default function() {
Post.count().exec((err, count) => {
if (count > 0) {
return;
@@ -34,13 +35,52 @@ export default function () {
qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem
ipsum quia dolor sit amet.`;
- const post1 = new Post({ name: 'Admin', title: 'Hello MERN', slug: 'hello-mern', cuid: 'cikqgkv4q01ck7453ualdn3hd', content: content1 });
- const post2 = new Post({ name: 'Admin', title: 'Lorem Ipsum', slug: 'lorem-ipsum', cuid: 'cikqgkv4q01ck7453ualdn3hf', content: content2 });
+ const commentBody = `Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod ea nam laboriosam nemo porro, perferendis,
+ et ex non expedita consequuntur sint reiciendis est ipsum libero recusandae. Architecto, nisi facilis? Corporis?`;
- Post.create([post1, post2], (error) => {
+ const post1 = new Post({
+ name: "Admin",
+ title: "Hello MERN",
+ slug: "hello-mern",
+ cuid: "cikqgkv4q01ck7453ualdn3hd",
+ content: content1
+ });
+ const post2 = new Post({
+ name: "Admin",
+ title: "Lorem Ipsum",
+ slug: "lorem-ipsum",
+ cuid: "cikqgkv4q01ck7453ualdn3hf",
+ content: content2
+ });
+
+ const comment1 = new Comment({
+ author: "Mr. 1",
+ body: commentBody,
+ owner: post1,
+ cuid: "gadkgjfdalkgjdflkgjhdfjgkh324234"
+ });
+ const comment2 = new Comment({
+ author: "Mr. Two",
+ body: commentBody,
+ owner: post1,
+ cuid: "gadkgjfdalkgjdflkgjhdfsdf6534"
+ });
+ const comment3 = new Comment({
+ author: "Mr. 3(threeee)",
+ body: commentBody,
+ owner: post1,
+ cuid: "gadkgjfsdsfdfsddalsdfsdfvbkgjhdfsdf6534"
+ });
+
+ Post.create([post1, post2], error => {
if (!error) {
- // console.log('ready to go....');
+ console.log("Successfully created 2 posts");
}
+ Comment.create([comment1, comment2, comment3], commentError => {
+ if (!commentError) {
+ console.log("Successfully created 3 comments");
+ }
+ });
});
});
}
diff --git a/server/models/comment.js b/server/models/comment.js
new file mode 100644
index 000000000..d07650c15
--- /dev/null
+++ b/server/models/comment.js
@@ -0,0 +1,12 @@
+import mongoose from "mongoose";
+const Schema = mongoose.Schema;
+
+const commentSchema = new Schema({
+ author: { type: "String", required: true },
+ body: { type: "String", required: true },
+ cuid: { type: "String", required: true, unique: true },
+ owner: { type: mongoose.Schema.Types.ObjectId, ref: "Post", required: true },
+ dateAdded: { type: "Date", default: Date.now, required: true }
+});
+
+export default mongoose.model("Comment", commentSchema);
diff --git a/server/models/post.js b/server/models/post.js
index e781bf7db..21d6a4f16 100644
--- a/server/models/post.js
+++ b/server/models/post.js
@@ -1,13 +1,19 @@
-import mongoose from 'mongoose';
+import mongoose from "mongoose";
const Schema = mongoose.Schema;
const postSchema = new Schema({
- name: { type: 'String', required: true },
- title: { type: 'String', required: true },
- content: { type: 'String', required: true },
- slug: { type: 'String', required: true },
- cuid: { type: 'String', required: true },
- dateAdded: { type: 'Date', default: Date.now, required: true },
+ name: { type: "String", required: true },
+ title: { type: "String", required: true },
+ content: { type: "String", required: true },
+ slug: { type: "String", required: true },
+ cuid: { type: "String", required: true, unique: true },
+ dateAdded: { type: "Date", default: Date.now, required: true }
});
-export default mongoose.model('Post', postSchema);
+postSchema.virtual("comments", {
+ ref: "Comment",
+ localField: "_id",
+ foreignField: "owner"
+});
+
+export default mongoose.model("Post", postSchema);
diff --git a/server/routes/comment.routes.js b/server/routes/comment.routes.js
new file mode 100644
index 000000000..dea5f5480
--- /dev/null
+++ b/server/routes/comment.routes.js
@@ -0,0 +1,17 @@
+import { Router } from "express";
+import * as CommentController from "../controllers/comment.controller";
+const router = new Router();
+
+// Get all Comments
+router.route("/posts/:cuid/comments").get(CommentController.getComments);
+
+// Add a new Comment
+router.route("/posts/:cuid/comments").post(CommentController.addComment);
+
+// Update a Comment
+router.route("/comments/:cuid").put(CommentController.updateComment);
+
+// Delete a comment by cuid
+router.route("/comments/:cuid").delete(CommentController.deleteComment);
+
+export default router;
diff --git a/server/server.js b/server/server.js
index ea3330200..079878dbc 100644
--- a/server/server.js
+++ b/server/server.js
@@ -1,69 +1,78 @@
-import Express from 'express';
-import compression from 'compression';
-import mongoose from 'mongoose';
-import bodyParser from 'body-parser';
-import path from 'path';
-import IntlWrapper from '../client/modules/Intl/IntlWrapper';
+import Express from "express";
+import compression from "compression";
+import mongoose from "mongoose";
+import bodyParser from "body-parser";
+import path from "path";
+import IntlWrapper from "../client/modules/Intl/IntlWrapper";
// Webpack Requirements
-import webpack from 'webpack';
-import config from '../webpack.config.dev';
-import webpackDevMiddleware from 'webpack-dev-middleware';
-import webpackHotMiddleware from 'webpack-hot-middleware';
+import webpack from "webpack";
+import config from "../webpack.config.dev";
+import webpackDevMiddleware from "webpack-dev-middleware";
+import webpackHotMiddleware from "webpack-hot-middleware";
// Initialize the Express App
const app = new Express();
// Run Webpack dev server in development mode
-if (process.env.NODE_ENV === 'development') {
+if (process.env.NODE_ENV === "development") {
const compiler = webpack(config);
- app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: config.output.publicPath }));
+ app.use(
+ webpackDevMiddleware(compiler, {
+ noInfo: true,
+ publicPath: config.output.publicPath
+ })
+ );
app.use(webpackHotMiddleware(compiler));
}
// React And Redux Setup
-import { configureStore } from '../client/store';
-import { Provider } from 'react-redux';
-import React from 'react';
-import { renderToString } from 'react-dom/server';
-import { match, RouterContext } from 'react-router';
-import Helmet from 'react-helmet';
+import { configureStore } from "../client/store";
+import { Provider } from "react-redux";
+import React from "react";
+import { renderToString } from "react-dom/server";
+import { match, RouterContext } from "react-router";
+import Helmet from "react-helmet";
// Import required modules
-import routes from '../client/routes';
-import { fetchComponentData } from './util/fetchData';
-import posts from './routes/post.routes';
-import dummyData from './dummyData';
-import serverConfig from './config';
+import routes from "../client/routes";
+import { fetchComponentData } from "./util/fetchData";
+import posts from "./routes/post.routes";
+import comments from "./routes/comment.routes";
+import dummyData from "./dummyData";
+import serverConfig from "./config";
// Set native promises as mongoose promise
mongoose.Promise = global.Promise;
// MongoDB Connection
-mongoose.connect(serverConfig.mongoURL, (error) => {
+mongoose.connect(serverConfig.mongoURL, error => {
if (error) {
- console.error('Please make sure Mongodb is installed and running!'); // eslint-disable-line no-console
+ console.error("Please make sure Mongodb is installed and running!"); // eslint-disable-line no-console
throw error;
}
-
// feed some dummy data in DB.
dummyData();
});
// Apply body Parser and server public assets and routes
app.use(compression());
-app.use(bodyParser.json({ limit: '20mb' }));
-app.use(bodyParser.urlencoded({ limit: '20mb', extended: false }));
-app.use(Express.static(path.resolve(__dirname, '../dist/client')));
-app.use('/api', posts);
+app.use(bodyParser.json({ limit: "20mb" }));
+app.use(bodyParser.urlencoded({ limit: "20mb", extended: false }));
+app.use(Express.static(path.resolve(__dirname, "../dist/client")));
+app.use("/api", posts);
+app.use("/api", comments);
// Render Initial HTML
const renderFullPage = (html, initialState) => {
const head = Helmet.rewind();
// Import Manifests
- const assetsManifest = process.env.webpackAssets && JSON.parse(process.env.webpackAssets);
- const chunkManifest = process.env.webpackChunkAssets && JSON.parse(process.env.webpackChunkAssets);
+ const assetsManifest =
+ process.env.webpackAssets && JSON.parse(process.env.webpackAssets);
+ const chunkManifest =
+ process.env.webpackChunkAssets &&
+ JSON.parse(process.env.webpackChunkAssets);
return `
@@ -75,7 +84,9 @@ const renderFullPage = (html, initialState) => {
${head.link.toString()}
${head.script.toString()}
- ${process.env.NODE_ENV === 'production' ? `
` : ''}
+ ${process.env.NODE_ENV === "production"
+ ? `
`
+ : ""}
@@ -83,22 +94,32 @@ const renderFullPage = (html, initialState) => {
${html}
-
-
+
+