Post follow and updates notifications V1 (#111)

* It is now possible to follow a post in order to receive updates about it
* Notifications are now sent when updates are published
* Post status changes are now tracked
* Update sidebar now shows the post status history
* Mark a comment as a post update using the comment form
* ... more ...
This commit is contained in:
Riccardo Graziosi
2022-05-28 11:03:36 +02:00
committed by GitHub
parent ce7be1b30c
commit dad382d2b1
59 changed files with 1080 additions and 71 deletions

View File

@@ -11,6 +11,12 @@ interface SetCommentReplyBodyAction {
body: string;
}
export const TOGGLE_COMMENT_IS_POST_UPDATE_FLAG = 'TOGGLE_COMMENT_IS_POST_UPDATE_FLAG';
interface ToggleCommentIsPostUpdateFlag {
type: typeof TOGGLE_COMMENT_IS_POST_UPDATE_FLAG;
commentId: number;
}
export const toggleCommentReply = (commentId: number): ToggleCommentReplyAction => ({
type: TOGGLE_COMMENT_REPLY,
commentId,
@@ -22,6 +28,12 @@ export const setCommentReplyBody = (commentId: number, body: string): SetComment
body,
});
export const toggleCommentIsPostUpdateFlag = (commentId: number): ToggleCommentIsPostUpdateFlag => ({
type: TOGGLE_COMMENT_IS_POST_UPDATE_FLAG,
commentId,
});
export type HandleCommentRepliesType =
ToggleCommentReplyAction |
SetCommentReplyBodyAction;
SetCommentReplyBodyAction |
ToggleCommentIsPostUpdateFlag;

View File

@@ -52,6 +52,7 @@ export const submitComment = (
postId: number,
body: string,
parentId: number,
isPostUpdate: boolean,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(commentSubmitStart(parentId));
@@ -64,6 +65,7 @@ export const submitComment = (
comment: {
body,
parent_id: parentId,
is_post_update: isPostUpdate,
},
}),
});

View File

@@ -0,0 +1,58 @@
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import IFollowJSON from '../../interfaces/json/IFollow';
import { State } from '../../reducers/rootReducer';
export const FOLLOW_REQUEST_START = 'FOLLOW_REQUEST_START';
interface FollowRequestStartAction {
type: typeof FOLLOW_REQUEST_START;
}
export const FOLLOW_REQUEST_SUCCESS = 'FOLLOW_REQUEST_SUCCESS';
interface FollowRequestSuccessAction {
type: typeof FOLLOW_REQUEST_SUCCESS;
follow: IFollowJSON;
}
export const FOLLOW_REQUEST_FAILURE = 'FOLLOW_REQUEST_FAILURE';
interface FollowRequestFailureAction {
type: typeof FOLLOW_REQUEST_FAILURE;
error: string;
}
export type FollowRequestActionTypes =
FollowRequestStartAction |
FollowRequestSuccessAction |
FollowRequestFailureAction;
const followRequestStart = (): FollowRequestActionTypes => ({
type: FOLLOW_REQUEST_START,
});
const followRequestSuccess = (
follow: IFollowJSON,
): FollowRequestActionTypes => ({
type: FOLLOW_REQUEST_SUCCESS,
follow,
});
const followRequestFailure = (error: string): FollowRequestActionTypes => ({
type: FOLLOW_REQUEST_FAILURE,
error,
});
export const requestFollow = (
postId: number,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(followRequestStart());
try {
const response = await fetch(`/posts/${postId}/follows`);
const json = await response.json();
dispatch(followRequestSuccess(json));
} catch (e) {
dispatch(followRequestFailure(e));
}
};

View File

@@ -0,0 +1,47 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import { State } from "../../reducers/rootReducer";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import HttpStatus from "../../constants/http_status";
import IFollowJSON from "../../interfaces/json/IFollow";
export const FOLLOW_SUBMIT_SUCCESS = 'FOLLOW_SUBMIT_SUCCESS';
interface FollowSubmitSuccessAction {
type: typeof FOLLOW_SUBMIT_SUCCESS,
postId: number;
isFollow: boolean;
follow: IFollowJSON;
}
export type FollowActionTypes = FollowSubmitSuccessAction;
const followSubmitSuccess = (
postId: number,
isFollow: boolean,
follow: IFollowJSON,
): FollowSubmitSuccessAction => ({
type: FOLLOW_SUBMIT_SUCCESS,
postId,
isFollow,
follow,
});
export const submitFollow = (
postId: number,
isFollow: boolean,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
try {
const res = await fetch(`/posts/${postId}/follows`, {
method: isFollow ? 'POST' : 'DELETE',
headers: buildRequestHeaders(authenticityToken),
});
const json = await res.json();
if (res.status === HttpStatus.Created || res.status === HttpStatus.Accepted)
dispatch(followSubmitSuccess(postId, isFollow, json));
} catch (e) {
console.log('An error occurred while following a post');
}
}

