Improve UI/UX of Post page (#416)

* Show post content and likes before fetching from backend API
* Autofocus reply and edit forms for comments
* Autofocus title field in post edit form
* More UI/UX improvements
This commit is contained in:
Riccardo Graziosi
2024-09-26 19:45:48 +02:00
committed by GitHub
parent 09d3e17c52
commit 20f93736f5
14 changed files with 114 additions and 42 deletions

View File

@@ -299,8 +299,8 @@ body {
} }
.staffIcon { .staffIcon {
font-size: 24px; font-size: 22px;
margin: 0 4px; margin: 0 0.5rem;
} }
.poweredBy { .poweredBy {

View File

@@ -1,5 +1,5 @@
.commentsContainer { .commentsContainer {
@extend .my-3; @extend .mt-2;
.commentForm { .commentForm {
@extend @extend
@@ -20,7 +20,7 @@
@extend @extend
.d-flex, .d-flex,
.flex-column, .flex-column,
.my-3; .mt-4;
.commentBodyForm { .commentBodyForm {
@extend .d-flex; @extend .d-flex;
@@ -71,19 +71,27 @@
.text-secondary, .text-secondary,
.text-uppercase, .text-uppercase,
.font-weight-lighter, .font-weight-lighter,
.my-2; .mt-5,
.mb-2;
} }
.commentList { @extend .mb-4; }
.commentList > .commentList { .commentList > .commentList {
padding-left: 32px; padding-left: 32px;
} }
.comment { .comment {
@extend @extend
.my-4; .mb-2;
.commentHeader { .commentHeader {
@extend .titleText; @extend
.d-flex,
.align-items-end,
.titleText;
height: 36px;
.commentAuthor { .commentAuthor {
@extend .ml-2; @extend .ml-2;

View File

@@ -59,6 +59,7 @@ class CommentEditForm extends React.Component<Props, State> {
value={body} value={body}
onChange={e => this.handleCommentBodyChange(e.target.value)} onChange={e => this.handleCommentBodyChange(e.target.value)}
rows={3} rows={3}
autoFocus
className="commentForm" className="commentForm"
/> />

View File

@@ -4,7 +4,7 @@ import I18n from 'i18n-js';
import NewComment from './NewComment'; import NewComment from './NewComment';
import CommentList from './CommentList'; import CommentList from './CommentList';
import Spinner from '../common/Spinner'; import Spinner from '../common/Spinner';
import { DangerText } from '../common/CustomTexts'; import { DangerText, MutedText } from '../common/CustomTexts';
import IComment from '../../interfaces/IComment'; import IComment from '../../interfaces/IComment';
import { ReplyFormState } from '../../reducers/replyFormReducer'; import { ReplyFormState } from '../../reducers/replyFormReducer';
@@ -122,15 +122,16 @@ class CommentsP extends React.Component<Props> {
userEmail={userEmail} userEmail={userEmail}
/> />
{ areLoading ? <Spinner /> : null }
{ error ? <DangerText>{error}</DangerText> : null }
<div className="commentsTitle"> <div className="commentsTitle">
{I18n.t('post.comments.title')} {I18n.t('post.comments.title')}
<Separator /> <Separator />
{I18n.t('common.comments_number', { count: comments.length })} {I18n.t('common.comments_number', { count: comments.length })}
</div> </div>
{ areLoading ? <Spinner /> : null }
{ error ? <DangerText>{error}</DangerText> : null }
{ comments.length === 0 && !areLoading && !error && <MutedText>{I18n.t('post.comments.empty')}</MutedText> }
<CommentList <CommentList
comments={comments} comments={comments}
replyForms={replyForms} replyForms={replyForms}

View File

@@ -51,6 +51,7 @@ const NewComment = ({
<textarea <textarea
value={body} value={body}
onChange={handleChange} onChange={handleChange}
autoFocus={parentId != null}
placeholder={I18n.t('post.new_comment.body_placeholder')} placeholder={I18n.t('post.new_comment.body_placeholder')}
className="commentForm" className="commentForm"
/> />

View File

@@ -65,6 +65,7 @@ const PostEditForm = ({
type="text" type="text"
value={title} value={title}
onChange={e => handleChangeTitle(e.target.value)} onChange={e => handleChangeTitle(e.target.value)}
autoFocus
className="postTitle form-control" className="postTitle form-control"
/> />
</div> </div>

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import IPost, { POST_APPROVAL_STATUS_APPROVED, POST_APPROVAL_STATUS_PENDING } from '../../interfaces/IPost'; import IPost, { POST_APPROVAL_STATUS_APPROVED, POST_APPROVAL_STATUS_PENDING, postJSON2JS } from '../../interfaces/IPost';
import IPostStatus from '../../interfaces/IPostStatus'; import IPostStatus from '../../interfaces/IPostStatus';
import IBoard from '../../interfaces/IBoard'; import IBoard from '../../interfaces/IBoard';
import ITenantSetting from '../../interfaces/ITenantSetting'; import ITenantSetting from '../../interfaces/ITenantSetting';
@@ -29,6 +29,7 @@ import HttpStatus from '../../constants/http_status';
import ActionLink from '../common/ActionLink'; import ActionLink from '../common/ActionLink';
import { EditIcon } from '../common/Icons'; import { EditIcon } from '../common/Icons';
import Badge, { BADGE_TYPE_DANGER, BADGE_TYPE_WARNING } from '../common/Badge'; import Badge, { BADGE_TYPE_DANGER, BADGE_TYPE_WARNING } from '../common/Badge';
import { likeJSON2JS } from '../../interfaces/ILike';
interface Props { interface Props {
postId: number; postId: number;
@@ -41,6 +42,7 @@ interface Props {
postStatusChanges: PostStatusChangesState; postStatusChanges: PostStatusChangesState;
boards: Array<IBoard>; boards: Array<IBoard>;
postStatuses: Array<IPostStatus>; postStatuses: Array<IPostStatus>;
originPost: any;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
currentUserFullName: string; currentUserFullName: string;
@@ -48,7 +50,7 @@ interface Props {
tenantSetting: ITenantSetting; tenantSetting: ITenantSetting;
authenticityToken: string; authenticityToken: string;
requestPost(postId: number): void; requestPost(postId: number): Promise<any>;
updatePost( updatePost(
postId: number, postId: number,
title: string, title: string,
@@ -58,7 +60,7 @@ interface Props {
authenticityToken: string, authenticityToken: string,
): Promise<any>; ): Promise<any>;
requestLikes(postId: number): void; requestLikes(postId: number): Promise<any>;
requestFollow(postId: number): void; requestFollow(postId: number): void;
requestPostStatusChanges(postId: number): void; requestPostStatusChanges(postId: number): void;
@@ -83,10 +85,20 @@ interface Props {
): void; ): void;
} }
class PostP extends React.Component<Props> { interface State {
postLoaded: boolean;
likesLoaded: boolean;
}
class PostP extends React.Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = {
postLoaded: false,
likesLoaded: false,
}
this._handleUpdatePost = this._handleUpdatePost.bind(this); this._handleUpdatePost = this._handleUpdatePost.bind(this);
this._handleDeletePost = this._handleDeletePost.bind(this); this._handleDeletePost = this._handleDeletePost.bind(this);
} }
@@ -94,8 +106,8 @@ class PostP extends React.Component<Props> {
componentDidMount() { componentDidMount() {
const { postId } = this.props; const { postId } = this.props;
this.props.requestPost(postId); this.props.requestPost(postId).then(() => this.setState({ postLoaded: true }));
this.props.requestLikes(postId); this.props.requestLikes(postId).then(() => this.setState({ likesLoaded: true }));
this.props.requestFollow(postId); this.props.requestFollow(postId);
this.props.requestPostStatusChanges(postId); this.props.requestPostStatusChanges(postId);
} }
@@ -137,7 +149,10 @@ class PostP extends React.Component<Props> {
this.props.deletePost( this.props.deletePost(
this.props.postId, this.props.postId,
this.props.authenticityToken this.props.authenticityToken
).then(() => window.location.href = `/boards/${this.props.post.boardId}`); ).then(() => {
const board = this.props.boards.find(board => board.id === this.props.post.boardId);
window.location.href = `/boards/${board.slug || board.id}`;
});
} }
render() { render() {
@@ -151,6 +166,7 @@ class PostP extends React.Component<Props> {
postStatusChanges, postStatusChanges,
boards, boards,
postStatuses, postStatuses,
originPost,
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
@@ -166,6 +182,14 @@ class PostP extends React.Component<Props> {
handleChangeEditFormPostStatus, handleChangeEditFormPostStatus,
} = this.props; } = this.props;
const {
postLoaded,
likesLoaded,
} = this.state;
const postToShow = postLoaded ? post : postJSON2JS(originPost.post);
const likesToShow = likesLoaded ? likes : { items: originPost.likes.map(l => likeJSON2JS(l)), areLoading: false, error: null };
const postUpdates = [ const postUpdates = [
...comments.items.filter(comment => comment.isPostUpdate === true), ...comments.items.filter(comment => comment.isPostUpdate === true),
...postStatusChanges.items, ...postStatusChanges.items,
@@ -187,15 +211,15 @@ class PostP extends React.Component<Props> {
{ {
isPowerUser && isPowerUser &&
<LikeList <LikeList
likes={likes.items} likes={likesToShow.items}
areLoading={likes.areLoading} areLoading={likesToShow.areLoading}
error={likes.error} error={likesToShow.error}
/> />
} }
<ActionBox <ActionBox
followed={followed} followed={followed}
submitFollow={() => submitFollow(post.id, !followed, authenticityToken)} submitFollow={() => submitFollow(postToShow.id, !followed, authenticityToken)}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
/> />
@@ -225,24 +249,24 @@ class PostP extends React.Component<Props> {
<> <>
<div className="postHeader"> <div className="postHeader">
<LikeButton <LikeButton
postId={post.id} postId={postToShow.id}
likeCount={likes.items.length} likeCount={likesToShow.items.length}
showLikeCount={isPowerUser || tenantSetting.show_vote_count} showLikeCount={isPowerUser || tenantSetting.show_vote_count}
liked={likes.items.find(like => like.email === currentUserEmail) ? 1 : 0} liked={likesToShow.items.find(like => like.email === currentUserEmail) ? 1 : 0}
size="large" size="large"
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
authenticityToken={authenticityToken} authenticityToken={authenticityToken}
/> />
<h3>{post.title}</h3> <h3>{postToShow.title}</h3>
</div> </div>
<div className="postInfo"> <div className="postInfo">
<PostBoardLabel <PostBoardLabel
{...boards.find(board => board.id === post.boardId)} {...boards.find(board => board.id === postToShow.boardId)}
/> />
<PostStatusLabel <PostStatusLabel
{...postStatuses.find(postStatus => postStatus.id === post.postStatusId)} {...postStatuses.find(postStatus => postStatus.id === postToShow.postStatusId)}
/> />
{ isPowerUser && { isPowerUser &&
<ActionLink onClick={toggleEditMode} icon={<EditIcon />} customClass='editAction'> <ActionLink onClick={toggleEditMode} icon={<EditIcon />} customClass='editAction'>
@@ -252,10 +276,10 @@ class PostP extends React.Component<Props> {
</div> </div>
{ {
(isPowerUser && post.approvalStatus !== POST_APPROVAL_STATUS_APPROVED) && (isPowerUser && postToShow.approvalStatus !== POST_APPROVAL_STATUS_APPROVED) &&
<div className="postInfo"> <div className="postInfo">
<Badge type={post.approvalStatus === POST_APPROVAL_STATUS_PENDING ? BADGE_TYPE_WARNING : BADGE_TYPE_DANGER}> <Badge type={postToShow.approvalStatus === POST_APPROVAL_STATUS_PENDING ? BADGE_TYPE_WARNING : BADGE_TYPE_DANGER}>
{ I18n.t(`activerecord.attributes.post.approval_status_${post.approvalStatus.toLowerCase()}`) } { I18n.t(`activerecord.attributes.post.approval_status_${postToShow.approvalStatus.toLowerCase()}`) }
</Badge> </Badge>
</div> </div>
} }
@@ -265,17 +289,17 @@ class PostP extends React.Component<Props> {
disallowedTypes={['heading', 'image', 'html']} disallowedTypes={['heading', 'image', 'html']}
unwrapDisallowed unwrapDisallowed
> >
{post.description} {postToShow.description}
</ReactMarkdown> </ReactMarkdown>
<PostFooter <PostFooter
createdAt={post.createdAt} createdAt={postToShow.createdAt}
handleDeletePost={this._handleDeletePost} handleDeletePost={this._handleDeletePost}
toggleEditMode={toggleEditMode} toggleEditMode={toggleEditMode}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
authorEmail={post.userEmail} authorEmail={postToShow.userEmail}
authorFullName={post.userFullName} authorFullName={postToShow.userFullName}
currentUserEmail={currentUserEmail} currentUserEmail={currentUserEmail}
/> />
</> </>

View File

@@ -33,7 +33,7 @@ const PostUpdateList = ({
<div className="postUpdateList"> <div className="postUpdateList">
{ {
postUpdates.length === 0 ? postUpdates.length === 0 && !areLoading && !error ?
<CenteredMutedText>{I18n.t('post.updates_box.empty')}</CenteredMutedText> <CenteredMutedText>{I18n.t('post.updates_box.empty')}</CenteredMutedText>
: :
null null

View File

@@ -16,6 +16,7 @@ interface Props {
postId: number; postId: number;
boards: Array<IBoard>; boards: Array<IBoard>;
postStatuses: Array<IPostStatus>; postStatuses: Array<IPostStatus>;
originPost: any;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
currentUserFullName: string; currentUserFullName: string;
@@ -38,6 +39,7 @@ class PostRoot extends React.Component<Props> {
postId, postId,
boards, boards,
postStatuses, postStatuses,
originPost,
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
currentUserFullName, currentUserFullName,
@@ -52,6 +54,7 @@ class PostRoot extends React.Component<Props> {
postId={postId} postId={postId}
boards={boards} boards={boards}
postStatuses={postStatuses} postStatuses={postStatuses}
originPost={originPost}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}

View File

@@ -34,8 +34,8 @@ const mapStateToProps = (state: State) => ({
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
requestPost(postId: number) { requestPost(postId: number): Promise<any> {
dispatch(requestPost(postId)); return dispatch(requestPost(postId));
}, },
updatePost( updatePost(
@@ -69,8 +69,8 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(changePostEditFormPostStatus(postStatusId)); dispatch(changePostEditFormPostStatus(postStatusId));
}, },
requestLikes(postId: number) { requestLikes(postId: number): Promise<any> {
dispatch(requestLikes(postId)); return dispatch(requestLikes(postId));
}, },
requestFollow(postId: number) { requestFollow(postId: number) {

View File

@@ -1,7 +1,15 @@
import ILikeJSON from "./json/ILike";
interface ILike { interface ILike {
id: number; id: number;
fullName: string; fullName: string;
email: string; email: string;
} }
export default ILike; export default ILike;
export const likeJSON2JS = (likeJSON: ILikeJSON): ILike => ({
id: likeJSON.id,
fullName: likeJSON.full_name,
email: likeJSON.email,
});

View File

@@ -1,3 +1,5 @@
import IPostJSON from "./json/IPost";
// Approval status // Approval status
export const POST_APPROVAL_STATUS_APPROVED = 'approved'; export const POST_APPROVAL_STATUS_APPROVED = 'approved';
export const POST_APPROVAL_STATUS_PENDING = 'pending'; export const POST_APPROVAL_STATUS_PENDING = 'pending';
@@ -26,4 +28,22 @@ interface IPost {
createdAt: string; createdAt: string;
} }
export default IPost; export default IPost;
export const postJSON2JS = (postJSON: IPostJSON): IPost => ({
id: postJSON.id,
title: postJSON.title,
slug: postJSON.slug,
description: postJSON.description,
approvalStatus: postJSON.approval_status,
boardId: postJSON.board_id,
postStatusId: postJSON.post_status_id,
likeCount: postJSON.likes_count,
liked: postJSON.liked,
commentsCount: postJSON.comments_count,
hotness: postJSON.hotness,
userId: postJSON.user_id,
userEmail: postJSON.user_email,
userFullName: postJSON.user_full_name,
createdAt: postJSON.created_at,
});

View File

@@ -5,6 +5,10 @@
postId: @post.id, postId: @post.id,
boards: @boards, boards: @boards,
postStatuses: @post_statuses, postStatuses: @post_statuses,
originPost: {
post: @post,
likes: @post.likes.select(:id, :full_name, :email).left_outer_joins(:user),
},
isLoggedIn: user_signed_in?, isLoggedIn: user_signed_in?,
isPowerUser: user_signed_in? ? current_user.moderator? : false, isPowerUser: user_signed_in? ? current_user.moderator? : false,
currentUserFullName: user_signed_in? ? current_user.full_name : nil, currentUserFullName: user_signed_in? ? current_user.full_name : nil,

View File

@@ -158,6 +158,7 @@ en:
not_following_description: "you won't receive notifications about this post" not_following_description: "you won't receive notifications about this post"
comments: comments:
title: 'Activity' title: 'Activity'
empty: 'There are no comments yet'
post_update_badge: 'Update' post_update_badge: 'Update'
reply_button: 'Reply' reply_button: 'Reply'
new_comment: new_comment: