Add edit and delete actions to posts and comments (#125)

This commit is contained in:
Riccardo Graziosi
2022-06-22 10:17:42 +02:00
committed by GitHub
parent 07ca2a304a
commit bc15140512
52 changed files with 1495 additions and 481 deletions

View File

@@ -1,5 +1,5 @@
class CommentsController < ApplicationController class CommentsController < ApplicationController
before_action :authenticate_user!, only: [:create, :update] before_action :authenticate_user!, only: [:create, :update, :destroy]
def index def index
comments = Comment comments = Comment
@@ -8,58 +8,80 @@ class CommentsController < ApplicationController
:body, :body,
:parent_id, :parent_id,
:is_post_update, :is_post_update,
:created_at,
:updated_at, :updated_at,
'users.full_name as user_full_name', 'users.full_name as user_full_name',
'users.email as user_email', 'users.email as user_email',
) )
.where(post_id: params[:post_id]) .where(post_id: params[:post_id])
.left_outer_joins(:user) .left_outer_joins(:user)
.order(updated_at: :desc) .order(created_at: :desc)
render json: comments render json: comments
end end
def create def create
comment = Comment.new(comment_params) @comment = Comment.new
@comment.assign_attributes(comment_create_params)
if comment.save if @comment.save
SendNotificationForCommentWorkflow.new(comment: comment).run SendNotificationForCommentWorkflow.new(comment: @comment).run
render json: comment.attributes.merge( render json: @comment.attributes.merge(
{ user_full_name: current_user.full_name, user_email: current_user.email } { user_full_name: current_user.full_name, user_email: current_user.email }
), status: :created ), status: :created
else else
render json: { render json: {
error: comment.errors.full_messages error: @comment.errors.full_messages
}, status: :unprocessable_entity }, status: :unprocessable_entity
end end
end end
def update def update
comment = Comment.find(params[:id]) @comment = Comment.find(params[:id])
authorize comment authorize @comment
comment.assign_attributes(comment_params)
if comment.save if @comment.update(comment_update_params)
render json: comment.attributes.merge( render json: @comment.attributes.merge(
{ user_full_name: current_user.full_name, user_email: current_user.email } { user_full_name: @comment.user.full_name, user_email: @comment.user.email }
) )
else else
render json: { render json: {
error: comment.errors.full_messages error: @comment.errors.full_messages
}, status: :unprocessable_entity
end
end
def destroy
@comment = Comment.find(params[:id])
authorize @comment
if @comment.destroy
render json: {
id: @comment.id,
}, status: :accepted
else
render json: {
error: @comment.errors.full_messages
}, status: :unprocessable_entity }, status: :unprocessable_entity
end end
end end
private private
def comment_params def comment_create_params
params params
.require(:comment) .require(:comment)
.permit(:body, :parent_id, :is_post_update) .permit(policy(@comment).permitted_attributes_for_create)
.merge( .merge(
user_id: current_user.id, user_id: current_user.id,
post_id: params[:post_id] post_id: params[:post_id]
) )
end end
def comment_update_params
params
.require(:comment)
.permit(policy(@comment).permitted_attributes_for_update)
end
end end

View File

@@ -3,13 +3,13 @@ class PostStatusChangesController < ApplicationController
post_status_changes = PostStatusChange post_status_changes = PostStatusChange
.select( .select(
:post_status_id, :post_status_id,
:updated_at, :created_at,
'users.full_name as user_full_name', 'users.full_name as user_full_name',
'users.email as user_email', 'users.email as user_email',
) )
.where(post_id: params[:post_id]) .where(post_id: params[:post_id])
.left_outer_joins(:user) .left_outer_joins(:user)
.order(updated_at: :asc) .order(created_at: :asc)
render json: post_status_changes render json: post_status_changes
end end

View File

@@ -1,5 +1,5 @@
class PostsController < ApplicationController class PostsController < ApplicationController
before_action :authenticate_user!, only: [:create, :update] before_action :authenticate_user!, only: [:create, :update, :destroy]
def index def index
posts = Post posts = Post
@@ -25,21 +25,37 @@ class PostsController < ApplicationController
end end
def create def create
post = Post.new(post_params) @post = Post.new
@post.assign_attributes(post_create_params)
if post.save if @post.save
Follow.create(post_id: post.id, user_id: current_user.id) Follow.create(post_id: @post.id, user_id: current_user.id)
render json: post, status: :created render json: @post, status: :created
else else
render json: { render json: {
error: post.errors.full_messages error: @post.errors.full_messages
}, status: :unprocessable_entity }, status: :unprocessable_entity
end end
end end
def show def show
@post = Post.find(params[:id]) @post = Post
.select(
:id,
:title,
:description,
:board_id,
:user_id,
:post_status_id,
:created_at,
:updated_at,
'users.email as user_email',
'users.full_name as user_full_name'
)
.joins(:user)
.find(params[:id])
@post_statuses = PostStatus.select(:id, :name, :color).order(order: :asc) @post_statuses = PostStatus.select(:id, :name, :color).order(order: :asc)
@board = @post.board @board = @post.board
@@ -51,35 +67,41 @@ class PostsController < ApplicationController
end end
def update def update
post = Post.find(params[:id]) @post = Post.find(params[:id])
authorize post authorize @post
post.board_id = params[:post][:board_id] if params[:post].has_key?(:board_id)
post_status_changed = false @post.assign_attributes(post_update_params)
if params[:post].has_key?(:post_status_id) and
params[:post][:post_status_id] != post.post_status_id
post_status_changed = true
post.post_status_id = params[:post][:post_status_id]
end
if post.save if @post.save
if post_status_changed if @post.post_status_id_previously_changed?
PostStatusChange.create( PostStatusChange.create(
user_id: current_user.id, user_id: current_user.id,
post_id: post.id, post_id: @post.id,
post_status_id: post.post_status_id post_status_id: @post.post_status_id
) )
send_notifications(post) UserMailer.notify_followers_of_post_status_change(post: @post).deliver_later
end end
render json: post, status: :no_content render json: @post
else else
render json: { render json: {
error: post.errors.full_messages error: @post.errors.full_messages
}, status: :unprocessable_entity
end
end
def destroy
@post = Post.find(params[:id])
authorize @post
if @post.destroy
render json: {
id: @post.id,
}, status: :accepted
else
render json: {
error: @post.errors.full_messages
}, status: :unprocessable_entity }, status: :unprocessable_entity
end end
end end
@@ -95,14 +117,16 @@ class PostsController < ApplicationController
.except(:page, :search) .except(:page, :search)
end end
def post_params def post_create_params
params params
.require(:post) .require(:post)
.permit(:title, :description, :board_id) .permit(policy(@post).permitted_attributes_for_create)
.merge(user_id: current_user.id) .merge(user_id: current_user.id)
end end
def send_notifications(post) def post_update_params
UserMailer.notify_followers_of_post_status_change(post: post).deliver_later params
.require(:post)
.permit(policy(@post).permitted_attributes_for_update)
end end
end end

View File

@@ -0,0 +1,72 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import { State } from "../../reducers/rootReducer";
export const COMMENT_DELETE_START = 'COMMENT_DELETE_START';
interface CommentDeleteStartAction {
type: typeof COMMENT_DELETE_START;
}
export const COMMENT_DELETE_SUCCESS = 'COMMENT_DELETE_SUCCESS';
interface CommentDeleteSuccessAction {
type: typeof COMMENT_DELETE_SUCCESS;
postId: number;
commentId: number;
}
export const COMMENT_DELETE_FAILURE = 'COMMENT_DELETE_FAILURE';
interface CommentDeleteFailureAction {
type: typeof COMMENT_DELETE_FAILURE;
error: string;
}
export type CommentDeleteActionTypes =
CommentDeleteStartAction |
CommentDeleteSuccessAction |
CommentDeleteFailureAction;
const commentDeleteStart = (): CommentDeleteStartAction => ({
type: COMMENT_DELETE_START,
});
const commentDeleteSuccess = (
postId: number,
commentId: number,
): CommentDeleteSuccessAction => ({
type: COMMENT_DELETE_SUCCESS,
postId,
commentId,
});
const commentDeleteFailure = (error: string): CommentDeleteFailureAction => ({
type: COMMENT_DELETE_FAILURE,
error,
});
export const deleteComment = (
postId: number,
commentId: number,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => (
async (dispatch) => {
dispatch(commentDeleteStart());
try {
const res = await fetch(`/posts/${postId}/comments/${commentId}`, {
method: 'DELETE',
headers: buildRequestHeaders(authenticityToken),
});
const json = await res.json();
if (res.status === HttpStatus.Accepted) {
dispatch(commentDeleteSuccess(postId, commentId));
} else {
dispatch(commentDeleteFailure(json.error));
}
} catch (e) {
dispatch(commentDeleteFailure(e));
}
}
);

View File

@@ -1,43 +1,81 @@
import { ThunkAction } from "redux-thunk";
import { State } from "../../reducers/rootReducer";
import { Action } from "redux"; import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders"; import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import ICommentJSON from "../../interfaces/json/IComment";
import { State } from "../../reducers/rootReducer";
export const TOGGLE_COMMENT_IS_UPDATE_SUCCESS = 'TOGGLE_COMMENT_IS_UPDATE_SUCCESS'; export const COMMENT_UPDATE_START = 'COMMENT_UPDATE_START';
export interface ToggleIsUpdateSuccessAction { interface CommentUpdateStartAction {
type: typeof TOGGLE_COMMENT_IS_UPDATE_SUCCESS; type: typeof COMMENT_UPDATE_START;
commentId: number;
} }
const toggleIsUpdateSuccess = ( export const COMMENT_UPDATE_SUCCESS = 'COMMENT_UPDATE_SUCCESS';
commentId: number, interface CommentUpdateSuccessAction {
): ToggleIsUpdateSuccessAction => ({ type: typeof COMMENT_UPDATE_SUCCESS;
type: TOGGLE_COMMENT_IS_UPDATE_SUCCESS, comment: ICommentJSON;
commentId, }
export const COMMENT_UPDATE_FAILURE = 'COMMENT_UPDATE_FAILURE';
interface CommentUpdateFailureAction {
type: typeof COMMENT_UPDATE_FAILURE;
error: string;
}
export type CommentUpdateActionTypes =
CommentUpdateStartAction |
CommentUpdateSuccessAction |
CommentUpdateFailureAction;
const commentUpdateStart = (): CommentUpdateStartAction => ({
type: COMMENT_UPDATE_START,
}); });
export const toggleCommentIsUpdate = ( const commentUpdateSuccess = (
commentJSON: ICommentJSON,
): CommentUpdateSuccessAction => ({
type: COMMENT_UPDATE_SUCCESS,
comment: commentJSON,
});
const commentUpdateFailure = (error: string): CommentUpdateFailureAction => ({
type: COMMENT_UPDATE_FAILURE,
error,
});
export const updateComment = (
postId: number, postId: number,
commentId: number, commentId: number,
currentIsPostUpdate: boolean, body: string,
isPostUpdate: boolean,
authenticityToken: string, authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { ): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(commentUpdateStart());
try { try {
const response = await fetch(`/posts/${postId}/comments/${commentId}`, { const res = await fetch(`/posts/${postId}/comments/${commentId}`, {
method: 'PATCH', method: 'PATCH',
headers: buildRequestHeaders(authenticityToken), headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({ body: JSON.stringify({
comment: { comment: {
is_post_update: !currentIsPostUpdate, body,
is_post_update: isPostUpdate,
}, },
}) }),
}); });
const json = await res.json();
if (response.status === 200) { if (res.status === HttpStatus.OK) {
dispatch(toggleIsUpdateSuccess(commentId)); dispatch(commentUpdateSuccess(json));
} else {
dispatch(commentUpdateFailure(json.error));
} }
return Promise.resolve(res);
} catch (e) { } catch (e) {
console.log(e); dispatch(commentUpdateFailure(e));
return Promise.resolve(null);
} }
} };

View File

@@ -1,40 +0,0 @@
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import { State } from '../../reducers/rootReducer';
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
export const CHANGE_POST_BOARD_SUCCESS = 'CHANGE_POST_BOARD_SUCCESS';
export interface ChangePostBoardSuccessAction {
type: typeof CHANGE_POST_BOARD_SUCCESS;
newBoardId: number;
}
const changePostBoardSuccess = (newBoardId: number): ChangePostBoardSuccessAction => ({
type: CHANGE_POST_BOARD_SUCCESS,
newBoardId,
});
export const changePostBoard = (
postId: number,
newBoardId: number,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
try {
const response = await fetch(`/posts/${postId}`, {
method: 'PATCH',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
post: {
board_id: newBoardId,
},
})
});
if (response.status === 204) {
dispatch(changePostBoardSuccess(newBoardId));
}
} catch (e) {
console.log(e);
}
}

View File

@@ -0,0 +1,61 @@
export const POST_CHANGE_EDIT_FORM_TITLE = 'POST_CHANGE_EDIT_FORM_TITLE';
interface PostChangeEditFormTitle {
type: typeof POST_CHANGE_EDIT_FORM_TITLE,
title: string,
}
export const changePostEditFormTitle = (
title: string
): PostChangeEditFormTitle => ({
type: POST_CHANGE_EDIT_FORM_TITLE,
title,
});
export const POST_CHANGE_EDIT_FORM_DESCRIPTION = 'POST_CHANGE_EDIT_FORM_DESCRIPTION';
interface PostChangeEditFormDescription {
type: typeof POST_CHANGE_EDIT_FORM_DESCRIPTION,
description: string,
}
export const changePostEditFormDescription = (
description: string
): PostChangeEditFormDescription => ({
type: POST_CHANGE_EDIT_FORM_DESCRIPTION,
description,
});
export const POST_CHANGE_EDIT_FORM_BOARD = 'POST_CHANGE_EDIT_FORM_BOARD';
interface PostChangeEditFormBoard {
type: typeof POST_CHANGE_EDIT_FORM_BOARD,
boardId: number,
}
export const changePostEditFormBoard = (
boardId: number
): PostChangeEditFormBoard => ({
type: POST_CHANGE_EDIT_FORM_BOARD,
boardId,
});
export const POST_CHANGE_EDIT_FORM_POST_STATUS = 'POST_CHANGE_EDIT_FORM_POST_STATUS';
interface PostChangeEditFormPostStatus {
type: typeof POST_CHANGE_EDIT_FORM_POST_STATUS,
postStatusId: number,
}
export const changePostEditFormPostStatus = (
postStatusId: number
): PostChangeEditFormPostStatus => ({
type: POST_CHANGE_EDIT_FORM_POST_STATUS,
postStatusId,
});
export type ChangePostEditFormActionTypes =
PostChangeEditFormTitle |
PostChangeEditFormDescription |
PostChangeEditFormBoard |
PostChangeEditFormPostStatus;

View File

@@ -1,40 +0,0 @@
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import { State } from '../../reducers/rootReducer';
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
export const CHANGE_POST_STATUS_SUCCESS = 'CHANGE_POST_STATUS_SUCCESS';
export interface ChangePostStatusSuccessAction {
type: typeof CHANGE_POST_STATUS_SUCCESS;
newPostStatusId: number;
}
const changePostStatusSuccess = (newPostStatusId: number): ChangePostStatusSuccessAction => ({
type: CHANGE_POST_STATUS_SUCCESS,
newPostStatusId,
});
export const changePostStatus = (
postId: number,
newPostStatusId: number,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
try {
const response = await fetch(`/posts/${postId}`, {
method: 'PATCH',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
post: {
post_status_id: newPostStatusId,
},
})
});
if (response.status === 204) {
dispatch(changePostStatusSuccess(newPostStatusId));
}
} catch (e) {
console.log(e);
}
}

View File

@@ -0,0 +1,69 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import { State } from "../../reducers/rootReducer";
export const POST_DELETE_START = 'POST_DELETE_START';
interface PostDeleteStartAction {
type: typeof POST_DELETE_START;
}
export const POST_DELETE_SUCCESS = 'POST_DELETE_SUCCESS';
interface PostDeleteSuccessAction {
type: typeof POST_DELETE_SUCCESS;
postId: number;
}
export const POST_DELETE_FAILURE = 'POST_DELETE_FAILURE';
interface PostDeleteFailureAction {
type: typeof POST_DELETE_FAILURE;
error: string;
}
export type PostDeleteActionTypes =
PostDeleteStartAction |
PostDeleteSuccessAction |
PostDeleteFailureAction;
const postDeleteStart = (): PostDeleteStartAction => ({
type: POST_DELETE_START,
});
const postDeleteSuccess = (
postId: number,
): PostDeleteSuccessAction => ({
type: POST_DELETE_SUCCESS,
postId,
});
const postDeleteFailure = (error: string): PostDeleteFailureAction => ({
type: POST_DELETE_FAILURE,
error,
});
export const deletePost = (
postId: number,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => (
async (dispatch) => {
dispatch(postDeleteStart());
try {
const res = await fetch(`/posts/${postId}`, {
method: 'DELETE',
headers: buildRequestHeaders(authenticityToken),
});
const json = await res.json();
if (res.status === HttpStatus.Accepted) {
dispatch(postDeleteSuccess(postId));
} else {
dispatch(postDeleteFailure(json.error));
}
} catch (e) {
dispatch(postDeleteFailure(e));
}
}
);

View File

@@ -0,0 +1,9 @@
export const POST_TOGGLE_EDIT_MODE = 'POST_TOGGLE_EDIT_MODE';
export interface PostToggleEditMode {
type: typeof POST_TOGGLE_EDIT_MODE;
}
export const togglePostEditMode = (): PostToggleEditMode => ({
type: POST_TOGGLE_EDIT_MODE,
});

View File

@@ -0,0 +1,83 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import IPostJSON from "../../interfaces/json/IPost";
import { State } from "../../reducers/rootReducer";
export const POST_UPDATE_START = 'POST_UPDATE_START';
interface PostUpdateStartAction {
type: typeof POST_UPDATE_START;
}
export const POST_UPDATE_SUCCESS = 'POST_UPDATE_SUCCESS';
interface PostUpdateSuccessAction {
type: typeof POST_UPDATE_SUCCESS;
post: IPostJSON;
}
export const POST_UPDATE_FAILURE = 'POST_UPDATE_FAILURE';
interface PostUpdateFailureAction {
type: typeof POST_UPDATE_FAILURE;
error: string;
}
export type PostUpdateActionTypes =
PostUpdateStartAction |
PostUpdateSuccessAction |
PostUpdateFailureAction;
const postUpdateStart = (): PostUpdateStartAction => ({
type: POST_UPDATE_START,
});
const postUpdateSuccess = (
postJSON: IPostJSON,
): PostUpdateSuccessAction => ({
type: POST_UPDATE_SUCCESS,
post: postJSON,
});
const postUpdateFailure = (error: string): PostUpdateFailureAction => ({
type: POST_UPDATE_FAILURE,
error,
});
export const updatePost = (
id: number,
title: string,
description: string,
boardId: number,
postStatusId: number,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(postUpdateStart());
try {
const res = await fetch(`/posts/${id}`, {
method: 'PATCH',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
post: {
title,
description,
board_id: boardId,
post_status_id: postStatusId,
}
}),
});
const json = await res.json();
if (res.status === HttpStatus.OK) {
dispatch(postUpdateSuccess(json));
} else {
dispatch(postUpdateFailure(json.error));
}
return Promise.resolve(res);
} catch (e) {
dispatch(postUpdateFailure(e));
return Promise.resolve(null);
}
};

View File