View File

@@ -0,0 +1,58 @@
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import IPostStatusChangeJSON from '../../interfaces/json/IPostStatusChange';
import { State } from '../../reducers/rootReducer';
export const POST_STATUS_CHANGES_REQUEST_START = 'POST_STATUS_CHANGES_REQUEST_START';
interface PostStatusChangesRequestStartAction {
type: typeof POST_STATUS_CHANGES_REQUEST_START;
}
export const POST_STATUS_CHANGES_REQUEST_SUCCESS = 'POST_STATUS_CHANGES_REQUEST_SUCCESS';
interface PostStatusChangesRequestSuccessAction {
type: typeof POST_STATUS_CHANGES_REQUEST_SUCCESS;
postStatusChanges: Array<IPostStatusChangeJSON>;
}
export const POST_STATUS_CHANGES_REQUEST_FAILURE = 'POST_STATUS_CHANGES_REQUEST_FAILURE';
interface PostStatusChangesRequestFailureAction {
type: typeof POST_STATUS_CHANGES_REQUEST_FAILURE;
error: string;
}
export type PostStatusChangesRequestActionTypes =
PostStatusChangesRequestStartAction |
PostStatusChangesRequestSuccessAction |
PostStatusChangesRequestFailureAction;
const postStatusChangesRequestStart = (): PostStatusChangesRequestActionTypes => ({
type: POST_STATUS_CHANGES_REQUEST_START,
});
const postStatusChangesRequestSuccess = (
postStatusChanges: Array<IPostStatusChangeJSON>,
): PostStatusChangesRequestActionTypes => ({
type: POST_STATUS_CHANGES_REQUEST_SUCCESS,
postStatusChanges,
});
const postStatusChangesRequestFailure = (error: string): PostStatusChangesRequestActionTypes => ({
type: POST_STATUS_CHANGES_REQUEST_FAILURE,
error,
});
export const requestPostStatusChanges = (
postId: number,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(postStatusChangesRequestStart());
try {
const response = await fetch(`/posts/${postId}/post_status_changes`);
const json = await response.json();
dispatch(postStatusChangesRequestSuccess(json));
} catch (e) {
dispatch(postStatusChangesRequestFailure(e));
}
};

View File

@@ -0,0 +1,14 @@
import IPostStatusChange from "../../interfaces/IPostStatusChange";
export const POST_STATUS_CHANGE_SUBMITTED = 'POST_STATUS_CHANGE_SUBMITTED';
export interface PostStatusChangeSubmitted {
type: typeof POST_STATUS_CHANGE_SUBMITTED;
postStatusChange: IPostStatusChange;
}
export const postStatusChangeSubmitted = (
postStatusChange: IPostStatusChange
): PostStatusChangeSubmitted => ({
type: POST_STATUS_CHANGE_SUBMITTED,
postStatusChange,
});

View File

