diff --git a/client/scss/styles.scss b/client/scss/styles.scss index 16f04539f5..c69f6564b7 100644 --- a/client/scss/styles.scss +++ b/client/scss/styles.scss @@ -98,6 +98,7 @@ These are classes for components. @import '../src/components/LoadingSpinner/LoadingSpinner'; @import '../src/components/PublicationStatus/PublicationStatus'; @import '../src/components/Explorer/Explorer'; +@import '../src/components/CommentApp/main'; // Legacy @import 'components/icons'; diff --git a/client/src/components/CommentApp/__fixtures__/state.tsx b/client/src/components/CommentApp/__fixtures__/state.tsx new file mode 100644 index 0000000000..f60369d209 --- /dev/null +++ b/client/src/components/CommentApp/__fixtures__/state.tsx @@ -0,0 +1,65 @@ +import type { Comment, CommentReply, CommentsState } from '../state/comments'; + + +const remoteReply: CommentReply = { + localId: 2, + remoteId: 2, + mode: 'default', + author: { id: 1, name: 'test user' }, + date: 0, + text: 'a reply', + newText: '', + deleted: false, +}; + +const localReply: CommentReply = { + localId: 3, + remoteId: null, + mode: 'default', + author: { id: 1, name: 'test user' }, + date: 0, + text: 'another reply', + newText: '', + deleted: false, +}; + +const remoteComment: Comment = { + contentpath: 'test_contentpath', + position: '', + localId: 1, + annotation: null, + remoteId: 1, + mode: 'default', + deleted: false, + author: { id: 1, name: 'test user' }, + date: 0, + text: 'test text', + newReply: '', + newText: '', + remoteReplyCount: 1, + replies: new Map([[remoteReply.localId, remoteReply], [localReply.localId, localReply]]), +}; + +const localComment: Comment = { + contentpath: 'test_contentpath_2', + position: '', + localId: 4, + annotation: null, + remoteId: null, + mode: 'default', + deleted: false, + author: { id: 1, name: 'test user' }, + date: 0, + text: 'unsaved comment', + newReply: '', + newText: '', + replies: new Map(), + remoteReplyCount: 0, +}; + +export const basicCommentsState: CommentsState = { + focusedComment: 1, + pinnedComment: 1, + remoteCommentCount: 1, + comments: new Map([[remoteComment.localId, remoteComment], [localComment.localId, localComment]]), +}; diff --git a/client/src/components/CommentApp/actions/comments.ts b/client/src/components/CommentApp/actions/comments.ts new file mode 100644 index 0000000000..6eb9cf7f86 --- /dev/null +++ b/client/src/components/CommentApp/actions/comments.ts @@ -0,0 +1,147 @@ +import type { + Comment, + CommentUpdate, + CommentReply, + CommentReplyUpdate, +} from '../state/comments'; + +export const ADD_COMMENT = 'add-comment'; +export const UPDATE_COMMENT = 'update-comment'; +export const DELETE_COMMENT = 'delete-comment'; +export const SET_FOCUSED_COMMENT = 'set-focused-comment'; +export const SET_PINNED_COMMENT = 'set-pinned-comment'; + +export const ADD_REPLY = 'add-reply'; +export const UPDATE_REPLY = 'update-reply'; +export const DELETE_REPLY = 'delete-reply'; + +export interface AddCommentAction { + type: typeof ADD_COMMENT; + comment: Comment; +} + +export interface UpdateCommentAction { + type: typeof UPDATE_COMMENT; + commentId: number; + update: CommentUpdate; +} + +export interface DeleteCommentAction { + type: typeof DELETE_COMMENT; + commentId: number; +} + +export interface SetFocusedCommentAction { + type: typeof SET_FOCUSED_COMMENT; + commentId: number | null; + updatePinnedComment: boolean; +} + +export interface AddReplyAction { + type: typeof ADD_REPLY; + commentId: number; + reply: CommentReply; +} + +export interface UpdateReplyAction { + type: typeof UPDATE_REPLY; + commentId: number; + replyId: number; + update: CommentReplyUpdate; +} + +export interface DeleteReplyAction { + type: typeof DELETE_REPLY; + commentId: number; + replyId: number; +} + +export type Action = + | AddCommentAction + | UpdateCommentAction + | DeleteCommentAction + | SetFocusedCommentAction + | AddReplyAction + | UpdateReplyAction + | DeleteReplyAction; + +export function addComment(comment: Comment): AddCommentAction { + return { + type: ADD_COMMENT, + comment, + }; +} + +export function updateComment( + commentId: number, + update: CommentUpdate +): UpdateCommentAction { + return { + type: UPDATE_COMMENT, + commentId, + update, + }; +} + +export function deleteComment(commentId: number): DeleteCommentAction { + return { + type: DELETE_COMMENT, + commentId, + }; +} + +export function setFocusedComment( + commentId: number | null, + { updatePinnedComment } = { updatePinnedComment: false } +): SetFocusedCommentAction { + return { + type: SET_FOCUSED_COMMENT, + commentId, + updatePinnedComment + }; +} + +export function addReply( + commentId: number, + reply: CommentReply +): AddReplyAction { + return { + type: ADD_REPLY, + commentId, + reply, + }; +} + +export function updateReply( + commentId: number, + replyId: number, + update: CommentReplyUpdate +): UpdateReplyAction { + return { + type: UPDATE_REPLY, + commentId, + replyId, + update, + }; +} + +export function deleteReply( + commentId: number, + replyId: number +): DeleteReplyAction { + return { + type: DELETE_REPLY, + commentId, + replyId, + }; +} + +export const commentActionFunctions = { + addComment, + updateComment, + deleteComment, + setFocusedComment, + addReply, + updateReply, + deleteReply, +}; diff --git a/client/src/components/CommentApp/actions/index.ts b/client/src/components/CommentApp/actions/index.ts new file mode 100644 index 0000000000..8c89b301b0 --- /dev/null +++ b/client/src/components/CommentApp/actions/index.ts @@ -0,0 +1,4 @@ +import type { Action as CommentsAction } from './comments'; +import type { Action as SettingsActon } from './settings'; + +export type Action = CommentsAction | SettingsActon; diff --git a/client/src/components/CommentApp/actions/settings.ts b/client/src/components/CommentApp/actions/settings.ts new file mode 100644 index 0000000000..7366af3523 --- /dev/null +++ b/client/src/components/CommentApp/actions/settings.ts @@ -0,0 +1,19 @@ +import type { SettingsStateUpdate } from '../state/settings'; + +export const UPDATE_GLOBAL_SETTINGS = 'update-global-settings'; + +export interface UpdateGlobalSettingsAction { + type: typeof UPDATE_GLOBAL_SETTINGS; + update: SettingsStateUpdate; +} + +export type Action = UpdateGlobalSettingsAction; + +export function updateGlobalSettings( + update: SettingsStateUpdate +): UpdateGlobalSettingsAction { + return { + type: UPDATE_GLOBAL_SETTINGS, + update, + }; +} diff --git a/client/src/components/CommentApp/comments.js b/client/src/components/CommentApp/comments.js index 57f9b2ce6e..8d1c18a8b6 100644 --- a/client/src/components/CommentApp/comments.js +++ b/client/src/components/CommentApp/comments.js @@ -1,4 +1,4 @@ -import { initCommentApp } from 'wagtail-comment-frontend'; +import { initCommentApp } from './main'; import { STRINGS } from '../../config/wagtailConfig'; function initComments() { diff --git a/client/src/components/CommentApp/components/Comment/index.stories.tsx b/client/src/components/CommentApp/components/Comment/index.stories.tsx new file mode 100644 index 0000000000..bac0fb71cc --- /dev/null +++ b/client/src/components/CommentApp/components/Comment/index.stories.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { createStore } from 'redux'; + +import { Store, reducer } from '../../state'; + +import { + RenderCommentsForStorybook, + addTestComment, +} from '../../utils/storybook'; + +export default { title: 'Comment' }; + +export function addNewComment() { + const store: Store = createStore(reducer); + + addTestComment(store, { + mode: 'creating', + focused: true, + }); + + return ; +} + +export function comment() { + const store: Store = createStore(reducer); + + addTestComment(store, { + mode: 'default', + text: 'An example comment', + }); + + return ; +} + +export function commentFromSomeoneElse() { + const store: Store = createStore(reducer); + + addTestComment(store, { + mode: 'default', + text: 'An example comment', + author: { + id: 2, + name: 'Someone else', + avatarUrl: 'https://gravatar.com/avatar/31c3d5cc27d1faa321c2413589e8a53f?s=200&d=robohash&r=x', + }, + }); + + return ; +} + +export function commentFromSomeoneElseWithoutAvatar() { + const store: Store = createStore(reducer); + + addTestComment(store, { + mode: 'default', + text: 'An example comment', + author: { + id: 2, + name: 'Someone else', + }, + }); + + return ; +} + +export function commentFromSomeoneWithAReallyLongName() { + const store: Store = createStore(reducer); + + addTestComment(store, { + mode: 'default', + text: 'An example comment', + author: { + id: 1, + name: 'This person has a really long name and it should wrap to the next line', + avatarUrl: 'https://gravatar.com/avatar/31c3d5cc27d1faa321c2413589e8a53f?s=200&d=robohash&r=x', + + }, + }); + + return ; +} + +export function focused() { + const store: Store = createStore(reducer); + + addTestComment(store, { + mode: 'default', + text: 'An example comment', + focused: true, + }); + + return ; +} + +export function saving() { + const store: Store = createStore(reducer); + + addTestComment(store, { + mode: 'saving', + text: 'An example comment', + }); + + return ; +} + +export function saveError() { + const store: Store = createStore(reducer); + + addTestComment(store, { + mode: 'save_error', + text: 'An example comment', + }); + + return ; +} + +export function deleteConfirm() { + const store: Store = createStore(reducer); + + addTestComment(store, { + mode: 'delete_confirm', + text: 'An example comment', + }); + + return ; +} + +export function deleting() { + const store: Store = createStore(reducer); + + addTestComment(store, { + mode: 'deleting', + text: 'An example comment', + }); + + return ; +} + +export function deleteError() { + const store: Store = createStore(reducer); + addTestComment(store, { + mode: 'delete_error', + text: 'An example comment', + }); + + return ; +} diff --git a/client/src/components/CommentApp/components/Comment/index.tsx b/client/src/components/CommentApp/components/Comment/index.tsx new file mode 100644 index 0000000000..cccbbc4023 --- /dev/null +++ b/client/src/components/CommentApp/components/Comment/index.tsx @@ -0,0 +1,596 @@ +/* eslint-disable react/prop-types */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import type { Store } from '../../state'; +import { Author, Comment, newCommentReply } from '../../state/comments'; +import { + updateComment, + deleteComment, + setFocusedComment, + addReply +} from '../../actions/comments'; +import { LayoutController } from '../../utils/layout'; +import { getNextReplyId } from '../../utils/sequences'; +import CommentReplyComponent from '../CommentReply'; +import type { TranslatableStrings } from '../../main'; +import { CommentHeader } from '../CommentHeader'; + +async function saveComment(comment: Comment, store: Store) { + store.dispatch( + updateComment(comment.localId, { + mode: 'saving', + }) + ); + + try { + store.dispatch( + updateComment(comment.localId, { + mode: 'default', + text: comment.newText, + remoteId: comment.remoteId, + author: comment.author, + date: comment.date, + }) + ); + } catch (err) { + /* eslint-disable-next-line no-console */ + console.error(err); + store.dispatch( + updateComment(comment.localId, { + mode: 'save_error', + }) + ); + } +} + +async function doDeleteComment(comment: Comment, store: Store) { + store.dispatch( + updateComment(comment.localId, { + mode: 'deleting', + }) + ); + + try { + store.dispatch(deleteComment(comment.localId)); + } catch (err) { + /* eslint-disable-next-line no-console */ + console.error(err); + store.dispatch( + updateComment(comment.localId, { + mode: 'delete_error', + }) + ); + } +} + +export interface CommentProps { + store: Store; + comment: Comment; + isFocused: boolean; + layout: LayoutController; + user: Author | null; + strings: TranslatableStrings; +} + +export default class CommentComponent extends React.Component { + renderReplies({ hideNewReply = false } = {}): React.ReactFragment { + const { comment, isFocused, store, user, strings } = this.props; + + if (!comment.remoteId) { + // Hide replies UI if the comment itself isn't saved yet + return <>; + } + + const onChangeNewReply = (e: React.ChangeEvent) => { + e.preventDefault(); + + store.dispatch( + updateComment(comment.localId, { + newReply: e.target.value, + }) + ); + }; + + const sendReply = async (e: React.FormEvent) => { + e.preventDefault(); + + const replyId = getNextReplyId(); + const reply = newCommentReply(replyId, user, Date.now(), { + text: comment.newReply, + mode: 'default', + }); + store.dispatch(addReply(comment.localId, reply)); + + store.dispatch( + updateComment(comment.localId, { + newReply: '', + }) + ); + }; + + const onClickCancelReply = (e: React.MouseEvent) => { + e.preventDefault(); + + store.dispatch( + updateComment(comment.localId, { + newReply: '', + }) + ); + }; + + const replies: React.ReactNode[] = []; + let replyBeingEdited = false; + for (const reply of comment.replies.values()) { + if (reply.mode === 'saving' || reply.mode === 'editing') { + replyBeingEdited = true; + } + + if (!reply.deleted) { + replies.push( + + ); + } + } + + // Hide new reply if a reply is being edited as well + const newReplyHidden = hideNewReply || replyBeingEdited; + + let replyActions = <>; + if (!newReplyHidden && isFocused && comment.newReply.length > 0) { + replyActions = ( +
+ + +
+ ); + } + + let replyTextarea = <>; + if (!newReplyHidden && (isFocused || comment.newReply)) { + replyTextarea = ( +