@@ -4,12 +4,11 @@ import Gravatar from 'react-gravatar';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import NewComment from './NewComment'; import NewComment from './NewComment';
import Separator from '../common/Separator';
import { MutedText } from '../common/CustomTexts';
import { ReplyFormState } from '../../reducers/replyFormReducer'; import { ReplyFormState } from '../../reducers/replyFormReducer';
import friendlyDate from '../../helpers/datetime'; import CommentEditForm from './CommentEditForm';
import CommentFooter from './CommentFooter';
interface Props { interface Props {
id: number; id: number;
@@ -17,119 +16,147 @@ interface Props {
isPostUpdate: boolean; isPostUpdate: boolean;
userFullName: string; userFullName: string;
userEmail: string; userEmail: string;
createdAt: string;
updatedAt: string; updatedAt: string;
replyForm: ReplyFormState; replyForm: ReplyFormState;
handleToggleCommentReply(): void; handleToggleCommentReply(): void;
handleCommentReplyBodyChange(e: React.FormEvent): void; handleCommentReplyBodyChange(e: React.FormEvent): void;
handleToggleIsCommentUpdate(commentId: number, currentIsPostUpdate: boolean): void;
handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean): void; handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean): void;
handleUpdateComment(commentId: number, body: string, isPostUpdate: boolean, onSuccess: Function): void;
handleDeleteComment(id: number): void;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
currentUserEmail: string; currentUserEmail: string;
} }
const Comment = ({ interface State {
id, editMode: boolean;
body, }
isPostUpdate,
userFullName,
userEmail,
updatedAt,
replyForm, class Comment extends React.Component<Props, State> {
handleToggleCommentReply, constructor(props: Props) {
handleCommentReplyBodyChange, super(props);
handleToggleIsCommentUpdate,
handleSubmitComment,
isLoggedIn, this.state = {
isPowerUser, editMode: false,
currentUserEmail, };
}: Props) => (
<div className="comment">
<div className="commentHeader">
<Gravatar email={userEmail} size={28} className="gravatar" />
<span className="commentAuthor">{userFullName}</span>
{
isPostUpdate ?
<span className="postUpdateBadge">
{I18n.t('post.comments.post_update_badge')}
</span>
:
null
}
</div>
<ReactMarkdown this.toggleEditMode = this.toggleEditMode.bind(this);
className="commentBody" this._handleUpdateComment = this._handleUpdateComment.bind(this);
disallowedTypes={['heading', 'image', 'html']} }
unwrapDisallowed
>
{body}
</ReactMarkdown>
<div className="commentFooter"> toggleEditMode() {
<a className="commentReplyButton commentLink" onClick={handleToggleCommentReply}> this.setState({editMode: !this.state.editMode});
}
_handleUpdateComment(body: string, isPostUpdate: boolean) {
this.props.handleUpdateComment(
this.props.id,
body,
isPostUpdate,
this.toggleEditMode,
);
}
render() {
const {
id,
body,
isPostUpdate,
userFullName,
userEmail,
createdAt,
updatedAt,
replyForm,
handleToggleCommentReply,
handleCommentReplyBodyChange,
handleSubmitComment,
handleDeleteComment,
isLoggedIn,
isPowerUser,
currentUserEmail,
} = this.props;
return (
<div className="comment">
<div className="commentHeader">
<Gravatar email={userEmail} size={28} className="gravatar" />
<span className="commentAuthor">{userFullName}</span>
{
isPostUpdate ?
<span className="postUpdateBadge">
{I18n.t('post.comments.post_update_badge')}
</span>
:
null
}
</div>
{
this.state.editMode ?
<CommentEditForm
id={id}
initialBody={body}
initialIsPostUpdate={isPostUpdate}
isPowerUser={isPowerUser}
handleUpdateComment={this._handleUpdateComment}
toggleEditMode={this.toggleEditMode}
/>
:
<>
<ReactMarkdown
className="commentBody"
disallowedTypes={['heading', 'image', 'html']}
unwrapDisallowed
>
{body}
</ReactMarkdown>
<CommentFooter
id={id}
createdAt={createdAt}
updatedAt={updatedAt}
replyForm={replyForm}
isPowerUser={isPowerUser}
currentUserEmail={currentUserEmail}
commentAuthorEmail={userEmail}
handleDeleteComment={handleDeleteComment}
handleToggleCommentReply={handleToggleCommentReply}
toggleEditMode={this.toggleEditMode}
/>
</>
}
{ {
replyForm.isOpen ? replyForm.isOpen ?
I18n.t('common.buttons.cancel') <NewComment
: body={replyForm.body}
I18n.t('post.comments.reply_button') parentId={id}
postUpdateFlagValue={replyForm.isPostUpdate}
isSubmitting={replyForm.isSubmitting}
error={replyForm.error}
handleChange={handleCommentReplyBodyChange}
handlePostUpdateFlag={() => null}
handleSubmit={handleSubmitComment}
isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser}
userEmail={currentUserEmail}
/>
:
null
} }
</a> </div>
{ );
isPowerUser ? }
<> }
<Separator />
<a
onClick={() => handleToggleIsCommentUpdate(id, isPostUpdate)}
className="commentLink"
>
{ 'Post update: ' + (isPostUpdate ? 'yes' : 'no') }
</a>
<Separator />
<a href={`/admin/comments/${id}/edit`} className="commentLink" data-turbolinks="false">
{I18n.t('common.buttons.edit')}
</a>
<Separator />
<a
href={`/admin/comments/${id}`}
className="commentLink"
data-method="delete"
data-confirm="Are you sure?"
data-turbolinks="false">
{I18n.t('common.buttons.delete')}
</a>
</>
:
null
}
<Separator />
<MutedText>{friendlyDate(updatedAt)}</MutedText>
</div>
{
replyForm.isOpen ?
<NewComment
body={replyForm.body}
parentId={id}
postUpdateFlagValue={replyForm.isPostUpdate}
isSubmitting={replyForm.isSubmitting}
error={replyForm.error}
handleChange={handleCommentReplyBodyChange}
handlePostUpdateFlag={() => null}
handleSubmit={handleSubmitComment}
isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser}
userEmail={currentUserEmail}
/>
:
null
}
</div>
);
export default Comment; export default Comment;

View File

@@ -0,0 +1,100 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Button from '../common/Button';
interface Props {
id: number;
initialBody: string;
initialIsPostUpdate: boolean;
isPowerUser: boolean;
handleUpdateComment(body: string, isPostUpdate: boolean): void;
toggleEditMode(): void;
}
interface State {
body: string;
isPostUpdate: boolean;
}
class CommentEditForm extends React.Component<Props, State> {
constructor(props) {
super(props);
this.state = {
body: '',
isPostUpdate: false,
};
this.handleCommentBodyChange = this.handleCommentBodyChange.bind(this);
this.handleCommentIsPostUpdateChange = this.handleCommentIsPostUpdateChange.bind(this);
}
componentDidMount() {
this.setState({
body: this.props.initialBody,
isPostUpdate: this.props.initialIsPostUpdate,
});
}
handleCommentBodyChange(newCommentBody: string) {
this.setState({ body: newCommentBody });
}
handleCommentIsPostUpdateChange(newIsPostUpdate: boolean) {
this.setState({ isPostUpdate: newIsPostUpdate });
}
render() {
const { id, isPowerUser, handleUpdateComment, toggleEditMode } = this.props;
const { body, isPostUpdate } = this.state;
return (
<div className="editCommentForm">
<textarea
value={body}
onChange={e => this.handleCommentBodyChange(e.target.value)}
className="commentForm"
/>
<div>
<div>
{
isPowerUser ?
<>
<input
id={`isPostUpdateFlagComment${id}`}
type="checkbox"
onChange={e => this.handleCommentIsPostUpdateChange(e.target.checked)}
checked={isPostUpdate || false}
/>
&nbsp;
<label htmlFor={`isPostUpdateFlagComment${id}`}>
{I18n.t('post.new_comment.is_post_update')}
</label>
</>
:
null
}
</div>
<div>
<a className="commentLink" onClick={toggleEditMode}>
{ I18n.t('common.buttons.cancel') }
</a>
&nbsp;
<Button
onClick={() => handleUpdateComment(body, isPostUpdate)}
>
{ I18n.t('common.buttons.update') }
</Button>
</div>
</div>
</div>
);
}
}
export default CommentEditForm;

View File

@@ -0,0 +1,78 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Separator from '../common/Separator';
import { MutedText } from '../common/CustomTexts';
import friendlyDate from '../../helpers/datetime';
import { ReplyFormState } from '../../reducers/replyFormReducer';
interface Props {
id: number;
createdAt: string;
updatedAt: string;
replyForm: ReplyFormState;
isPowerUser: boolean;
currentUserEmail: string;
commentAuthorEmail: string;
handleDeleteComment(id: number): void;
handleToggleCommentReply(): void;
toggleEditMode(): void;
}
const CommentFooter = ({
id,
createdAt,
updatedAt,
replyForm,
isPowerUser,
currentUserEmail,
commentAuthorEmail,
handleDeleteComment,
handleToggleCommentReply,
toggleEditMode,
}: Props) => (
<div className="commentFooter">
<a className="commentReplyButton commentLink" onClick={handleToggleCommentReply}>
{
replyForm.isOpen ?
I18n.t('common.buttons.cancel')
:
I18n.t('post.comments.reply_button')
}
</a>
{
isPowerUser || currentUserEmail === commentAuthorEmail ?
<>
<Separator />
<a onClick={toggleEditMode} className="commentLink">
{I18n.t('common.buttons.edit')}
</a>
<Separator />
<a
onClick={() => confirm(I18n.t('common.confirmation')) && handleDeleteComment(id)}
className="commentLink">
{I18n.t('common.buttons.delete')}
</a>
</>
:
null
}
<Separator />
<MutedText>{friendlyDate(createdAt)}</MutedText>
{
createdAt !== updatedAt ?
<>
<Separator />
<MutedText>{ I18n.t('common.edited').toLowerCase() }</MutedText>
</>
:
null
}
</div>
);
export default CommentFooter;

View File