@@ -7,7 +7,7 @@ import { MutedText } from '../shared/CustomTexts';
import { ReplyFormState } from '../../reducers/replyFormReducer';
import friendlyDate from '../../helpers/friendlyDate';
import friendlyDate from '../../helpers/datetime';
interface Props {
id: number;
@@ -21,7 +21,7 @@ interface Props {
handleToggleCommentReply(): void;
handleCommentReplyBodyChange(e: React.FormEvent): void;
handleToggleIsCommentUpdate(commentId: number, currentIsPostUpdate: boolean): void;
handleSubmitComment(body: string, parentId: number): void;
handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean): void;
isLoggedIn: boolean;
isPowerUser: boolean;
@@ -48,7 +48,7 @@ const Comment = ({
}: Props) => (
<div className="comment">
<div className="commentHeader">
<Gravatar email={userEmail} size={24} className="gravatar" />
<Gravatar email={userEmail} size={28} className="gravatar" />
<span className="commentAuthor">{userFullName}</span>
{ isPostUpdate ? <span className="postUpdateBadge">Post update</span> : null }
</div>
@@ -89,12 +89,15 @@ const Comment = ({
<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}
/>
:

View File

@@ -14,7 +14,7 @@ interface Props {
toggleCommentReply(commentId: number): void;
setCommentReplyBody(commentId: number, body: string): void;
handleToggleIsCommentUpdate(commentId: number, currentIsPostUpdate: boolean): void;
handleSubmitComment(body: string, parentId: number): void;
handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean): void;
isLoggedIn: boolean;
isPowerUser: boolean;

View File

@@ -23,6 +23,7 @@ interface Props {
requestComments(postId: number, page?: number): void;
toggleCommentReply(commentId: number): void;
setCommentReplyBody(commentId: number, body: string): void;
toggleCommentIsPostUpdateFlag(): void;
toggleCommentIsPostUpdate(
postId: number,
commentId: number,
@@ -33,6 +34,7 @@ interface Props {
postId: number,
body: string,
parentId: number,
isPostUpdate: boolean,
authenticityToken: string,
): void;
}
@@ -51,11 +53,12 @@ class CommentsP extends React.Component<Props> {
);
}
_handleSubmitComment = (body: string, parentId: number) => {
_handleSubmitComment = (body: string, parentId: number, isPostUpdate: boolean) => {
this.props.submitComment(
this.props.postId,
body,
parentId,
isPostUpdate,
this.props.authenticityToken,
);
}
@@ -73,6 +76,7 @@ class CommentsP extends React.Component<Props> {
toggleCommentReply,
setCommentReplyBody,
toggleCommentIsPostUpdateFlag,
} = this.props;
const postReply = replyForms.find(replyForm => replyForm.commentId === null);
@@ -82,6 +86,7 @@ class CommentsP extends React.Component<Props> {
<NewComment
body={postReply && postReply.body}
parentId={null}
postUpdateFlagValue={postReply && postReply.isPostUpdate}
isSubmitting={postReply && postReply.isSubmitting}
error={postReply && postReply.error}
handleChange={
@@ -89,9 +94,11 @@ class CommentsP extends React.Component<Props> {
setCommentReplyBody(null, (e.target as HTMLTextAreaElement).value)
)
}
handlePostUpdateFlag={toggleCommentIsPostUpdateFlag}
handleSubmit={this._handleSubmitComment}
isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser}
userEmail={userEmail}
/>
@@ -99,7 +106,7 @@ class CommentsP extends React.Component<Props> {
{ error ? <DangerText>{error}</DangerText> : null }
<div className="commentsTitle">
activity &bull; {comments.length} comments
activity &bull; {comments.length} comment{comments.length === 1 ? '' : 's'}
</div>
<CommentList

View File

@@ -1,6 +1,8 @@
import * as React from 'react';
import Gravatar from 'react-gravatar';
import NewCommentUpdateSection from './NewCommentUpdateSection';
import Button from '../shared/Button';
import Spinner from '../shared/Spinner';
import { DangerText } from '../shared/CustomTexts';
@@ -8,24 +10,34 @@ import { DangerText } from '../shared/CustomTexts';
interface Props {
body: string;
parentId: number;
postUpdateFlagValue: boolean;
isSubmitting: boolean;
error: string;
handleChange(e: React.FormEvent): void;
handleSubmit(body: string, parentId: number): void;
handlePostUpdateFlag(): void;
handleSubmit(
body: string,
parentId: number,
isPostUpdate: boolean
): void;
isLoggedIn: boolean;
isPowerUser: boolean;
userEmail: string;
}
const NewComment = ({
body,
parentId,
postUpdateFlagValue,
isSubmitting,
error,
handleChange,
handlePostUpdateFlag,
handleSubmit,
isLoggedIn,
isPowerUser,
userEmail,
}: Props) => (
<React.Fragment>
@@ -33,18 +45,29 @@ const NewComment = ({
{
isLoggedIn ?
<React.Fragment>
<Gravatar email={userEmail} size={36} className="currentUserAvatar" />
<textarea
value={body}
onChange={handleChange}
placeholder="Leave a comment"
className="newCommentBody"
/>
<Button
onClick={() => handleSubmit(body, parentId)}
className="submitCommentButton">
{ isSubmitting ? <Spinner color="light" /> : 'Submit' }
</Button>
<div className="commentBodyForm">
<Gravatar email={userEmail} size={48} className="currentUserAvatar" />
<textarea
value={body}
onChange={handleChange}
placeholder="Leave a comment"
className="newCommentBody"
/>
<Button
onClick={() => handleSubmit(body, parentId, postUpdateFlagValue)}
className="submitCommentButton">
{ isSubmitting ? <Spinner color="light" /> : 'Submit' }
</Button>
</div>
{
isPowerUser && parentId == null ?
<NewCommentUpdateSection
postUpdateFlagValue={postUpdateFlagValue}
handlePostUpdateFlag={handlePostUpdateFlag}
/>
:
null
}
</React.Fragment>
:
<a href="/users/sign_in" className="loginInfo">You need to log in to post comments.</a>

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import { MutedText } from '../shared/CustomTexts';
interface Props {
postUpdateFlagValue: boolean;
handlePostUpdateFlag(): void;
}
const NewCommentUpdateSection = ({
postUpdateFlagValue,
handlePostUpdateFlag,
}: Props) => (
<div className="commentIsUpdateForm">
<div>
<input
id="isPostUpdateFlag"
type="checkbox"
onChange={handlePostUpdateFlag}
checked={postUpdateFlagValue || false}
/>
&nbsp;
<label htmlFor="isPostUpdateFlag">Mark as post update</label>
</div>
{
postUpdateFlagValue ?
<MutedText>Users that follow this post will be notified</MutedText>
:
null
}
</div>
);
export default NewCommentUpdateSection;

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import Button from '../shared/Button';
import { BoxTitleText, SmallMutedText } from '../shared/CustomTexts';
interface Props {
followed: boolean;
submitFollow(): void;
isLoggedIn: boolean;
}
const ActionBox = ({followed, submitFollow, isLoggedIn}: Props) => (
<div className="actionBoxContainer">
<div className="actionBoxFollow">
<BoxTitleText>Actions</BoxTitleText>
<br />
<Button onClick={isLoggedIn ? submitFollow : () => location.href = '/users/sign_in'} outline>
{ followed ? 'Unfollow post' : 'Follow post' }
</Button>
<br />
<SmallMutedText>
{ followed ?
'you\'re receiving notifications about new updates on this post'
:
'you won\'t receive notifications about this post'
}
</SmallMutedText>
</div>
</div>
);
export default ActionBox;

View File

@@ -4,7 +4,9 @@ import IPost from '../../interfaces/IPost';
import IPostStatus from '../../interfaces/IPostStatus';
import IBoard from '../../interfaces/IBoard';
import PostUpdateList from './PostUpdateList';
import LikeList from './LikeList';
import ActionBox from './ActionBox';
import LikeButton from '../../containers/LikeButton';
import PostBoardSelect from './PostBoardSelect';
import PostStatusSelect from './PostStatusSelect';
@@ -13,25 +15,31 @@ import PostStatusLabel from '../shared/PostStatusLabel';
import Comments from '../../containers/Comments';
import { MutedText } from '../shared/CustomTexts';
import friendlyDate from '../../helpers/friendlyDate';
import { LikesState } from '../../reducers/likesReducer';
import { CommentsState } from '../../reducers/commentsReducer';
import PostUpdateList from './PostUpdateList';
import { PostStatusChangesState } from '../../reducers/postStatusChangesReducer';
import friendlyDate, { fromRailsStringToJavascriptDate } from '../../helpers/datetime';
interface Props {
postId: number;
post: IPost;
likes: LikesState;
followed: boolean;
comments: CommentsState;
postStatusChanges: PostStatusChangesState;
boards: Array<IBoard>;
postStatuses: Array<IPostStatus>;
isLoggedIn: boolean;
isPowerUser: boolean;
userFullName: string;
userEmail: string;
authenticityToken: string;
requestPost(postId: number): void;
requestLikes(postId: number): void;
requestFollow(postId: number): void;
requestPostStatusChanges(postId: number): void;
changePostBoard(
postId: number,
newBoardId: number,
@@ -40,38 +48,62 @@ interface Props {
changePostStatus(
postId: number,
newPostStatusId: number,
userFullName: string,
userEmail: string,
authenticityToken: string,
): void;
submitFollow(
postId: number,
isFollow: boolean,
authenticityToken: string,
): void;
}
class PostP extends React.Component<Props> {
componentDidMount() {
this.props.requestPost(this.props.postId);
this.props.requestLikes(this.props.postId);
const {postId} = this.props;
this.props.requestPost(postId);
this.props.requestLikes(postId);
this.props.requestFollow(postId);
this.props.requestPostStatusChanges(postId);
}
render() {
const {
post,
likes,
followed,
comments,
postStatusChanges,
boards,
postStatuses,
isLoggedIn,
isPowerUser,
userFullName,
userEmail,
authenticityToken,
changePostBoard,
changePostStatus,
submitFollow,
} = this.props;
const postUpdates = [
...comments.items.filter(comment => comment.isPostUpdate === true),
...postStatusChanges.items,
].sort(
(a, b) =>
fromRailsStringToJavascriptDate(a.updatedAt) < fromRailsStringToJavascriptDate(b.updatedAt) ? 1 : -1
);
return (
<div className="pageContainer">
<div className="sidebar">
<PostUpdateList
postUpdates={comments.items.filter(comment => comment.isPostUpdate === true)}
postUpdates={postUpdates}
postStatuses={postStatuses}
areLoading={comments.areLoading}
error={comments.error}
/>
@@ -81,6 +113,13 @@ class PostP extends React.Component<Props> {
areLoading={likes.areLoading}
error={likes.error}
/>
<ActionBox
followed={followed}
submitFollow={() => submitFollow(post.id, !followed, authenticityToken)}
isLoggedIn={isLoggedIn}
/>
</div>
<div className="postAndCommentsContainer">
@@ -113,7 +152,8 @@ class PostP extends React.Component<Props> {
postStatuses={postStatuses}
selectedPostStatusId={post.postStatusId}
handleChange={
newPostStatusId => changePostStatus(post.id, newPostStatusId, authenticityToken)
newPostStatusId =>
changePostStatus(post.id, newPostStatusId, userFullName, userEmail, authenticityToken)
}
/>
</div>

View File

@@ -5,17 +5,22 @@ import { BoxTitleText, DangerText, CenteredMutedText, MutedText } from '../share
import Spinner from '../shared/Spinner';
import IComment from '../../interfaces/IComment';
import IPostStatusChange from '../../interfaces/IPostStatusChange';
import IPostStatus from '../../interfaces/IPostStatus';
import friendlyDate from '../../helpers/friendlyDate';
import friendlyDate from '../../helpers/datetime';
import PostStatusLabel from '../shared/PostStatusLabel';
interface Props {
postUpdates: Array<IComment>;
postUpdates: Array<IComment | IPostStatusChange>;
postStatuses: Array<IPostStatus>
areLoading: boolean;
error: string;
}
const PostUpdateList = ({
postUpdates,
postStatuses,
areLoading,
error,
}: Props) => (
@@ -33,7 +38,18 @@ const PostUpdateList = ({
<span>{postUpdate.userFullName}</span>
</div>
<p className="postUpdateListItemBody">{postUpdate.body}</p>
<p className="postUpdateListItemBody">
{ 'body' in postUpdate ?
postUpdate.body
:
<React.Fragment>
<i>changed status to</i>&nbsp;
<PostStatusLabel
{...postStatuses.find(postStatus => postStatus.id === postUpdate.postStatusId)}
/>
</React.Fragment>
}
</p>
<MutedText>{friendlyDate(postUpdate.updatedAt)}</MutedText>
</div>

View File

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

View File

@@ -25,6 +25,10 @@ export const CenteredMutedText = ({ children }: Props) => (
<p className="centeredText"><span className="mutedText">{children}</span></p>
);
export const SmallMutedText = ({ children }: Props) => (
<p className="smallMutedText">{children}</p>
);
export const UppercaseText = ({ children }: Props) => (
<span className="uppercaseText">{children}</span>
);

View File

@@ -9,8 +9,8 @@ const PostStatusLabel = ({
name,
color,
}: Props) => (
<span className="badge" style={{backgroundColor: color, color: 'white'}}>
{name?.toUpperCase()}
<span className="badge" style={{backgroundColor: color || 'black', color: 'white'}}>
{(name || 'no status').toUpperCase()}
</span>
);

View File

@@ -4,6 +4,7 @@ import { requestComments } from '../actions/Comment/requestComments';
import {
toggleCommentReply,
setCommentReplyBody,
toggleCommentIsPostUpdateFlag,
} from '../actions/Comment/handleCommentReplies';
import { toggleCommentIsUpdate } from '../actions/Comment/updateComment';
import { submitComment } from '../actions/Comment/submitComment';
@@ -32,6 +33,10 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(setCommentReplyBody(commentId, body));
},
toggleCommentIsPostUpdateFlag() {
dispatch(toggleCommentIsPostUpdateFlag(null));
},
toggleCommentIsPostUpdate(
postId: number,
commentId: number,
@@ -45,9 +50,10 @@ const mapDispatchToProps = (dispatch) => ({
postId: number,
body: string,
parentId: number,
isPostUpdate: boolean,
authenticityToken: string,
) {
dispatch(submitComment(postId, body, parentId, authenticityToken));
dispatch(submitComment(postId, body, parentId, isPostUpdate, authenticityToken));
},
});

View File

@@ -4,15 +4,23 @@ import { requestPost } from '../actions/Post/requestPost';
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 { requestFollow } from '../actions/Follow/requestFollow';
import { requestPostStatusChanges } from '../actions/PostStatusChange/requestPostStatusChanges';
import { postStatusChangeSubmitted } from '../actions/PostStatusChange/submittedPostStatusChange';
import { State } from '../reducers/rootReducer';
import PostP from '../components/Post/PostP';
import { fromJavascriptDateToRailsString } from '../helpers/datetime';
const mapStateToProps = (state: State) => ({
post: state.currentPost.item,
likes: state.currentPost.likes,
followed: state.currentPost.followed,
comments: state.currentPost.comments,
postStatusChanges: state.currentPost.postStatusChanges,
});
const mapDispatchToProps = (dispatch) => ({
@@ -24,14 +32,41 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(requestLikes(postId));
},
requestFollow(postId: number) {
dispatch(requestFollow(postId));
},
requestPostStatusChanges(postId: number) {
dispatch(requestPostStatusChanges(postId));
},
changePostBoard(postId: number, newBoardId: number, authenticityToken: string) {
dispatch(changePostBoard(postId, newBoardId, authenticityToken));
},
changePostStatus(postId: number, newPostStatusId: number, authenticityToken: string) {
changePostStatus(
postId: number,
newPostStatusId: number,
userFullName: string,
userEmail: string,
authenticityToken: string
) {
if (isNaN(newPostStatusId)) newPostStatusId = null;
dispatch(changePostStatus(postId, newPostStatusId, authenticityToken));
dispatch(changePostStatus(postId, newPostStatusId, authenticityToken)).then(res => {
if (res && res.status !== 204) return;
dispatch(postStatusChangeSubmitted({
postStatusId: newPostStatusId,
userFullName,
userEmail,
updatedAt: fromJavascriptDateToRailsString(new Date()),
}));
});
},
submitFollow(postId: number, isFollow: boolean, authenticityToken: string) {
dispatch(submitFollow(postId, isFollow, authenticityToken));
},
});

View File

@@ -1,4 +1,4 @@
const friendlyDate = date => {
export const friendlyDate = date => {
var now = new Date();
var timeStamp = fromRailsStringToJavascriptDate(date);
@@ -7,13 +7,13 @@ const friendlyDate = date => {
if (secondsPast < 60) {
return 'just now';
} else if (secondsPast < 3600) {
let minutesPast = parseInt(secondsPast / 60);
let minutesPast = Math.round(secondsPast / 60);
return minutesPast + ' ' + (minutesPast === 1 ? 'minute' : 'minutes') + ' ago';
} else if (secondsPast <= 86400) {
let hoursPast = parseInt(secondsPast / 3600);
let hoursPast = Math.round(secondsPast / 3600);
return hoursPast + ' ' + (hoursPast === 1 ? 'hour' : 'hours') + ' ago';
} else {
let daysPast = parseInt(secondsPast / 86400);
let daysPast = Math.round(secondsPast / 86400);
return daysPast + ' ' + (daysPast === 1 ? 'day' : 'days') + ' ago';
}
}
@@ -24,9 +24,13 @@ export default friendlyDate;
Converts the default Rails datetime string
format to a JavaScript Date object.
*/
const fromRailsStringToJavascriptDate = date => {
export const fromRailsStringToJavascriptDate = date => {
let dateOnly = date.slice(0, 10);
let timeOnly = date.slice(11, 19);
return new Date(`${dateOnly}T${timeOnly}Z`);
}
export const fromJavascriptDateToRailsString = (date: Date) => {
return date.toJSON();
}

View File

@@ -0,0 +1,8 @@
interface IPostStatusChange {
postStatusId: number;
userFullName: string;
userEmail: string;
updatedAt: string;
}
export default IPostStatusChange;

View File

@@ -0,0 +1,7 @@
interface IFollowJSON {
id: number;
user_id: number;
post_id: number;
}
export default IFollowJSON;

View File

@@ -0,0 +1,8 @@
interface IPostStatusChangeJSON {
post_status_id: number;
user_full_name: string;
user_email: string;
updated_at: string;
}
export default IPostStatusChangeJSON;

View File

@@ -11,6 +11,7 @@ import {
HandleCommentRepliesType,
TOGGLE_COMMENT_REPLY,
SET_COMMENT_REPLY_BODY,
TOGGLE_COMMENT_IS_POST_UPDATE_FLAG,
} from '../actions/Comment/handleCommentReplies';
import {
@@ -82,6 +83,7 @@ const commentsReducer = (
case TOGGLE_COMMENT_REPLY:
case SET_COMMENT_REPLY_BODY:
case TOGGLE_COMMENT_IS_POST_UPDATE_FLAG:
return {
...state,
replyForms: replyFormsReducer(state.replyForms, action),

View File

@@ -38,6 +38,7 @@ import {
HandleCommentRepliesType,
TOGGLE_COMMENT_REPLY,
SET_COMMENT_REPLY_BODY,
TOGGLE_COMMENT_IS_POST_UPDATE_FLAG,
} from '../actions/Comment/handleCommentReplies';
import {
@@ -52,21 +53,40 @@ import {
TOGGLE_COMMENT_IS_UPDATE_SUCCESS,
} from '../actions/Comment/updateComment';
import { FollowActionTypes, FOLLOW_SUBMIT_SUCCESS } from '../actions/Follow/submitFollow';
import { FollowRequestActionTypes, FOLLOW_REQUEST_SUCCESS } from '../actions/Follow/requestFollow';
import {
PostStatusChangesRequestActionTypes,
POST_STATUS_CHANGES_REQUEST_START,
POST_STATUS_CHANGES_REQUEST_SUCCESS,
POST_STATUS_CHANGES_REQUEST_FAILURE,
} from '../actions/PostStatusChange/requestPostStatusChanges';
import {
PostStatusChangeSubmitted,
POST_STATUS_CHANGE_SUBMITTED
} from '../actions/PostStatusChange/submittedPostStatusChange';
import postReducer from './postReducer';
import likesReducer from './likesReducer';
import commentsReducer from './commentsReducer';
import { LikesState } from './likesReducer';
import { CommentsState } from './commentsReducer';
import postStatusChangesReducer, { PostStatusChangesState } from './postStatusChangesReducer';
import IPost from '../interfaces/IPost';
interface CurrentPostState {
item: IPost;
isLoading: boolean;
error: string;
likes: LikesState;
followed: boolean;
comments: CommentsState;
postStatusChanges: PostStatusChangesState,
}
const initialState: CurrentPostState = {
@@ -74,7 +94,9 @@ const initialState: CurrentPostState = {
isLoading: false,
error: '',
likes: likesReducer(undefined, {} as LikesRequestActionTypes),
followed: false,
comments: commentsReducer(undefined, {} as CommentsRequestActionTypes),
postStatusChanges: postStatusChangesReducer(undefined, {} as PostStatusChangesRequestActionTypes),
};
const currentPostReducer = (
@@ -88,7 +110,11 @@ const currentPostReducer = (
CommentsRequestActionTypes |
HandleCommentRepliesType |
CommentSubmitActionTypes |
ToggleIsUpdateSuccessAction
ToggleIsUpdateSuccessAction |
FollowActionTypes |
FollowRequestActionTypes |
PostStatusChangesRequestActionTypes |
PostStatusChangeSubmitted
): CurrentPostState => {
switch (action.type) {
case POST_REQUEST_START:
@@ -137,11 +163,33 @@ const currentPostReducer = (
case COMMENT_SUBMIT_SUCCESS:
case COMMENT_SUBMIT_FAILURE:
case TOGGLE_COMMENT_IS_UPDATE_SUCCESS:
case TOGGLE_COMMENT_IS_POST_UPDATE_FLAG:
return {
...state,
comments: commentsReducer(state.comments, action),
};
case FOLLOW_REQUEST_SUCCESS:
return {
...state,
followed: action.follow.user_id ? true : false,
};
case FOLLOW_SUBMIT_SUCCESS:
return {
...state,
followed: action.isFollow,
};
case POST_STATUS_CHANGES_REQUEST_START:
case POST_STATUS_CHANGES_REQUEST_SUCCESS:
case POST_STATUS_CHANGES_REQUEST_FAILURE:
case POST_STATUS_CHANGE_SUBMITTED:
return {
...state,
postStatusChanges: postStatusChangesReducer(state.postStatusChanges, action),
};
default:
return state;
}

View File

@@ -0,0 +1,71 @@
import {
PostStatusChangesRequestActionTypes,
POST_STATUS_CHANGES_REQUEST_START,
POST_STATUS_CHANGES_REQUEST_SUCCESS,
POST_STATUS_CHANGES_REQUEST_FAILURE,
} from "../actions/PostStatusChange/requestPostStatusChanges";
import {
PostStatusChangeSubmitted,
POST_STATUS_CHANGE_SUBMITTED
} from '../actions/PostStatusChange/submittedPostStatusChange';
import IPostStatusChange from "../interfaces/IPostStatusChange";
export interface PostStatusChangesState {
items: Array<IPostStatusChange>;
areLoading: boolean;
error: string;
}
const initialState: PostStatusChangesState = {
items: [],
areLoading: false,
error: '',
};
const postStatusChangesReducer = (
state = initialState,
action:
PostStatusChangesRequestActionTypes |
PostStatusChangeSubmitted
) => {
switch (action.type) {
case POST_STATUS_CHANGES_REQUEST_START:
return {
...state,
areLoading: true,
};
case POST_STATUS_CHANGES_REQUEST_SUCCESS:
return {
...state,
items: action.postStatusChanges.map(postStatusChange => ({
postStatusId: postStatusChange.post_status_id,
userFullName: postStatusChange.user_full_name,
userEmail: postStatusChange.user_email,
updatedAt: postStatusChange.updated_at,
})),
areLoading: false,
error: '',
};
case POST_STATUS_CHANGES_REQUEST_FAILURE:
return {
...state,
areLoading: false,
error: action.error,
};
case POST_STATUS_CHANGE_SUBMITTED:
return {
...state,
items: [action.postStatusChange, ...state.items],
};
default:
return state;
}
}
export default postStatusChangesReducer;

View File

@@ -7,6 +7,7 @@ import {
HandleCommentRepliesType,
TOGGLE_COMMENT_REPLY,
SET_COMMENT_REPLY_BODY,
TOGGLE_COMMENT_IS_POST_UPDATE_FLAG,
} from '../actions/Comment/handleCommentReplies';
import {
@@ -20,6 +21,7 @@ export interface ReplyFormState {
commentId: number;
isOpen: boolean;
body: string;
isPostUpdate: boolean;
isSubmitting: boolean;
error: string;
}
@@ -28,6 +30,7 @@ const initialState: ReplyFormState = {
commentId: undefined,
isOpen: false,
body: '',
isPostUpdate: false,
isSubmitting: false,
error: '',
}
@@ -58,6 +61,12 @@ const replyFormReducer = (
body: action.body,
};
case TOGGLE_COMMENT_IS_POST_UPDATE_FLAG:
return {
...state,
isPostUpdate: !state.isPostUpdate,
};
case COMMENT_SUBMIT_START:
return {
...state,
@@ -69,6 +78,7 @@ const replyFormReducer = (
...state,
isOpen: false,
body: '',
isPostUpdate: false,
isSubmitting: false,
error: '',
};

View File

@@ -9,6 +9,7 @@ import {
HandleCommentRepliesType,
TOGGLE_COMMENT_REPLY,
SET_COMMENT_REPLY_BODY,
TOGGLE_COMMENT_IS_POST_UPDATE_FLAG,
} from '../actions/Comment/handleCommentReplies';
import {
@@ -42,6 +43,7 @@ const ReplyFormsReducer = (
case TOGGLE_COMMENT_REPLY:
case SET_COMMENT_REPLY_BODY:
case TOGGLE_COMMENT_IS_POST_UPDATE_FLAG:
return (
state.map(
replyForm => (

View File

@@ -1,9 +1,25 @@
.commentsContainer {
@extend .my-3;
.newCommentForm {
@extend
.d-flex,
.flex-column,
.my-3;
.commentBodyForm {
@extend .d-flex;
}
.commentIsUpdateForm {
@extend
.d-flex,
.justify-content-between,
.mt-3;
margin-left: 48px;
}
.currentUserAvatar {
@extend
.gravatar,

View File

@@ -17,7 +17,9 @@
@extend .sidebarCard;
.postUpdateList {
@extend .w-100;
@extend
.scroll-shadows,
.w-100;
max-height: 250px;
overflow-y: scroll;
@@ -26,8 +28,16 @@
@extend
.d-flex,
.flex-column,
.p-2,
.my-1;
.p-2;
&:after { // displays the little centered border under each post update
content: "";
display: block;
margin: 0 auto;
width: 25%;
padding-top: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.postUpdateListItemHeader {
@extend .d-flex;
@@ -42,7 +52,7 @@
}
.postUpdateListItemBody {
@extend .m-0;
@extend .my-1;
font-size: 15px;
}
@@ -54,7 +64,9 @@
@extend .sidebarCard;
.likeList {
@extend .w-100;
@extend
.scroll-shadows,
.w-100;
max-height: 170px;
overflow-y: scroll;
@@ -69,7 +81,19 @@
}
}
}
}
}
.actionBoxContainer {
@extend
.sidebarCard,
.text-center;
.btn {
@extend
.mt-3,
.mb-1;
}
}
}
.postAndCommentsContainer {

View File

@@ -72,7 +72,7 @@ a {
.mb-3,
.p-2;
width: 250px;
width: 280px;
margin-right: 16px;
}
}

View File

@@ -27,6 +27,15 @@
color: $muted-text-color;
}
.smallMutedText {
@extend
.mutedText,
.m-0;
font-size: smaller;
line-height: 95%;
}
.uppercaseText {
@extend
.text-secondary,

View File

@@ -4,7 +4,7 @@
*/
.container {
max-width: 920px;
max-width: 960px;
}
.turbolinks-progress-bar {
@@ -34,4 +34,41 @@
color: #fff;
background-color: #343a40;
border-color: #343a40;
}
// Credits: https://codepen.io/chriscoyier/pen/YzXBYvL
.scroll-shadows {
max-height: 200px;
overflow: auto;
background:
/* Shadow Cover TOP */
linear-gradient(
white 30%,
rgba(255, 255, 255, 0)
) center top,
/* Shadow Cover BOTTOM */
linear-gradient(
rgba(255, 255, 255, 0),
white 70%
) center bottom,
/* Shadow TOP */
radial-gradient(
farthest-side at 50% 0,
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0)
) center top,
/* Shadow BOTTOM */
radial-gradient(
farthest-side at 50% 100%,
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0)
) center bottom;
background-repeat: no-repeat;
background-size: 100% 40px, 100% 40px, 100% 10px, 100% 10px;
background-attachment: local, local, scroll, scroll;
}