@@ -13,8 +13,10 @@ interface Props {
toggleCommentReply(commentId: number): void; toggleCommentReply(commentId: number): void;
setCommentReplyBody(commentId: number, body: string): void; setCommentReplyBody(commentId: number, body: string): void;
handleToggleIsCommentUpdate(commentId: number, currentIsPostUpdate: boolean): void;
handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean): void; handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean): void;
handleUpdateComment(commentId: number, body: string, isPostUpdate: boolean, onSuccess: Function): void;
handleDeleteComment(id: number): void;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
@@ -29,8 +31,9 @@ const CommentList = ({
toggleCommentReply, toggleCommentReply,
setCommentReplyBody, setCommentReplyBody,
handleToggleIsCommentUpdate,
handleSubmitComment, handleSubmitComment,
handleUpdateComment,
handleDeleteComment,
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
@@ -49,8 +52,11 @@ const CommentList = ({
setCommentReplyBody(comment.id, (e.target as HTMLTextAreaElement).value) setCommentReplyBody(comment.id, (e.target as HTMLTextAreaElement).value)
) )
} }
handleToggleIsCommentUpdate={handleToggleIsCommentUpdate}
handleSubmitComment={handleSubmitComment} handleSubmitComment={handleSubmitComment}
handleUpdateComment={handleUpdateComment}
handleDeleteComment={handleDeleteComment}
{...comment} {...comment}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
@@ -66,8 +72,10 @@ const CommentList = ({
toggleCommentReply={toggleCommentReply} toggleCommentReply={toggleCommentReply}
setCommentReplyBody={setCommentReplyBody} setCommentReplyBody={setCommentReplyBody}
handleToggleIsCommentUpdate={handleToggleIsCommentUpdate}
handleSubmitComment={handleSubmitComment} handleSubmitComment={handleSubmitComment}
handleUpdateComment={handleUpdateComment}
handleDeleteComment={handleDeleteComment}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}

View File

@@ -26,12 +26,7 @@ interface Props {
toggleCommentReply(commentId: number): void; toggleCommentReply(commentId: number): void;
setCommentReplyBody(commentId: number, body: string): void; setCommentReplyBody(commentId: number, body: string): void;
toggleCommentIsPostUpdateFlag(): void; toggleCommentIsPostUpdateFlag(): void;
toggleCommentIsPostUpdate(
postId: number,
commentId: number,
currentIsPostUpdate: boolean,
authenticityToken: string,
): void;
submitComment( submitComment(
postId: number, postId: number,
body: string, body: string,
@@ -39,6 +34,19 @@ interface Props {
isPostUpdate: boolean, isPostUpdate: boolean,
authenticityToken: string, authenticityToken: string,
): void; ): void;
updateComment(
postId: number,
commentId: number,
body: string,
isPostUpdate: boolean,
onSuccess: Function,
authenticityToken: string,
): void;
deleteComment(
postId: number,
commentId: number,
authenticityToken: string,
): void;
} }
class CommentsP extends React.Component<Props> { class CommentsP extends React.Component<Props> {
@@ -46,15 +54,6 @@ class CommentsP extends React.Component<Props> {
this.props.requestComments(this.props.postId); this.props.requestComments(this.props.postId);
} }
_handleToggleIsCommentUpdate = (commentId: number, currentIsPostUpdate: boolean) => {
this.props.toggleCommentIsPostUpdate(
this.props.postId,
commentId,
currentIsPostUpdate,
this.props.authenticityToken,
);
}
_handleSubmitComment = (body: string, parentId: number, isPostUpdate: boolean) => { _handleSubmitComment = (body: string, parentId: number, isPostUpdate: boolean) => {
this.props.submitComment( this.props.submitComment(
this.props.postId, this.props.postId,
@@ -65,6 +64,25 @@ class CommentsP extends React.Component<Props> {
); );
} }
_handleUpdateComment = (commentId: number, body: string, isPostUpdate: boolean, onSuccess: Function) => {
this.props.updateComment(
this.props.postId,
commentId,
body,
isPostUpdate,
onSuccess,
this.props.authenticityToken,
);
}
_handleDeleteComment = (commentId: number) => {
this.props.deleteComment(
this.props.postId,
commentId,
this.props.authenticityToken,
);
}
render() { render() {
const { const {
isLoggedIn, isLoggedIn,
@@ -118,8 +136,9 @@ class CommentsP extends React.Component<Props> {
replyForms={replyForms} replyForms={replyForms}
toggleCommentReply={toggleCommentReply} toggleCommentReply={toggleCommentReply}
setCommentReplyBody={setCommentReplyBody} setCommentReplyBody={setCommentReplyBody}
handleToggleIsCommentUpdate={this._handleToggleIsCommentUpdate}
handleSubmitComment={this._handleSubmitComment} handleSubmitComment={this._handleSubmitComment}
handleUpdateComment={this._handleUpdateComment}
handleDeleteComment={this._handleDeleteComment}
parentId={null} parentId={null}
level={1} level={1}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}

View File

@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import I18n from 'i18n-js';
import Gravatar from 'react-gravatar'; import Gravatar from 'react-gravatar';
import I18n from 'i18n-js';
import NewCommentUpdateSection from './NewCommentUpdateSection'; import NewCommentUpdateSection from './NewCommentUpdateSection';
@@ -52,7 +52,7 @@ const NewComment = ({
value={body} value={body}
onChange={handleChange} onChange={handleChange}
placeholder={I18n.t('post.new_comment.body_placeholder')} placeholder={I18n.t('post.new_comment.body_placeholder')}
className="newCommentBody" className="commentForm"
/> />
<Button <Button
onClick={() => handleSubmit(body, parentId, postUpdateFlagValue)} onClick={() => handleSubmit(body, parentId, postUpdateFlagValue)}

View File

@@ -0,0 +1,107 @@
import * as React from 'react';
import I18n from 'i18n-js';
import PostBoardSelect from './PostBoardSelect';
import PostStatusSelect from './PostStatusSelect';
import IPostStatus from '../../interfaces/IPostStatus';
import IBoard from '../../interfaces/IBoard';
import Button from '../common/Button';
import Spinner from '../common/Spinner';
interface Props {
title: string;
description?: string;
boardId: number;
postStatusId?: number;
isUpdating: boolean;
error: string;
handleChangeTitle(title: string): void;
handleChangeDescription(description: string): void;
handleChangeBoard(boardId: number): void;
handleChangePostStatus(postStatusId: number): void;
isPowerUser: boolean;
boards: Array<IBoard>;
postStatuses: Array<IPostStatus>;
toggleEditMode(): void;
handleUpdatePost(
title: string,
description: string,
boardId: number,
postStatusId: number,
): void;
}
const PostEditForm = ({
title,
description,
boardId,
postStatusId,
isUpdating,
error,
handleChangeTitle,
handleChangeDescription,
handleChangeBoard,
handleChangePostStatus,
isPowerUser,
boards,
postStatuses,
toggleEditMode,
handleUpdatePost,
}: Props) => (
<div className="postEditForm">
<div className="postHeader">
<input
type="text"
value={title}
onChange={e => handleChangeTitle(e.target.value)}
className="form-control"
/>
</div>
{
isPowerUser ?
<div className="postSettings">
<PostBoardSelect
boards={boards}
selectedBoardId={boardId}
handleChange={newBoardId => handleChangeBoard(newBoardId)}
/>
<PostStatusSelect
postStatuses={postStatuses}
selectedPostStatusId={postStatusId}
handleChange={newPostStatusId => handleChangePostStatus(newPostStatusId)}
/>
</div>
:
null
}
<textarea
value={description}
onChange={e => handleChangeDescription(e.target.value)}
rows={5}
className="form-control"
/>
<div className="postEditFormButtons">
<a onClick={toggleEditMode}>
{ I18n.t('common.buttons.cancel') }
</a>
&nbsp;
<Button onClick={() => handleUpdatePost(title, description, boardId, postStatusId)}>
{ isUpdating ? <Spinner /> : I18n.t('common.buttons.update') }
</Button>
</div>
</div>
);
export default PostEditForm;

View File

@@ -0,0 +1,53 @@
import * as React from 'react';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js';
import { MutedText } from '../common/CustomTexts';
import friendlyDate from '../../helpers/datetime';
import Separator from '../common/Separator';
interface Props {
createdAt: string;
toggleEditMode(): void;
handleDeletePost(): void;
isPowerUser: boolean;
authorEmail: string;
authorFullName: string;
currentUserEmail: string;
}
const PostFooter = ({
createdAt,
toggleEditMode,
handleDeletePost,
isPowerUser,
authorEmail,
authorFullName,
currentUserEmail,
}: Props) => (
<div className="postFooter">
<div className="postAuthor">
<span>{ I18n.t('post.published_by').toLowerCase() } &nbsp;</span>
<Gravatar email={authorEmail} size={24} className="postAuthorAvatar" /> &nbsp;
{authorFullName}
</div>
{
isPowerUser || authorEmail === currentUserEmail ?
<>
<a onClick={toggleEditMode}>
{ I18n.t('common.buttons.edit') }
</a>
<Separator />
<a onClick={() => confirm(I18n.t('common.confirmation')) && handleDeletePost()}>
{ I18n.t('common.buttons.delete') }
</a>
<Separator />
</>
:
null
}
<MutedText>{friendlyDate(createdAt)}</MutedText>
</div>
);
export default PostFooter;

View File

@@ -1,32 +1,34 @@
import * as React from 'react'; import * as React from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import I18n from 'i18n-js';
import IPost from '../../interfaces/IPost'; import IPost from '../../interfaces/IPost';
import IPostStatus from '../../interfaces/IPostStatus'; import IPostStatus from '../../interfaces/IPostStatus';
import IBoard from '../../interfaces/IBoard'; import IBoard from '../../interfaces/IBoard';
import PostUpdateList from './PostUpdateList'; import PostUpdateList from './PostUpdateList';
import PostEditForm from './PostEditForm';
import PostFooter from './PostFooter';
import LikeList from './LikeList'; import LikeList from './LikeList';
import ActionBox from './ActionBox'; import ActionBox from './ActionBox';
import LikeButton from '../../containers/LikeButton'; import LikeButton from '../../containers/LikeButton';
import PostBoardSelect from './PostBoardSelect';
import PostStatusSelect from './PostStatusSelect';
import PostBoardLabel from '../common/PostBoardLabel'; import PostBoardLabel from '../common/PostBoardLabel';
import PostStatusLabel from '../common/PostStatusLabel'; import PostStatusLabel from '../common/PostStatusLabel';
import Comments from '../../containers/Comments'; import Comments from '../../containers/Comments';
import { MutedText } from '../common/CustomTexts';
import Sidebar from '../common/Sidebar'; import Sidebar from '../common/Sidebar';
import { LikesState } from '../../reducers/likesReducer'; import { LikesState } from '../../reducers/likesReducer';
import { CommentsState } from '../../reducers/commentsReducer'; import { CommentsState } from '../../reducers/commentsReducer';
import { PostStatusChangesState } from '../../reducers/postStatusChangesReducer'; import { PostStatusChangesState } from '../../reducers/postStatusChangesReducer';
import { PostEditFormState } from '../../reducers/currentPostReducer';
import friendlyDate, { fromRailsStringToJavascriptDate } from '../../helpers/datetime'; import { fromRailsStringToJavascriptDate } from '../../helpers/datetime';
import HttpStatus from '../../constants/http_status';
interface Props { interface Props {
postId: number; postId: number;
post: IPost; post: IPost;
editMode: boolean;
editForm: PostEditFormState;
likes: LikesState; likes: LikesState;
followed: boolean; followed: boolean;
comments: CommentsState; comments: CommentsState;
@@ -35,26 +37,38 @@ interface Props {
postStatuses: Array<IPostStatus>; postStatuses: Array<IPostStatus>;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
userFullName: string; currentUserFullName: string;
userEmail: string; currentUserEmail: string;
authenticityToken: string; authenticityToken: string;
requestPost(postId: number): void; requestPost(postId: number): void;
updatePost(
postId: number,
title: string,
description: string,
boardId: number,
postStatusId: number,
authenticityToken: string,
): Promise<any>;
requestLikes(postId: number): void; requestLikes(postId: number): void;
requestFollow(postId: number): void; requestFollow(postId: number): void;
requestPostStatusChanges(postId: number): void; requestPostStatusChanges(postId: number): void;
changePostBoard(
postId: number, toggleEditMode(): void;
newBoardId: number, handleChangeEditFormTitle(title: string): void;
authenticityToken: string, handleChangeEditFormDescription(description: string): void;
): void; handleChangeEditFormBoard(boardId: number): void;
changePostStatus( handleChangeEditFormPostStatus(postStatusId: number): void;
postId: number,
deletePost(postId: number, authenticityToken: string): Promise<any>;
postStatusChangeSubmitted(
newPostStatusId: number, newPostStatusId: number,
userFullName: string, userFullName: string,
userEmail: string, userEmail: string,
authenticityToken: string,
): void; ): void;
submitFollow( submitFollow(
postId: number, postId: number,
isFollow: boolean, isFollow: boolean,
@@ -63,8 +77,15 @@ interface Props {
} }
class PostP extends React.Component<Props> { class PostP extends React.Component<Props> {
constructor(props: Props) {
super(props);
this._handleUpdatePost = this._handleUpdatePost.bind(this);
this._handleDeletePost = this._handleDeletePost.bind(this);
}
componentDidMount() { componentDidMount() {
const {postId} = this.props; const { postId } = this.props;
this.props.requestPost(postId); this.props.requestPost(postId);
this.props.requestLikes(postId); this.props.requestLikes(postId);
@@ -72,9 +93,51 @@ class PostP extends React.Component<Props> {
this.props.requestPostStatusChanges(postId); this.props.requestPostStatusChanges(postId);
} }
_handleUpdatePost(title: string, description: string, boardId: number, postStatusId: number) {
const {
postId,
post,
currentUserFullName,
currentUserEmail,
authenticityToken,
updatePost,
postStatusChangeSubmitted,
} = this.props;
const oldPostStatusId = post.postStatusId;
updatePost(
postId,
title,
description,
boardId,
postStatusId,
authenticityToken,
).then(res => {
if (res?.status !== HttpStatus.OK) return;
if (postStatusId === oldPostStatusId) return;
postStatusChangeSubmitted(
postStatusId,
currentUserFullName,
currentUserEmail,
);
});
}
_handleDeletePost() {
this.props.deletePost(
this.props.postId,
this.props.authenticityToken
).then(() => window.location.href = `/boards/${this.props.post.boardId}`);
}
render() { render() {
const { const {
post, post,
editMode,
editForm,
likes, likes,
followed, followed,
comments, comments,
@@ -84,13 +147,15 @@ class PostP extends React.Component<Props> {
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
userFullName, currentUserEmail,
userEmail,
authenticityToken, authenticityToken,
changePostBoard,
changePostStatus,
submitFollow, submitFollow,
toggleEditMode,
handleChangeEditFormTitle,
handleChangeEditFormDescription,
handleChangeEditFormBoard,
handleChangeEditFormPostStatus,
} = this.props; } = this.props;
const postUpdates = [ const postUpdates = [
@@ -98,7 +163,7 @@ class PostP extends React.Component<Props> {
...postStatusChanges.items, ...postStatusChanges.items,
].sort( ].sort(
(a, b) => (a, b) =>
fromRailsStringToJavascriptDate(a.updatedAt) < fromRailsStringToJavascriptDate(b.updatedAt) ? 1 : -1 fromRailsStringToJavascriptDate(a.createdAt) < fromRailsStringToJavascriptDate(b.createdAt) ? 1 : -1
); );
return ( return (
@@ -126,71 +191,72 @@ class PostP extends React.Component<Props> {
</Sidebar> </Sidebar>
<div className="postAndCommentsContainer"> <div className="postAndCommentsContainer">
<> {
<div className="postHeader"> editMode ?
<LikeButton <PostEditForm
postId={post.id} {...editForm}
likesCount={likes.items.length}
liked={likes.items.find(like => like.email === userEmail) ? 1 : 0}
isLoggedIn={isLoggedIn}
authenticityToken={authenticityToken}
/>
<h2>{post.title}</h2>
{
isPowerUser && post ?
<a href={`/admin/posts/${post.id}`} data-turbolinks="false">
{I18n.t('post.edit_button')}
</a>
:
null
}
</div>
{
isPowerUser && post ?
<div className="postSettings">
<PostBoardSelect
boards={boards}
selectedBoardId={post.boardId}
handleChange={
newBoardId => changePostBoard(post.id, newBoardId, authenticityToken)
}
/>
<PostStatusSelect
postStatuses={postStatuses}
selectedPostStatusId={post.postStatusId}
handleChange={
newPostStatusId =>
changePostStatus(post.id, newPostStatusId, userFullName, userEmail, authenticityToken)
}
/>
</div>
:
<div className="postInfo">
<PostBoardLabel
{...boards.find(board => board.id === post.boardId)}
/>
<PostStatusLabel
{...postStatuses.find(postStatus => postStatus.id === post.postStatusId)}
/>
</div>
}
<ReactMarkdown
className="postDescription"
disallowedTypes={['heading', 'image', 'html']}
unwrapDisallowed
>
{post.description}
</ReactMarkdown>
<MutedText>{friendlyDate(post.createdAt)}</MutedText>
</>
handleChangeTitle={handleChangeEditFormTitle}
handleChangeDescription={handleChangeEditFormDescription}
handleChangeBoard={handleChangeEditFormBoard}
handleChangePostStatus={handleChangeEditFormPostStatus}
isPowerUser={isPowerUser}
boards={boards}
postStatuses={postStatuses}
toggleEditMode={toggleEditMode}
handleUpdatePost={this._handleUpdatePost}
/>
:
<>
<div className="postHeader">
<LikeButton
postId={post.id}
likesCount={likes.items.length}
liked={likes.items.find(like => like.email === currentUserEmail) ? 1 : 0}
isLoggedIn={isLoggedIn}
authenticityToken={authenticityToken}
/>
<h3>{post.title}</h3>
</div>
<div className="postInfo">
<PostBoardLabel
{...boards.find(board => board.id === post.boardId)}
/>
<PostStatusLabel
{...postStatuses.find(postStatus => postStatus.id === post.postStatusId)}
/>
</div>
<ReactMarkdown
className="postDescription"
disallowedTypes={['heading', 'image', 'html']}
unwrapDisallowed
>
{post.description}
</ReactMarkdown>
<PostFooter
createdAt={post.createdAt}
handleDeletePost={this._handleDeletePost}
toggleEditMode={toggleEditMode}
isPowerUser={isPowerUser}
authorEmail={post.userEmail}
authorFullName={post.userFullName}
currentUserEmail={currentUserEmail}
/>
</>
}
<Comments <Comments
postId={this.props.postId} postId={this.props.postId}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
userEmail={userEmail} userEmail={currentUserEmail}
authenticityToken={authenticityToken} authenticityToken={authenticityToken}
/> />
</div> </div>

View File

@@ -65,7 +65,7 @@ const PostUpdateList = ({
} }
</div> </div>
<MutedText>{friendlyDate(postUpdate.updatedAt)}</MutedText> <MutedText>{friendlyDate(postUpdate.createdAt)}</MutedText>
</div> </div>
)) ))
} }

View File

@@ -17,8 +17,8 @@ interface Props {
postStatuses: Array<IPostStatus>; postStatuses: Array<IPostStatus>;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
userFullName: string; currentUserFullName: string;
userEmail: string; currentUserEmail: string;
authenticityToken: string; authenticityToken: string;
} }
@@ -38,8 +38,8 @@ class PostRoot extends React.Component<Props> {
postStatuses, postStatuses,
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
userFullName, currentUserFullName,
userEmail, currentUserEmail,
authenticityToken authenticityToken
} = this.props; } = this.props;
@@ -52,8 +52,8 @@ class PostRoot extends React.Component<Props> {
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
userFullName={userFullName} currentUserFullName={currentUserFullName}
userEmail={userEmail} currentUserEmail={currentUserEmail}
authenticityToken={authenticityToken} authenticityToken={authenticityToken}
/> />
</Provider> </Provider>

View File

@@ -87,10 +87,7 @@ class BoardsEditable extends React.Component<Props, State> {
<Separator /> <Separator />
<a <a onClick={() => confirm(I18n.t('common.confirmation')) && handleDelete(id)}>
onClick={() => handleDelete(id)}
data-confirm="Are you sure?"
>
{I18n.t('common.buttons.delete')} {I18n.t('common.buttons.delete')}
</a> </a>
</div> </div>

View File

@@ -78,10 +78,7 @@ class PostStatusEditable extends React.Component<Props, State> {
<Separator /> <Separator />
<a <a onClick={() => confirm(I18n.t('common.confirmation')) && handleDelete(id)}>
onClick={() => handleDelete(id)}
data-confirm="Are you sure?"
>
{I18n.t('common.buttons.delete')} {I18n.t('common.buttons.delete')}
</a> </a>
</div> </div>

View File

@@ -6,7 +6,7 @@ interface Props {
} }
const Box = ({ customClass, children }: Props) => ( const Box = ({ customClass, children }: Props) => (
<div className={`box ${customClass}`}> <div className={`box${customClass ? ' ' + customClass : ''}`}>
{children} {children}
</div> </div>
); );

View File

@@ -9,7 +9,7 @@ interface Props {
} }
const SidebarBox = ({ title, customClass, children }: Props) => ( const SidebarBox = ({ title, customClass, children }: Props) => (
<div className={`sidebarBox ${customClass}`}> <div className={`sidebarBox${customClass ? ' ' + customClass : ''}`}>
<BoxTitleText>{title}</BoxTitleText> <BoxTitleText>{title}</BoxTitleText>
{children} {children}
</div> </div>

View File

@@ -6,12 +6,14 @@ import {
setCommentReplyBody, setCommentReplyBody,
toggleCommentIsPostUpdateFlag, toggleCommentIsPostUpdateFlag,
} from '../actions/Comment/handleCommentReplies'; } from '../actions/Comment/handleCommentReplies';
import { toggleCommentIsUpdate } from '../actions/Comment/updateComment';
import { submitComment } from '../actions/Comment/submitComment'; import { submitComment } from '../actions/Comment/submitComment';
import { updateComment } from '../actions/Comment/updateComment';
import { deleteComment } from '../actions/Comment/deleteComment';
import { State } from '../reducers/rootReducer'; import { State } from '../reducers/rootReducer';
import CommentsP from '../components/Comments/CommentsP'; import CommentsP from '../components/Comments/CommentsP';
import HttpStatus from '../constants/http_status';
const mapStateToProps = (state: State) => ({ const mapStateToProps = (state: State) => ({
comments: state.currentPost.comments.items, comments: state.currentPost.comments.items,
@@ -37,15 +39,6 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(toggleCommentIsPostUpdateFlag(null)); dispatch(toggleCommentIsPostUpdateFlag(null));
}, },
toggleCommentIsPostUpdate(
postId: number,
commentId: number,
currentIsPostUpdate: boolean,
authenticityToken: string,
) {
dispatch(toggleCommentIsUpdate(postId, commentId, currentIsPostUpdate, authenticityToken));
},
submitComment( submitComment(
postId: number, postId: number,
body: string, body: string,
@@ -55,6 +48,27 @@ const mapDispatchToProps = (dispatch) => ({
) { ) {
dispatch(submitComment(postId, body, parentId, isPostUpdate, authenticityToken)); dispatch(submitComment(postId, body, parentId, isPostUpdate, authenticityToken));
}, },
updateComment(
postId: number,
commentId: number,
body: string,
isPostUpdate: boolean,
onSuccess: Function,
authenticityToken: string,
) {
dispatch(updateComment(postId, commentId, body, isPostUpdate, authenticityToken)).then(res => {
if (res && res.status === HttpStatus.OK) onSuccess();
});
},
deleteComment(
postId: number,
commentId: number,
authenticityToken: string,
) {
dispatch(deleteComment(postId, commentId, authenticityToken));
},
}); });
export default connect( export default connect(

View File

@@ -1,9 +1,16 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { requestPost } from '../actions/Post/requestPost'; import { requestPost } from '../actions/Post/requestPost';
import { deletePost } from '../actions/Post/deletePost';
import { togglePostEditMode } from '../actions/Post/togglePostEditMode';
import {
changePostEditFormBoard,
changePostEditFormDescription,
changePostEditFormPostStatus,
changePostEditFormTitle
} from '../actions/Post/changePostEditForm';
import { requestLikes } from '../actions/Like/requestLikes'; import { requestLikes } from '../actions/Like/requestLikes';
import { changePostBoard } from '../actions/Post/changePostBoard';
import { changePostStatus } from '../actions/Post/changePostStatus';
import { submitFollow } from '../actions/Follow/submitFollow'; import { submitFollow } from '../actions/Follow/submitFollow';
import { requestFollow } from '../actions/Follow/requestFollow'; import { requestFollow } from '../actions/Follow/requestFollow';
import { requestPostStatusChanges } from '../actions/PostStatusChange/requestPostStatusChanges'; import { requestPostStatusChanges } from '../actions/PostStatusChange/requestPostStatusChanges';
@@ -14,9 +21,12 @@ import { State } from '../reducers/rootReducer';
import PostP from '../components/Post/PostP'; import PostP from '../components/Post/PostP';
import { fromJavascriptDateToRailsString } from '../helpers/datetime'; import { fromJavascriptDateToRailsString } from '../helpers/datetime';
import { updatePost } from '../actions/Post/updatePost';
const mapStateToProps = (state: State) => ({ const mapStateToProps = (state: State) => ({
post: state.currentPost.item, post: state.currentPost.item,
editMode: state.currentPost.editMode,
editForm: state.currentPost.editForm,
likes: state.currentPost.likes, likes: state.currentPost.likes,
followed: state.currentPost.followed, followed: state.currentPost.followed,
comments: state.currentPost.comments, comments: state.currentPost.comments,
@@ -28,6 +38,37 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(requestPost(postId)); dispatch(requestPost(postId));
}, },
updatePost(
postId: number,
title: string,
description: string,
boardId: number,
postStatusId: number,
authenticityToken: string,
) {
return dispatch(updatePost(postId, title, description, boardId, postStatusId, authenticityToken));
},
toggleEditMode() {
dispatch(togglePostEditMode());
},
handleChangeEditFormTitle(title: string) {
dispatch(changePostEditFormTitle(title));
},
handleChangeEditFormDescription(description: string) {
dispatch(changePostEditFormDescription(description));
},
handleChangeEditFormBoard(boardId: number) {
dispatch(changePostEditFormBoard(boardId));
},
handleChangeEditFormPostStatus(postStatusId: number) {
dispatch(changePostEditFormPostStatus(postStatusId));
},
requestLikes(postId: number) { requestLikes(postId: number) {
dispatch(requestLikes(postId)); dispatch(requestLikes(postId));
}, },
@@ -40,29 +81,21 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(requestPostStatusChanges(postId)); dispatch(requestPostStatusChanges(postId));
}, },
changePostBoard(postId: number, newBoardId: number, authenticityToken: string) { deletePost(postId: number, authenticityToken: string) {
dispatch(changePostBoard(postId, newBoardId, authenticityToken)); return dispatch(deletePost(postId, authenticityToken));
}, },
changePostStatus( postStatusChangeSubmitted(
postId: number,
newPostStatusId: number, newPostStatusId: number,
userFullName: string, userFullName: string,
userEmail: string, userEmail: string,
authenticityToken: string
) { ) {
if (isNaN(newPostStatusId)) newPostStatusId = null; dispatch(postStatusChangeSubmitted({
postStatusId: newPostStatusId,
dispatch(changePostStatus(postId, newPostStatusId, authenticityToken)).then(res => { userFullName,
if (res && res.status !== 204) return; userEmail,
createdAt: fromJavascriptDateToRailsString(new Date()),
dispatch(postStatusChangeSubmitted({ }));
postStatusId: newPostStatusId,
userFullName,
userEmail,
updatedAt: fromJavascriptDateToRailsString(new Date()),
}));
});
}, },
submitFollow(postId: number, isFollow: boolean, authenticityToken: string) { submitFollow(postId: number, isFollow: boolean, authenticityToken: string) {

View File

@@ -5,6 +5,7 @@ interface IComment {
isPostUpdate: boolean; isPostUpdate: boolean;
userFullName: string; userFullName: string;
userEmail: string; userEmail: string;
createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@@ -9,6 +9,8 @@ interface IPost {
commentsCount: number; commentsCount: number;
hotness: number; hotness: number;
userId: number; userId: number;
userEmail: string;
userFullName: string;
createdAt: string; createdAt: string;
} }

View File

@@ -2,7 +2,7 @@ interface IPostStatusChange {
postStatusId: number; postStatusId: number;
userFullName: string; userFullName: string;
userEmail: string; userEmail: string;
updatedAt: string; createdAt: string;
} }
export default IPostStatusChange; export default IPostStatusChange;

View File

@@ -5,6 +5,7 @@ interface ICommentJSON {
is_post_update: boolean; is_post_update: boolean;
user_full_name: string; user_full_name: string;
user_email: string; user_email: string;
created_at: string;
updated_at: string; updated_at: string;
} }

View File

@@ -9,6 +9,8 @@ interface IPostJSON {
comments_count: number; comments_count: number;
hotness: number; hotness: number;
user_id: number; user_id: number;
user_email: string;
user_full_name: string;
created_at: string; created_at: string;
} }

View File

@@ -2,7 +2,7 @@ interface IPostStatusChangeJSON {
post_status_id: number; post_status_id: number;
user_full_name: string; user_full_name: string;
user_email: string; user_email: string;
updated_at: string; created_at: string;
} }
export default IPostStatusChangeJSON; export default IPostStatusChangeJSON;

View File

@@ -3,6 +3,11 @@ import {
COMMENT_REQUEST_SUCCESS, COMMENT_REQUEST_SUCCESS,
} from '../actions/Comment/requestComment'; } from '../actions/Comment/requestComment';
import {
CommentUpdateActionTypes,
COMMENT_UPDATE_SUCCESS,
} from '../actions/Comment/updateComment';
import IComment from '../interfaces/IComment'; import IComment from '../interfaces/IComment';
const initialState: IComment = { const initialState: IComment = {
@@ -12,15 +17,17 @@ const initialState: IComment = {
isPostUpdate: false, isPostUpdate: false,
userFullName: '<Unknown user>', userFullName: '<Unknown user>',
userEmail: 'example@example.com', userEmail: 'example@example.com',
createdAt: undefined,
updatedAt: undefined, updatedAt: undefined,
}; };
const commentReducer = ( const commentReducer = (
state = initialState, state = initialState,
action: CommentRequestSuccessAction, action: CommentRequestSuccessAction | CommentUpdateActionTypes,
): IComment => { ): IComment => {
switch (action.type) { switch (action.type) {
case COMMENT_REQUEST_SUCCESS: case COMMENT_REQUEST_SUCCESS:
case COMMENT_UPDATE_SUCCESS:
return { return {
id: action.comment.id, id: action.comment.id,
body: action.comment.body, body: action.comment.body,
@@ -28,6 +35,7 @@ const commentReducer = (
isPostUpdate: action.comment.is_post_update, isPostUpdate: action.comment.is_post_update,
userFullName: action.comment.user_full_name, userFullName: action.comment.user_full_name,
userEmail: action.comment.user_email, userEmail: action.comment.user_email,
createdAt: action.comment.created_at,
updatedAt: action.comment.updated_at, updatedAt: action.comment.updated_at,
}; };

View File

@@ -22,8 +22,13 @@ import {
} from '../actions/Comment/submitComment'; } from '../actions/Comment/submitComment';
import { import {
ToggleIsUpdateSuccessAction, CommentDeleteActionTypes,
TOGGLE_COMMENT_IS_UPDATE_SUCCESS, COMMENT_DELETE_SUCCESS,
} from '../actions/Comment/deleteComment';
import {
CommentUpdateActionTypes,
COMMENT_UPDATE_SUCCESS,
} from '../actions/Comment/updateComment'; } from '../actions/Comment/updateComment';
import commentReducer from './commentReducer'; import commentReducer from './commentReducer';
@@ -54,7 +59,8 @@ const commentsReducer = (
CommentsRequestActionTypes | CommentsRequestActionTypes |
HandleCommentRepliesType | HandleCommentRepliesType |
CommentSubmitActionTypes | CommentSubmitActionTypes |
ToggleIsUpdateSuccessAction CommentUpdateActionTypes |
CommentDeleteActionTypes
): CommentsState => { ): CommentsState => {
switch (action.type) { switch (action.type) {
case COMMENTS_REQUEST_START: case COMMENTS_REQUEST_START:
@@ -103,17 +109,19 @@ const commentsReducer = (
replyForms: replyFormsReducer(state.replyForms, action), replyForms: replyFormsReducer(state.replyForms, action),
}; };
case TOGGLE_COMMENT_IS_UPDATE_SUCCESS: case COMMENT_DELETE_SUCCESS:
return {
...state,
items: state.items.filter(comment => comment.id !== action.commentId),
};
case COMMENT_UPDATE_SUCCESS:
return { return {
...state, ...state,
items: items: state.items.map(comment => (
state.items.map(comment => { comment.id === action.comment.id ? commentReducer(comment, action) : comment
if (comment.id === action.commentId) { )),
comment.isPostUpdate = !comment.isPostUpdate; };
return comment;
} else return comment;
})
}
default: default:
return state; return state;

View File

@@ -6,14 +6,21 @@ import {
} from '../actions/Post/requestPost'; } from '../actions/Post/requestPost';
import { import {
ChangePostBoardSuccessAction, PostUpdateActionTypes,
CHANGE_POST_BOARD_SUCCESS, POST_UPDATE_START,
} from '../actions/Post/changePostBoard'; POST_UPDATE_SUCCESS,
POST_UPDATE_FAILURE,
} from '../actions/Post/updatePost';
import { POST_TOGGLE_EDIT_MODE, PostToggleEditMode } from '../actions/Post/togglePostEditMode';
import { import {
ChangePostStatusSuccessAction, ChangePostEditFormActionTypes,
CHANGE_POST_STATUS_SUCCESS, POST_CHANGE_EDIT_FORM_TITLE,
} from '../actions/Post/changePostStatus'; POST_CHANGE_EDIT_FORM_DESCRIPTION,
POST_CHANGE_EDIT_FORM_BOARD,
POST_CHANGE_EDIT_FORM_POST_STATUS,
} from '../actions/Post/changePostEditForm';
import { import {
LikesRequestActionTypes, LikesRequestActionTypes,
@@ -49,8 +56,13 @@ import {
} from '../actions/Comment/submitComment'; } from '../actions/Comment/submitComment';
import { import {
ToggleIsUpdateSuccessAction, CommentDeleteActionTypes,
TOGGLE_COMMENT_IS_UPDATE_SUCCESS, COMMENT_DELETE_SUCCESS,
} from '../actions/Comment/deleteComment';
import {
CommentUpdateActionTypes,
COMMENT_UPDATE_SUCCESS,
} from '../actions/Comment/updateComment'; } from '../actions/Comment/updateComment';
import { FollowActionTypes, FOLLOW_SUBMIT_SUCCESS } from '../actions/Follow/submitFollow'; import { FollowActionTypes, FOLLOW_SUBMIT_SUCCESS } from '../actions/Follow/submitFollow';
@@ -78,11 +90,22 @@ import postStatusChangesReducer, { PostStatusChangesState } from './postStatusCh
import IPost from '../interfaces/IPost'; import IPost from '../interfaces/IPost';
export interface PostEditFormState {
title: string;
description?: string;
boardId: number;
postStatusId?: number;
isUpdating: boolean;
error: string;
}
interface CurrentPostState { interface CurrentPostState {
item: IPost; item: IPost;
isLoading: boolean; isLoading: boolean;
error: string; error: string;
editMode: boolean;
editForm: PostEditFormState;
likes: LikesState; likes: LikesState;
followed: boolean; followed: boolean;
comments: CommentsState; comments: CommentsState;
@@ -93,6 +116,16 @@ const initialState: CurrentPostState = {
item: postReducer(undefined, {} as PostRequestActionTypes), item: postReducer(undefined, {} as PostRequestActionTypes),
isLoading: false, isLoading: false,
error: '', error: '',
editMode: false,
editForm: {
title: '',
description: '',
boardId: 1,
postStatusId: 1,
isUpdating: false,
error: '',
},
likes: likesReducer(undefined, {} as LikesRequestActionTypes), likes: likesReducer(undefined, {} as LikesRequestActionTypes),
followed: false, followed: false,
comments: commentsReducer(undefined, {} as CommentsRequestActionTypes), comments: commentsReducer(undefined, {} as CommentsRequestActionTypes),
@@ -103,14 +136,16 @@ const currentPostReducer = (
state = initialState, state = initialState,
action: action:
PostRequestActionTypes | PostRequestActionTypes |
ChangePostBoardSuccessAction | PostUpdateActionTypes |
ChangePostStatusSuccessAction | PostToggleEditMode |
ChangePostEditFormActionTypes |
LikesRequestActionTypes | LikesRequestActionTypes |
LikeActionTypes | LikeActionTypes |
CommentsRequestActionTypes | CommentsRequestActionTypes |
HandleCommentRepliesType | HandleCommentRepliesType |
CommentSubmitActionTypes | CommentSubmitActionTypes |
ToggleIsUpdateSuccessAction | CommentUpdateActionTypes |
CommentDeleteActionTypes |
FollowActionTypes | FollowActionTypes |
FollowRequestActionTypes | FollowRequestActionTypes |
PostStatusChangesRequestActionTypes | PostStatusChangesRequestActionTypes |
@@ -138,11 +173,67 @@ const currentPostReducer = (
error: action.error, error: action.error,
}; };
case CHANGE_POST_BOARD_SUCCESS: case POST_UPDATE_START:
case CHANGE_POST_STATUS_SUCCESS: return {
...state, editForm: { ...state.editForm, isUpdating: true, error: '' }
};
case POST_UPDATE_SUCCESS:
return { return {
...state, ...state,
item: postReducer(state.item, action), item: postReducer(state.item, action),
editForm: {
...state.editForm,
isUpdating: false,
},
editMode: false,
};
case POST_UPDATE_FAILURE:
return {
...state, editForm: { ...state.editForm, isUpdating: false, error: action.error }
};
case POST_UPDATE_START:
return {
...state, editForm: { ...state.editForm, isUpdating: true }
};
case POST_TOGGLE_EDIT_MODE:
return {
...state,
editMode: !state.editMode,
editForm: {
...state.editForm,
title: state.item.title,
description: state.item.description,
boardId: state.item.boardId,
postStatusId: state.item.postStatusId,
},
};
case POST_CHANGE_EDIT_FORM_TITLE:
return {
...state,
editForm: { ...state.editForm, title: action.title },
};
case POST_CHANGE_EDIT_FORM_DESCRIPTION:
return {
...state,
editForm: { ...state.editForm, description: action.description },
};
case POST_CHANGE_EDIT_FORM_BOARD:
return {
...state,
editForm: { ...state.editForm, boardId: action.boardId },
};
case POST_CHANGE_EDIT_FORM_POST_STATUS:
return {
...state,
editForm: { ...state.editForm, postStatusId: action.postStatusId },
}; };
case LIKES_REQUEST_START: case LIKES_REQUEST_START:
@@ -162,7 +253,8 @@ const currentPostReducer = (
case COMMENT_SUBMIT_START: case COMMENT_SUBMIT_START:
case COMMENT_SUBMIT_SUCCESS: case COMMENT_SUBMIT_SUCCESS:
case COMMENT_SUBMIT_FAILURE: case COMMENT_SUBMIT_FAILURE:
case TOGGLE_COMMENT_IS_UPDATE_SUCCESS: case COMMENT_UPDATE_SUCCESS:
case COMMENT_DELETE_SUCCESS:
case TOGGLE_COMMENT_IS_POST_UPDATE_FLAG: case TOGGLE_COMMENT_IS_POST_UPDATE_FLAG:
return { return {
...state, ...state,

View File

@@ -4,14 +4,9 @@ import {
} from '../actions/Post/requestPost'; } from '../actions/Post/requestPost';
import { import {
ChangePostBoardSuccessAction, PostUpdateActionTypes,
CHANGE_POST_BOARD_SUCCESS, POST_UPDATE_SUCCESS,
} from '../actions/Post/changePostBoard'; } from '../actions/Post/updatePost';
import {
ChangePostStatusSuccessAction,
CHANGE_POST_STATUS_SUCCESS,
} from '../actions/Post/changePostStatus';
import IPost from '../interfaces/IPost'; import IPost from '../interfaces/IPost';
@@ -26,6 +21,8 @@ const initialState: IPost = {
commentsCount: 0, commentsCount: 0,
hotness: 0, hotness: 0,
userId: 0, userId: 0,
userEmail: '',
userFullName: '',
createdAt: '', createdAt: '',
}; };
@@ -33,8 +30,7 @@ const postReducer = (
state = initialState, state = initialState,
action: action:
PostRequestActionTypes | PostRequestActionTypes |
ChangePostBoardSuccessAction | PostUpdateActionTypes
ChangePostStatusSuccessAction,
): IPost => { ): IPost => {
switch (action.type) { switch (action.type) {
case POST_REQUEST_SUCCESS: case POST_REQUEST_SUCCESS:
@@ -49,19 +45,18 @@ const postReducer = (
commentsCount: action.post.comments_count, commentsCount: action.post.comments_count,
hotness: action.post.hotness, hotness: action.post.hotness,
userId: action.post.user_id, userId: action.post.user_id,
userEmail: action.post.user_email,
userFullName: action.post.user_full_name,
createdAt: action.post.created_at, createdAt: action.post.created_at,
}; };
case CHANGE_POST_BOARD_SUCCESS: case POST_UPDATE_SUCCESS:
return { return {
...state, ...state,
boardId: action.newBoardId, title: action.post.title,
}; description: action.post.description,
boardId: action.post.board_id,
case CHANGE_POST_STATUS_SUCCESS: postStatusId: action.post.post_status_id,
return {
...state,
postStatusId: action.newPostStatusId,
}; };
default: default:

View File

@@ -44,7 +44,7 @@ const postStatusChangesReducer = (
postStatusId: postStatusChange.post_status_id, postStatusId: postStatusChange.post_status_id,
userFullName: postStatusChange.user_full_name, userFullName: postStatusChange.user_full_name,
userEmail: postStatusChange.user_email, userEmail: postStatusChange.user_email,
updatedAt: postStatusChange.updated_at, createdAt: postStatusChange.created_at,
})), })),
areLoading: false, areLoading: false,
error: '', error: '',

View File

@@ -14,6 +14,11 @@ import {
POSTS_REQUEST_FAILURE, POSTS_REQUEST_FAILURE,
} from '../actions/Post/requestPosts'; } from '../actions/Post/requestPosts';
import {
PostDeleteActionTypes,
POST_DELETE_SUCCESS,
} from '../actions/Post/deletePost';
import { postRequestSuccess } from '../actions/Post/requestPost'; import { postRequestSuccess } from '../actions/Post/requestPost';
import { import {
@@ -49,6 +54,7 @@ const postsReducer = (
state = initialState, state = initialState,
action: action:
PostsRequestActionTypes | PostsRequestActionTypes |
PostDeleteActionTypes |
ChangeFiltersActionTypes | ChangeFiltersActionTypes |
LikeActionTypes, LikeActionTypes,
): PostsState => { ): PostsState => {
@@ -79,6 +85,12 @@ const postsReducer = (
error: action.error, error: action.error,
}; };
case POST_DELETE_SUCCESS:
return {
...state,
items: state.items.filter(post => post.id !== action.postId),
};
case SET_SEARCH_FILTER: case SET_SEARCH_FILTER:
case SET_POST_STATUS_FILTER: case SET_POST_STATUS_FILTER:
return { return {

View File

@@ -1,6 +1,30 @@
.commentsContainer { .commentsContainer {
@extend .my-3; @extend .my-3;
a.commentLink {
color: $primary-color;
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
.commentForm {
@extend
.form-control,
.w-100,
.p-2,
.mr-2;
height: 80px;
border: thin solid grey;
border-radius: 4px;
resize: none;
}
.newCommentForm { .newCommentForm {
@extend @extend
.d-flex, .d-flex,
@@ -27,21 +51,6 @@
.mr-2; .mr-2;
} }
.newCommentBody {
@extend
.form-control,
.w-100,
.p-2,
.mr-2;
height: 80px;
border: thin solid grey;
border-radius: 4px;
resize: none;
}
.submitCommentButton { .submitCommentButton {
@extend @extend
.align-self-end; .align-self-end;
@@ -52,6 +61,18 @@
} }
} }
.editCommentForm {
textarea {
@extend .my-2;
}
& > div {
@extend
.d-flex,
.justify-content-between;
}
}
.commentsTitle { .commentsTitle {
@extend @extend
.text-secondary, .text-secondary,
@@ -93,15 +114,6 @@
.commentFooter { .commentFooter {
font-size: 14px; font-size: 14px;
.commentLink {
color: $primary-color;
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
} }
} }
} }

View File

@@ -2,8 +2,7 @@
@extend @extend
.d-flex, .d-flex,
.flex-column, .flex-column,
.mr-3, .mr-3;
.mt-2;
$like_button_size: 11px; $like_button_size: 11px;

View File

@@ -104,7 +104,8 @@
.postHeader { .postHeader {
@extend @extend
.d-flex; .d-flex,
.mb-3;
a { a {
@extend @extend
@@ -128,9 +129,47 @@
.postDescription { .postDescription {
@extend @extend
.my-3; .my-4;
color: $primary-color; color: $primary-color;
p:last-child {
@extend .mb-0;
}
}
.postFooter {
.postAuthor {
@extend
.mutedText,
.mb-2;
.postAuthorAvatar { @extend .gravatar; }
}
a {
&:hover {
cursor: pointer;
text-decoration: underline;
}
}
}
.postEditForm {
.form-control {
@extend .my-3;
}
.postEditFormButtons {
text-align: right;
a {
&:hover {
cursor: pointer;
text-decoration: underline;
}
}
}
} }
} }
} }

View File

@@ -1,5 +1,25 @@
class CommentPolicy < ApplicationPolicy class CommentPolicy < ApplicationPolicy
def permitted_attributes_for_create
if user.power_user?
[:body, :parent_id, :is_post_update]
else
[:body, :parent_id]
end
end
def permitted_attributes_for_update
if user.power_user?
[:body, :is_post_update]
else
[:body]
end
end
def update? def update?
user == record.user or user.power_user? user == record.user or user.power_user?
end end
def destroy?
user == record.user or user.power_user?
end
end end

View File

@@ -1,5 +1,21 @@
class PostPolicy < ApplicationPolicy class PostPolicy < ApplicationPolicy
def permitted_attributes_for_create
[:title, :description, :board_id]
end
def permitted_attributes_for_update
if user.power_user?
[:title, :description, :board_id, :post_status_id]
else
[:title, :description]
end
end
def update? def update?
user == record.user or user.power_user? user == record.user or user.power_user?
end end
def destroy?
user == record.user or user.power_user?
end
end end

View File

@@ -7,8 +7,8 @@
postStatuses: @post_statuses, postStatuses: @post_statuses,
isLoggedIn: user_signed_in?, isLoggedIn: user_signed_in?,
isPowerUser: user_signed_in? ? current_user.power_user? : false, isPowerUser: user_signed_in? ? current_user.power_user? : false,
userFullName: user_signed_in? ? current_user.full_name : nil, currentUserFullName: user_signed_in? ? current_user.full_name : nil,
userEmail: user_signed_in? ? current_user.email : nil, currentUserEmail: user_signed_in? ? current_user.email : nil,
authenticityToken: form_authenticity_token, authenticityToken: form_authenticity_token,
} }
) )

View File

@@ -30,6 +30,8 @@ en:
other: '%{count} comments' other: '%{count} comments'
no_status: 'No status' no_status: 'No status'
loading: 'Loading...' loading: 'Loading...'
confirmation: 'Are you sure?'
edited: 'Edited'
buttons: buttons:
edit: 'Edit' edit: 'Edit'
delete: 'Delete' delete: 'Delete'
@@ -74,6 +76,7 @@ en:
empty: 'There are no posts' empty: 'There are no posts'
post: post:
edit_button: 'Edit' edit_button: 'Edit'
published_by: 'Published by'
post_status_select: post_status_select:
no_post_status: 'None' no_post_status: 'None'
updates_box: updates_box:
@@ -124,7 +127,7 @@ en:
title: 'Roadmap' title: 'Roadmap'
title2: 'Not in roadmap' title2: 'Not in roadmap'
empty: 'The roadmap is empty.' empty: 'The roadmap is empty.'
help: 'You can add new statuses to the roadmap by dragging them from the section below. If you want to add a new status or change their order, go to Site settings -> Statuses.' help: 'You can add statuses to the roadmap by dragging them from the section below. If you instead want to create a new status or change their order, go to Site settings -> Statuses.'
user_mailer: user_mailer:
opening_greeting: 'Hello!' opening_greeting: 'Hello!'
closing_greeting: 'Have a great day!' closing_greeting: 'Have a great day!'

View File

@@ -30,6 +30,8 @@ it:
other: '%{count} commenti' other: '%{count} commenti'
no_status: 'Nessuno stato' no_status: 'Nessuno stato'
loading: 'Caricamento...' loading: 'Caricamento...'
confirmation: 'Sei sicuro?'
edited: 'Modificato'
buttons: buttons:
edit: 'Modifica' edit: 'Modifica'
delete: 'Elimina' delete: 'Elimina'
@@ -74,6 +76,7 @@ it:
empty: 'Non ci sono post' empty: 'Non ci sono post'
post: post:
edit_button: 'Modifica' edit_button: 'Modifica'
published_by: 'Pubblicato da'
post_status_select: post_status_select:
no_post_status: 'Nessuno' no_post_status: 'Nessuno'
updates_box: updates_box:
@@ -124,7 +127,7 @@ it:
title: 'Roadmap' title: 'Roadmap'
title2: 'Non mostrati in roadmap' title2: 'Non mostrati in roadmap'
empty: 'La roadmap è vuota.' empty: 'La roadmap è vuota.'
help: "Puoi aggiungere nuovi stati alla roadmap trascinandoli dalla sezione sottostante. Se vuoi aggiungere un nuovo stato o cambiarne l'ordine, vai in Impostazioni sito -> Stati." help: "Puoi aggiungere stati alla roadmap trascinandoli dalla sezione sottostante. Se invece vuoi creare un nuovo stato o cambiarne l'ordine, vai in Impostazioni sito -> Stati."
user_mailer: user_mailer:
opening_greeting: 'Ciao!' opening_greeting: 'Ciao!'
closing_greeting: 'Buona giornata!' closing_greeting: 'Buona giornata!'

View File

@@ -14,12 +14,12 @@ Rails.application.routes.draw do
devise_for :users devise_for :users
resources :posts, only: [:index, :create, :show, :update] do resources :posts, only: [:index, :create, :show, :update, :destroy] do
resource :follows, only: [:create, :destroy] resource :follows, only: [:create, :destroy]
resources :follows, only: [:index] resources :follows, only: [:index]
resource :likes, only: [:create, :destroy] resource :likes, only: [:create, :destroy]
resources :likes, only: [:index] resources :likes, only: [:index]
resources :comments, only: [:index, :create, :update] resources :comments, only: [:index, :create, :update, :destroy]
resources :post_status_changes, only: [:index] resources :post_status_changes, only: [:index]
end end

0
script/rspec-no-system-specs.sh Normal file → Executable file
View File

View File

@@ -11,10 +11,12 @@ RSpec.describe 'comments routing', :aggregate_failures, type: :routing do
expect(patch: '/posts/1/comments/1').to route_to( expect(patch: '/posts/1/comments/1').to route_to(
controller: 'comments', action: 'update', post_id: "1", id: "1" controller: 'comments', action: 'update', post_id: "1", id: "1"
) )
expect(delete: '/posts/1/comments/1').to route_to(
controller: 'comments', action: 'destroy', post_id: "1", id: "1"
)
expect(get: '/posts/1/comments/1').not_to be_routable expect(get: '/posts/1/comments/1').not_to be_routable
expect(get: '/posts/1/comments/new').not_to be_routable expect(get: '/posts/1/comments/new').not_to be_routable
expect(get: '/posts/1/comments/1/edit').not_to be_routable expect(get: '/posts/1/comments/1/edit').not_to be_routable
expect(delete: '/posts/1/comments/1').not_to be_routable
end end
end end

View File

@@ -14,11 +14,13 @@ RSpec.describe 'posts routing', :aggregate_failures, type: :routing do
expect(patch: '/posts/1').to route_to( expect(patch: '/posts/1').to route_to(
controller: 'posts', action: 'update', id: '1' controller: 'posts', action: 'update', id: '1'
) )
expect(get: '/posts/new').not_to route_to( expect(get: '/posts/new').not_to route_to(
controller: 'posts', action: 'new' controller: 'posts', action: 'new'
) )
expect(delete: '/posts/1').to route_to(
controller: 'posts', action: 'destroy', id: '1'
)
expect(get: '/posts/1/edit').not_to be_routable expect(get: '/posts/1/edit').not_to be_routable
expect(delete: '/posts/1').not_to be_routable
end end
end end