Merge Wagtail Comment Frontend (#6953)

* Copy code from wagtail-comment-frontend

Exact copy of the src directory from:
4486c2fc32

* Integrate commenting code

* Linting
pull/7050/head
Karl Hobley 2021-04-01 10:00:22 +01:00 zatwierdzone przez Matt Westcott
rodzic daada5b4c8
commit bbbc31ff60
45 zmienionych plików z 3892 dodań i 14 usunięć

Wyświetl plik

@ -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';

Wyświetl plik

@ -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]]),
};

Wyświetl plik

@ -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,
};

Wyświetl plik

@ -0,0 +1,4 @@
import type { Action as CommentsAction } from './comments';
import type { Action as SettingsActon } from './settings';
export type Action = CommentsAction | SettingsActon;

Wyświetl plik

@ -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,
};
}

Wyświetl plik

@ -1,4 +1,4 @@
import { initCommentApp } from 'wagtail-comment-frontend';
import { initCommentApp } from './main';
import { STRINGS } from '../../config/wagtailConfig';
function initComments() {

Wyświetl plik

@ -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 <RenderCommentsForStorybook store={store} />;
}
export function comment() {
const store: Store = createStore(reducer);
addTestComment(store, {
mode: 'default',
text: 'An example comment',
});
return <RenderCommentsForStorybook store={store} />;
}
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 <RenderCommentsForStorybook store={store} />;
}
export function commentFromSomeoneElseWithoutAvatar() {
const store: Store = createStore(reducer);
addTestComment(store, {
mode: 'default',
text: 'An example comment',
author: {
id: 2,
name: 'Someone else',
},
});
return <RenderCommentsForStorybook store={store} />;
}
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 <RenderCommentsForStorybook store={store} />;
}
export function focused() {
const store: Store = createStore(reducer);
addTestComment(store, {
mode: 'default',
text: 'An example comment',
focused: true,
});
return <RenderCommentsForStorybook store={store} />;
}
export function saving() {
const store: Store = createStore(reducer);
addTestComment(store, {
mode: 'saving',
text: 'An example comment',
});
return <RenderCommentsForStorybook store={store} />;
}
export function saveError() {
const store: Store = createStore(reducer);
addTestComment(store, {
mode: 'save_error',
text: 'An example comment',
});
return <RenderCommentsForStorybook store={store} />;
}
export function deleteConfirm() {
const store: Store = createStore(reducer);
addTestComment(store, {
mode: 'delete_confirm',
text: 'An example comment',
});
return <RenderCommentsForStorybook store={store} />;
}
export function deleting() {
const store: Store = createStore(reducer);
addTestComment(store, {
mode: 'deleting',
text: 'An example comment',
});
return <RenderCommentsForStorybook store={store} />;
}
export function deleteError() {
const store: Store = createStore(reducer);
addTestComment(store, {
mode: 'delete_error',
text: 'An example comment',
});
return <RenderCommentsForStorybook store={store} />;
}

Wyświetl plik

@ -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<CommentProps> {
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<HTMLTextAreaElement>) => {
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(
<CommentReplyComponent
key={reply.localId}
store={store}
user={user}
comment={comment}
reply={reply}
strings={strings}
/>
);
}
}
// 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 = (
<div className="comment__reply-actions">
<button
type="submit"
className="comment__button comment__button--primary"
>
{strings.REPLY}
</button>
<button
type="button"
onClick={onClickCancelReply}
className="comment__button"
>
{strings.CANCEL}
</button>
</div>
);
}
let replyTextarea = <></>;
if (!newReplyHidden && (isFocused || comment.newReply)) {
replyTextarea = (
<textarea
className="comment__reply-input"
placeholder="Enter your reply..."
value={comment.newReply}
onChange={onChangeNewReply}
style={{ resize: 'none' }}
/>
);
}
return (
<>
<ul className="comment__replies">{replies}</ul>
<form onSubmit={sendReply}>
{replyTextarea}
{replyActions}
</form>
</>
);
}
renderCreating(): React.ReactFragment {
const { comment, store, strings } = this.props;
const onChangeText = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
e.preventDefault();
store.dispatch(
updateComment(comment.localId, {
newText: e.target.value,
})
);
};
const onSave = async (e: React.FormEvent) => {
e.preventDefault();
await saveComment(comment, store);
};
const onCancel = (e: React.MouseEvent) => {
e.preventDefault();
store.dispatch(deleteComment(comment.localId));
};
return (
<>
<CommentHeader commentReply={comment} store={store} strings={strings} />
<form onSubmit={onSave}>
<textarea
className="comment__input"
value={comment.newText}
onChange={onChangeText}
style={{ resize: 'none' }}
placeholder="Enter your comments..."
/>
<div className="comment__actions">
<button
type="submit"
className="comment__button comment__button--primary"
>
{strings.COMMENT}
</button>
<button
type="button"
onClick={onCancel}
className="comment__button"
>
{strings.CANCEL}
</button>
</div>
</form>
</>
);
}
renderEditing(): React.ReactFragment {
const { comment, store, strings } = this.props;
const onChangeText = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
e.preventDefault();
store.dispatch(
updateComment(comment.localId, {
newText: e.target.value,
})
);
};
const onSave = async (e: React.FormEvent) => {
e.preventDefault();
await saveComment(comment, store);
};
const onCancel = (e: React.MouseEvent) => {
e.preventDefault();
store.dispatch(
updateComment(comment.localId, {
mode: 'default',
newText: comment.text,
})
);
};
return (
<>
<CommentHeader commentReply={comment} store={store} strings={strings} />
<form onSubmit={onSave}>
<textarea
className="comment__input"
value={comment.newText}
onChange={onChangeText}
style={{ resize: 'none' }}
/>
<div className="comment__actions">
<button
type="submit"
className="comment__button comment__button--primary"
>
{strings.SAVE}
</button>
<button
type="button"
onClick={onCancel}
className="comment__button"
>
{strings.CANCEL}
</button>
</div>
</form>
{this.renderReplies({ hideNewReply: true })}
</>
);
}
renderSaving(): React.ReactFragment {
const { comment, store, strings } = this.props;
return (
<>
<CommentHeader commentReply={comment} store={store} strings={strings} />
<p className="comment__text">{comment.text}</p>
<div className="comment__progress">{strings.SAVING}</div>
{this.renderReplies({ hideNewReply: true })}
</>
);
}
renderSaveError(): React.ReactFragment {
const { comment, store, strings } = this.props;
const onClickRetry = async (e: React.MouseEvent) => {
e.preventDefault();
await saveComment(comment, store);
};
return (
<>
<CommentHeader commentReply={comment} store={store} strings={strings} />
<p className="comment__text">{comment.text}</p>
{this.renderReplies({ hideNewReply: true })}
<div className="comment__error">
{strings.SAVE_ERROR}
<button
type="button"
className="comment__button"
onClick={onClickRetry}
>
{strings.RETRY}
</button>
</div>
</>
);
}
renderDeleteConfirm(): React.ReactFragment {
const { comment, store, strings } = this.props;
const onClickDelete = async (e: React.MouseEvent) => {
e.preventDefault();
await doDeleteComment(comment, store);
};
const onClickCancel = (e: React.MouseEvent) => {
e.preventDefault();
store.dispatch(
updateComment(comment.localId, {
mode: 'default',
})
);
};
return (
<>
<CommentHeader commentReply={comment} store={store} strings={strings} />
<p className="comment__text">{comment.text}</p>
<div className="comment__confirm-delete">
{strings.CONFIRM_DELETE_COMMENT}
<button
type="button"
className="comment__button comment__button--red"
onClick={onClickDelete}
>
{strings.DELETE}
</button>
<button
type="button"
className="comment__button"
onClick={onClickCancel}
>
{strings.CANCEL}
</button>
</div>
{this.renderReplies({ hideNewReply: true })}
</>
);
}
renderDeleting(): React.ReactFragment {
const { comment, store, strings } = this.props;
return (
<>
<CommentHeader commentReply={comment} store={store} strings={strings} />
<p className="comment__text">{comment.text}</p>
<div className="comment__progress">{strings.DELETING}</div>
{this.renderReplies({ hideNewReply: true })}
</>
);
}
renderDeleteError(): React.ReactFragment {
const { comment, store, strings } = this.props;
const onClickRetry = async (e: React.MouseEvent) => {
e.preventDefault();
await doDeleteComment(comment, store);
};
const onClickCancel = async (e: React.MouseEvent) => {
e.preventDefault();
store.dispatch(
updateComment(comment.localId, {
mode: 'default',
})
);
};
return (
<>
<CommentHeader commentReply={comment} store={store} strings={strings} />
<p className="comment__text">{comment.text}</p>
{this.renderReplies({ hideNewReply: true })}
<div className="comment__error">
{strings.DELETE_ERROR}
<button
type="button"
className="comment__button"
onClick={onClickCancel}
>
{strings.CANCEL}
</button>
<button
type="button"
className="comment__button"
onClick={onClickRetry}
>
{strings.RETRY}
</button>
</div>
</>
);
}
renderDefault(): React.ReactFragment {
const { comment, store, strings } = this.props;
// Show edit/delete buttons if this comment was authored by the current user
let onEdit;
let onDelete;
if (comment.author === null || this.props.user && this.props.user.id === comment.author.id) {
onEdit = () => {
store.dispatch(
updateComment(comment.localId, {
mode: 'editing',
newText: comment.text,
})
);
};
onDelete = () => {
store.dispatch(
updateComment(comment.localId, {
mode: 'delete_confirm',
})
);
};
}
return (
<>
<CommentHeader
commentReply={comment}
store={store}
strings={strings}
onResolve={doDeleteComment}
onEdit={onEdit}
onDelete={onDelete}
/>
<p className="comment__text">{comment.text}</p>
{this.renderReplies()}
</>
);
}
render() {
let inner: React.ReactFragment;
switch (this.props.comment.mode) {
case 'creating':
inner = this.renderCreating();
break;
case 'editing':
inner = this.renderEditing();
break;
case 'saving':
inner = this.renderSaving();
break;
case 'save_error':
inner = this.renderSaveError();
break;
case 'delete_confirm':
inner = this.renderDeleteConfirm();
break;
case 'deleting':
inner = this.renderDeleting();
break;
case 'delete_error':
inner = this.renderDeleteError();
break;
default:
inner = this.renderDefault();
break;
}
const onClick = () => {
this.props.store.dispatch(setFocusedComment(this.props.comment.localId));
};
const onDoubleClick = () => {
this.props.store.dispatch(setFocusedComment(this.props.comment.localId, { updatePinnedComment: true }));
};
const top = this.props.layout.getCommentPosition(
this.props.comment.localId
);
const right = this.props.isFocused ? 50 : 0;
return (
<li
key={this.props.comment.localId}
className={`comment comment--mode-${this.props.comment.mode} ${this.props.isFocused ? 'comment--focused' : ''}`}
style={{
position: 'absolute',
top: `${top}px`,
right: `${right}px`,
}}
data-comment-id={this.props.comment.localId}
onClick={onClick}
onDoubleClick={onDoubleClick}
>
{inner}
</li>
);
}
componentDidMount() {
const element = ReactDOM.findDOMNode(this);
if (element instanceof HTMLElement) {
// If this is a new comment, focus in the edit box
if (this.props.comment.mode === 'creating') {
const textAreaElement = element.querySelector('textarea');
if (textAreaElement instanceof HTMLTextAreaElement) {
textAreaElement.focus();
}
}
this.props.layout.setCommentElement(this.props.comment.localId, element);
this.props.layout.setCommentHeight(
this.props.comment.localId,
element.offsetHeight
);
}
}
componentWillUnmount() {
this.props.layout.setCommentElement(this.props.comment.localId, null);
}
componentDidUpdate() {
const element = ReactDOM.findDOMNode(this);
// Keep height up to date so that other comments will be moved out of the way
if (element instanceof HTMLElement) {
this.props.layout.setCommentHeight(
this.props.comment.localId,
element.offsetHeight
);
}
}
}

Wyświetl plik

@ -0,0 +1,100 @@
.comment {
@include box;
font-size: 1.5em;
width: 100%;
max-width: 400px;
display: block;
transition: top 0.5s ease 0s, right 0.5s ease 0s, height 0.5s ease 0s;
pointer-events: auto;
&__text {
color: $color-box-text;
font-size: 0.8em;
margin-top: 32px;
margin-bottom: 0;
&--mode-deleting {
color: $color-grey-1;
}
}
form {
padding-top: 20px;
}
&--mode-deleting &__text {
color: $color-grey-3;
}
&__replies {
list-style-type: none;
padding: 0;
}
&__button {
@include button;
}
&__actions &__button,
&__confirm-delete &__button,
&__reply-actions &__button {
margin-right: 10px;
margin-top: 10px;
}
&__confirm-delete,
&__error {
color: $color-box-text;
font-weight: bold;
font-size: 0.8em;
button {
margin-left: 10px;
/* stylelint-disable-next-line declaration-no-important */
margin-right: 0 !important;
}
}
&__error {
color: $color-white;
background-color: $color-red-dark;
border-radius: 3px;
padding: 5px;
padding-left: 10px;
height: 26px;
line-height: 26px;
vertical-align: middle;
button {
height: 26px;
float: right;
margin-left: 5px;
color: $color-white;
background-color: $color-red-very-dark;
border-color: $color-red-very-dark;
padding: 2px;
padding-left: 10px;
padding-right: 10px;
font-size: 0.65em;
font-weight: bold;
}
&::after {
display: block;
content: '';
clear: both;
}
}
&__progress {
margin-top: 20px;
font-weight: bold;
font-size: 0.8em;
}
&__reply-input {
/* stylelint-disable-next-line declaration-no-important */
margin-top: 20px !important;
}
}

Wyświetl plik

@ -0,0 +1,83 @@
/* eslint-disable react/prop-types */
import dateFormat from 'dateformat';
import React, { FunctionComponent } from 'react';
import type { Store } from '../../state';
import { TranslatableStrings } from '../../main';
import { Author } from '../../state/comments';
interface CommentReply {
author: Author | null;
date: number;
}
interface CommentHeaderProps {
commentReply: CommentReply;
store: Store;
strings: TranslatableStrings;
onResolve?(commentReply: CommentReply, store: Store): void;
onEdit?(commentReply: CommentReply, store: Store): void;
onDelete?(commentReply: CommentReply, store: Store): void;
}
export const CommentHeader: FunctionComponent<CommentHeaderProps> = ({
commentReply, store, strings, onResolve, onEdit, onDelete
}) => {
const { author, date } = commentReply;
const onClickResolve = (e: React.MouseEvent) => {
e.preventDefault();
if (onResolve) {
onResolve(commentReply, store);
}
};
const onClickEdit = async (e: React.MouseEvent) => {
e.preventDefault();
if (onEdit) {
onEdit(commentReply, store);
}
};
const onClickDelete = async (e: React.MouseEvent) => {
e.preventDefault();
if (onDelete) {
onDelete(commentReply, store);
}
};
return (
<div className="comment-header">
<div className="comment-header__actions">
{onResolve &&
<div className="comment-header__action comment-header__action--resolve">
<button type="button" aria-label={strings.RESOLVE} onClick={onClickResolve}>
</button>
</div>
}
{(onEdit || onDelete) &&
<div className="comment-header__action comment-header__action--more">
<details>
<summary aria-label={strings.MORE_ACTIONS} aria-haspopup="menu" role="button">
</summary>
<div className="comment-header__more-actions">
{onEdit && <button type="button" role="menuitem" onClick={onClickEdit}>{strings.EDIT}</button>}
{onDelete && <button type="button" role="menuitem" onClick={onClickDelete}>{strings.DELETE}</button>}
</div>
</details>
</div>
}
</div>
{author && author.avatarUrl &&
<img className="comment-header__avatar" src={author.avatarUrl} role="presentation" />}
<p className="comment-header__author">{author ? author.name : ''}</p>
<p className="comment-header__date">{dateFormat(date, 'h:MM mmmm d')}</p>
</div>
);
};

Wyświetl plik

@ -0,0 +1,136 @@
.comment-header {
position: relative;
&__avatar {
position: absolute;
width: 40px;
height: 40px;
border-radius: 20px;
}
&__author,
&__date {
max-width: calc(100% - 160px); // Leave room for actions to the right and avatar to the left
margin: 0;
margin-left: 57px;
font-size: 0.7em;
}
&__date {
color: $color-grey-25;
}
&__actions {
position: absolute;
right: 0;
}
&__action {
float: left;
margin-left: 10px;
border-radius: 5px;
&:hover {
background-color: $color-grey-7;
}
> button,
> details > summary {
// Hides triangle on Firefox
list-style-type: none;
// Hides triangle on Chrome
&::-webkit-details-marker { display: none; }
width: 50px;
height: 50px;
position: relative;
background-color: unset;
border: unset;
-moz-outline-radius: 10px;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 50px;
height: 50px;
mask-position: center;
mask-size: 25px 25px;
mask-repeat: no-repeat;
}
&:hover {
cursor: pointer;
}
}
> details {
position: relative;
> div {
position: absolute;
right: 0;
top: 60px;
}
}
&--resolve {
> button::before,
> details > summary::before {
background-color: $color-teal;
mask-image: url('./icons/check-solid.svg');
}
}
&--more {
> button::before,
> details > summary::before {
background-color: $color-grey-25;
background-position: center;
mask-image: url('./icons/ellipsis-v-solid.svg');
}
}
}
&__more-actions {
background-color: #333;
padding: 0.75rem 1rem;
min-width: 8rem;
text-transform: none;
position: absolute;
z-index: 1000;
list-style: none;
text-align: left;
&:before {
content: '';
border: 0.35rem solid transparent;
border-bottom-color: #333;
display: block;
position: absolute;
bottom: 100%;
right: 1rem;
}
button {
display: block;
background: unset;
border: unset;
color: #fff;
padding: 10px;
font-size: 20px;
width: 120px;
text-align: left;
&:hover {
color: #aaa;
cursor: pointer;
}
}
}
}
.comment--mode-deleting .comment-header,
.comment-reply--mode-deleting .comment-header {
opacity: 0.5;
}

Wyświetl plik

@ -0,0 +1,184 @@
import React from 'react';
import { createStore } from 'redux';
import { Store, reducer } from '../../state';
import {
RenderCommentsForStorybook,
addTestComment,
addTestReply,
} from '../../utils/storybook';
export default { title: 'CommentReply' };
export function reply() {
const store: Store = createStore(reducer);
const commentId = addTestComment(store, {
mode: 'default',
text: 'An example comment',
});
addTestReply(store, commentId, {
mode: 'default',
text: 'An example reply',
});
return <RenderCommentsForStorybook store={store} />;
}
export function replyFromSomeoneElse() {
const store: Store = createStore(reducer);
const commentId = addTestComment(store, {
mode: 'default',
text: 'An example comment',
});
addTestReply(store, commentId, {
mode: 'default',
text: 'An example reply',
author: {
id: 2,
name: 'Someone else',
avatarUrl: 'https://gravatar.com/avatar/31c3d5cc27d1faa321c2413589e8a53f?s=200&d=robohash&r=x',
},
});
return <RenderCommentsForStorybook store={store} />;
}
export function focused() {
const store: Store = createStore(reducer);
const commentId = addTestComment(store, {
mode: 'default',
text: 'An example comment',
focused: true,
});
addTestReply(store, commentId, {
mode: 'default',
text: 'An example reply',
});
return <RenderCommentsForStorybook store={store} />;
}
export function editing() {
const store: Store = createStore(reducer);
const commentId = addTestComment(store, {
mode: 'default',
text: 'An example comment',
focused: true,
});
addTestReply(store, commentId, {
mode: 'editing',
text: 'An example reply',
});
return <RenderCommentsForStorybook store={store} />;
}
export function saving() {
const store: Store = createStore(reducer);
const commentId = addTestComment(store, {
mode: 'default',
text: 'An example comment',
focused: true,
});
addTestReply(store, commentId, {
mode: 'saving',
text: 'An example reply',
});
return <RenderCommentsForStorybook store={store} />;
}
export function saveError() {
const store: Store = createStore(reducer);
const commentId = addTestComment(store, {
mode: 'default',
text: 'An example comment',
focused: true,
});
addTestReply(store, commentId, {
mode: 'save_error',
text: 'An example reply',
});
return <RenderCommentsForStorybook store={store} />;
}
export function deleteConfirm() {
const store: Store = createStore(reducer);
const commentId = addTestComment(store, {
mode: 'default',
text: 'An example comment',
focused: true,
});
addTestReply(store, commentId, {
mode: 'delete_confirm',
text: 'An example reply',
});
return <RenderCommentsForStorybook store={store} />;
}
export function deleting() {
const store: Store = createStore(reducer);
const commentId = addTestComment(store, {
mode: 'default',
text: 'An example comment',
focused: true,
});
addTestReply(store, commentId, {
mode: 'deleting',
text: 'An example reply',
});
return <RenderCommentsForStorybook store={store} />;
}
export function deleteError() {
const store: Store = createStore(reducer);
const commentId = addTestComment(store, {
mode: 'default',
text: 'An example comment',
focused: true,
});
addTestReply(store, commentId, {
mode: 'delete_error',
text: 'An example reply',
});
return <RenderCommentsForStorybook store={store} />;
}
export function deleted() {
const store: Store = createStore(reducer);
const commentId = addTestComment(store, {
mode: 'default',
text: 'An example comment',
focused: true,
});
addTestReply(store, commentId, {
mode: 'deleted',
text: 'An example reply',
});
return <RenderCommentsForStorybook store={store} />;
}

Wyświetl plik

@ -0,0 +1,346 @@
/* eslint-disable react/prop-types */
import React from 'react';
import type { Store } from '../../state';
import type { Comment, CommentReply, Author } from '../../state/comments';
import { updateReply, deleteReply } from '../../actions/comments';
import type { TranslatableStrings } from '../../main';
import { CommentHeader } from '../CommentHeader';
export async function saveCommentReply(
comment: Comment,
reply: CommentReply,
store: Store
) {
store.dispatch(
updateReply(comment.localId, reply.localId, {
mode: 'saving',
})
);
try {
store.dispatch(
updateReply(comment.localId, reply.localId, {
mode: 'default',
text: reply.newText,
author: reply.author,
})
);
} catch (err) {
/* eslint-disable-next-line no-console */
console.error(err);
store.dispatch(
updateReply(comment.localId, reply.localId, {
mode: 'save_error',
})
);
}
}
async function deleteCommentReply(
comment: Comment,
reply: CommentReply,
store: Store
) {
store.dispatch(
updateReply(comment.localId, reply.localId, {
mode: 'deleting',
})
);
try {
store.dispatch(deleteReply(comment.localId, reply.localId));
} catch (err) {
store.dispatch(
updateReply(comment.localId, reply.localId, {
mode: 'delete_error',
})
);
}
}
export interface CommentReplyProps {
comment: Comment;
reply: CommentReply;
store: Store;
user: Author | null;
strings: TranslatableStrings;
}
export default class CommentReplyComponent extends React.Component<CommentReplyProps> {
renderEditing(): React.ReactFragment {
const { comment, reply, store, strings } = this.props;
const onChangeText = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
e.preventDefault();
store.dispatch(
updateReply(comment.localId, reply.localId, {
newText: e.target.value,
})
);
};
const onSave = async (e: React.FormEvent) => {
e.preventDefault();
await saveCommentReply(comment, reply, store);
};
const onCancel = (e: React.MouseEvent) => {
e.preventDefault();
store.dispatch(
updateReply(comment.localId, reply.localId, {
mode: 'default',
newText: reply.text,
})
);
};
return (
<>
<CommentHeader commentReply={reply} store={store} strings={strings} />
<form onSubmit={onSave}>
<textarea
className="comment-reply__input"
value={reply.newText}
onChange={onChangeText}
style={{ resize: 'none' }}
/>
<div className="comment-reply__actions">
<button
type="submit"
className="comment-reply__button comment-reply__button--primary"
>
{strings.SAVE}
</button>
<button
type="button"
className="comment-reply__button"
onClick={onCancel}
>
{strings.CANCEL}
</button>
</div>
</form>
</>
);
}
renderSaving(): React.ReactFragment {
const { reply, store, strings } = this.props;
return (
<>
<CommentHeader commentReply={reply} store={store} strings={strings} />
<p className="comment-reply__text">{reply.text}</p>
<div className="comment-reply__progress">{strings.SAVING}</div>
</>
);
}
renderSaveError(): React.ReactFragment {
const { comment, reply, store, strings } = this.props;
const onClickRetry = async (e: React.MouseEvent) => {
e.preventDefault();
await saveCommentReply(comment, reply, store);
};
return (
<>
<CommentHeader commentReply={reply} store={store} strings={strings} />
<p className="comment-reply__text">{reply.text}</p>
<div className="comment-reply__error">
{strings.SAVE_ERROR}
<button
type="button"
className="comment-reply__button"
onClick={onClickRetry}
>
{strings.RETRY}
</button>
</div>
</>
);
}
renderDeleteConfirm(): React.ReactFragment {
const { comment, reply, store, strings } = this.props;
const onClickDelete = async (e: React.MouseEvent) => {
e.preventDefault();
await deleteCommentReply(comment, reply, store);
};
const onClickCancel = (e: React.MouseEvent) => {
e.preventDefault();
store.dispatch(
updateReply(comment.localId, reply.localId, {
mode: 'default',
})
);
};
return (
<>
<CommentHeader commentReply={reply} store={store} strings={strings} />
<p className="comment-reply__text">{reply.text}</p>
<div className="comment-reply__confirm-delete">
{strings.CONFIRM_DELETE_COMMENT}
<button
type="button"
className="comment-reply__button comment-reply__button--red"
onClick={onClickDelete}
>
{strings.DELETE}
</button>
<button
type="button"
className="comment-reply__button"
onClick={onClickCancel}
>
{strings.CANCEL}
</button>
</div>
</>
);
}
renderDeleting(): React.ReactFragment {
const { reply, store, strings } = this.props;
return (
<>
<CommentHeader commentReply={reply} store={store} strings={strings} />
<p className="comment-reply__text">{reply.text}</p>
<div className="comment-reply__progress">{strings.DELETING}</div>
</>
);
}
renderDeleteError(): React.ReactFragment {
const { comment, reply, store, strings } = this.props;
const onClickRetry = async (e: React.MouseEvent) => {
e.preventDefault();
await deleteCommentReply(comment, reply, store);
};
const onClickCancel = async (e: React.MouseEvent) => {
e.preventDefault();
store.dispatch(
updateReply(comment.localId, reply.localId, {
mode: 'default',
})
);
};
return (
<>
<CommentHeader commentReply={reply} store={store} strings={strings} />
<p className="comment-reply__text">{reply.text}</p>
<div className="comment-reply__error">
{strings.DELETE_ERROR}
<button
type="button"
className="comment-reply__button"
onClick={onClickCancel}
>
{strings.CANCEL}
</button>
<button
type="button"
className="comment-reply__button"
onClick={onClickRetry}
>
{strings.RETRY}
</button>
</div>
</>
);
}
renderDefault(): React.ReactFragment {
const { comment, reply, store, strings } = this.props;
// Show edit/delete buttons if this reply was authored by the current user
let onEdit;
let onDelete;
if (reply.author === null || this.props.user && this.props.user.id === reply.author.id) {
onEdit = () => {
store.dispatch(
updateReply(comment.localId, reply.localId, {
mode: 'editing',
newText: reply.text,
})
);
};
onDelete = () => {
store.dispatch(
updateReply(comment.localId, reply.localId, {
mode: 'delete_confirm',
})
);
};
}
return (
<>
<CommentHeader commentReply={reply} store={store} strings={strings} onEdit={onEdit} onDelete={onDelete} />
<p className="comment-reply__text">{reply.text}</p>
</>
);
}
render() {
let inner: React.ReactFragment;
switch (this.props.reply.mode) {
case 'editing':
inner = this.renderEditing();
break;
case 'saving':
inner = this.renderSaving();
break;
case 'save_error':
inner = this.renderSaveError();
break;
case 'delete_confirm':
inner = this.renderDeleteConfirm();
break;
case 'deleting':
inner = this.renderDeleting();
break;
case 'delete_error':
inner = this.renderDeleteError();
break;
default:
inner = this.renderDefault();
break;
}
return (
<li
key={this.props.reply.localId}
className={`comment-reply comment-reply--mode-${this.props.reply.mode}`}
data-reply-id={this.props.reply.localId}
>
{inner}
</li>
);
}
}

Wyświetl plik

@ -0,0 +1,89 @@
.comment-reply {
margin-top: 40px;
pointer-events: auto;
position: relative;
&__text {
color: $color-box-text;
font-size: 0.8em;
margin-top: 32px;
margin-bottom: 0;
&--mode-deleting {
color: $color-grey-1;
}
}
&--mode-deleting &__avatar {
opacity: 0.5;
}
&--mode-deleting &__text {
color: $color-grey-3;
}
&__button {
@include button;
}
&__actions,
&__confirm-delete,
&__progress,
&__error {
&::after {
display: block;
content: '';
clear: both;
}
}
&__actions &__button,
&__confirm-delete &__button {
margin-top: 5px;
margin-right: 5px;
}
&__confirm-delete,
&__error {
color: $color-box-text;
font-weight: bold;
font-size: 0.8em;
button {
margin-left: 10px;
/* stylelint-disable-next-line declaration-no-important */
margin-right: 0 !important;
}
}
&__error {
color: $color-white;
background-color: $color-red-dark;
border-radius: 3px;
padding: 5px;
padding-left: 10px;
height: 26px;
line-height: 26px;
vertical-align: middle;
button {
height: 26px;
float: right;
margin-left: 5px;
color: $color-white;
background-color: $color-red-very-dark;
border-color: $color-red-very-dark;
padding: 2px;
padding-left: 10px;
padding-right: 10px;
font-size: 0.65em;
font-weight: bold;
}
}
&__progress {
margin-top: 20px;
font-weight: bold;
font-size: 0.8em;
}
}

Wyświetl plik

@ -0,0 +1,198 @@
import React from 'react';
import type { Comment, CommentReply } from '../../state/comments';
interface PrefixedHiddenInputProps {
prefix: string;
value: number | string | null;
fieldName: string;
}
function PrefixedHiddenInput({
prefix,
value,
fieldName,
}: PrefixedHiddenInputProps) {
return (
<input
type="hidden"
name={`${prefix}-${fieldName}`}
value={value === null ? '' : value}
id={`id_${prefix}-${fieldName}`}
/>
);
}
export interface CommentReplyFormComponentProps {
reply: CommentReply;
prefix: string;
formNumber: number;
}
export function CommentReplyFormComponent({
reply,
formNumber,
prefix,
}: CommentReplyFormComponentProps) {
const fullPrefix = `${prefix}-${formNumber}`;
return (
<fieldset>
<PrefixedHiddenInput
fieldName="DELETE"
value={reply.deleted ? 1 : ''}
prefix={fullPrefix}
/>
<PrefixedHiddenInput
fieldName="id"
value={reply.remoteId}
prefix={fullPrefix}
/>
<PrefixedHiddenInput
fieldName="text"
value={reply.text}
prefix={fullPrefix}
/>
</fieldset>
);
}
export interface CommentReplyFormSetProps {
replies: CommentReply[];
prefix: string;
remoteReplyCount: number;
}
export function CommentReplyFormSetComponent({
replies,
prefix,
remoteReplyCount,
}: CommentReplyFormSetProps) {
const fullPrefix = `${prefix}-replies`;
const commentForms = replies.map((reply, formNumber) => (
<CommentReplyFormComponent
key={reply.localId}
formNumber={formNumber}
reply={reply}
prefix={fullPrefix}
/>
));
return (
<>
<PrefixedHiddenInput
fieldName="TOTAL_FORMS"
value={replies.length}
prefix={fullPrefix}
/>
<PrefixedHiddenInput
fieldName="INITIAL_FORMS"
value={remoteReplyCount}
prefix={fullPrefix}
/>
<PrefixedHiddenInput
fieldName="MIN_NUM_FORMS"
value="0"
prefix={fullPrefix}
/>
<PrefixedHiddenInput
fieldName="MAX_NUM_FORMS"
value=""
prefix={fullPrefix}
/>
{commentForms}
</>
);
}
export interface CommentFormProps {
comment: Comment;
formNumber: number;
prefix: string;
}
export function CommentFormComponent({
comment,
formNumber,
prefix,
}: CommentFormProps) {
const fullPrefix = `${prefix}-${formNumber}`;
return (
<fieldset>
<PrefixedHiddenInput
fieldName="DELETE"
value={comment.deleted ? 1 : ''}
prefix={fullPrefix}
/>
<PrefixedHiddenInput
fieldName="id"
value={comment.remoteId}
prefix={fullPrefix}
/>
<PrefixedHiddenInput
fieldName="contentpath"
value={comment.contentpath}
prefix={fullPrefix}
/>
<PrefixedHiddenInput
fieldName="text"
value={comment.text}
prefix={fullPrefix}
/>
<PrefixedHiddenInput
fieldName="position"
value={comment.position}
prefix={fullPrefix}
/>
<CommentReplyFormSetComponent
replies={Array.from(comment.replies.values())}
prefix={fullPrefix}
remoteReplyCount={comment.remoteReplyCount}
/>
</fieldset>
);
}
export interface CommentFormSetProps {
comments: Comment[];
remoteCommentCount: number;
}
export function CommentFormSetComponent({
comments,
remoteCommentCount,
}: CommentFormSetProps) {
const prefix = 'comments';
const commentForms = comments.map((comment, formNumber) => (
<CommentFormComponent
key={comment.localId}
comment={comment}
formNumber={formNumber}
prefix={prefix}
/>
));
return (
<>
<PrefixedHiddenInput
fieldName="TOTAL_FORMS"
value={comments.length}
prefix={prefix}
/>
<PrefixedHiddenInput
fieldName="INITIAL_FORMS"
value={remoteCommentCount}
prefix={prefix}
/>
<PrefixedHiddenInput
fieldName="MIN_NUM_FORMS"
value="0"
prefix={prefix}
/>
<PrefixedHiddenInput fieldName="MAX_NUM_FORMS" value="" prefix={prefix} />
{commentForms}
</>
);
}

Wyświetl plik

@ -0,0 +1,35 @@
import React from 'react';
import { createStore } from 'redux';
import { Store, reducer } from '../../state';
import { Styling } from '../../utils/storybook';
import TopBarComponent from './index';
import { defaultStrings } from '../../main';
export default { title: 'TopBar' };
function RenderTopBarForStorybook({ store }: { store: Store }) {
const [state, setState] = React.useState(store.getState());
store.subscribe(() => {
setState(store.getState());
});
return (
<>
<Styling />
<TopBarComponent
store={store}
strings={defaultStrings}
{...state.settings}
/>
</>
);
}
export function topBar() {
const store: Store = createStore(reducer);
return <RenderTopBarForStorybook store={store} />;
}

Wyświetl plik

@ -0,0 +1,42 @@
import React from 'react';
import type { Store } from '../../state';
import { updateGlobalSettings } from '../../actions/settings';
import Checkbox from '../widgets/Checkbox';
import type { TranslatableStrings } from '../../main';
export interface TopBarProps {
commentsEnabled: boolean;
store: Store;
strings: TranslatableStrings;
}
export default function TopBarComponent({
commentsEnabled,
store,
strings,
}: TopBarProps) {
const onChangeCommentsEnabled = (checked: boolean) => {
store.dispatch(
updateGlobalSettings({
commentsEnabled: checked,
})
);
};
return (
<div className="comments-topbar">
<ul className="comments-topbar__settings">
<li>
<Checkbox
id="show-comments"
label={strings.SHOW_COMMENTS}
onChange={onChangeCommentsEnabled}
checked={commentsEnabled}
/>
</li>
</ul>
</div>
);
}

Wyświetl plik

@ -0,0 +1,21 @@
.comments-topbar {
width: 100%;
height: 50px;
line-height: 50px;
background-color: $color-box-background;
color: $color-white;
padding: 5px;
font-family: 'Open Sans', sans-serif;
position: sticky;
top: 0;
&__settings {
list-style-type: none;
margin: 0;
li {
float: left;
margin-left: 20px;
}
}
}

Wyświetl plik

@ -0,0 +1,23 @@
import React from 'react';
import { Styling } from '../../../utils/storybook';
import Checkbox from '.';
export default { title: 'Checkbox' };
export function checkbox() {
const [checked, setChecked] = React.useState(false);
return (
<>
<Styling />
<Checkbox
id="id"
label="Checkbox"
checked={checked}
onChange={setChecked}
/>
</>
);
}

Wyświetl plik

@ -0,0 +1,44 @@
import React from 'react';
export interface CheckboxProps {
id: string;
label: string;
checked: boolean;
disabled?: boolean;
onChange?: (checked: boolean) => any;
}
const Checkbox = (props: CheckboxProps) => {
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (props.onChange) {
props.onChange(e.target.checked);
}
};
if (props.disabled) {
return (
<div className="checkbox">
<input
id={props.id}
type="checkbox"
checked={props.checked}
disabled={true}
/>
<label htmlFor={props.id}>{props.label}</label>
</div>
);
}
return (
<div className="checkbox">
<input
id={props.id}
type="checkbox"
onChange={onChange}
checked={props.checked}
/>
<label htmlFor={props.id}>{props.label}</label>
</div>
);
};
export default Checkbox;

Wyświetl plik

@ -0,0 +1,62 @@
$checkbox-size: 26px;
$checkbox-check-size: $checkbox-size * 0.75;
$checkbox-check-padding: ($checkbox-size - $checkbox-check-size) / 2;
.checkbox {
display: inline-block;
line-height: $checkbox-size;
position: relative;
label {
display: inline-block;
text-align: right;
padding-right: $checkbox-size + 10px;
font-size: 0.8em;
font-weight: bold;
padding-top: 8px;
cursor: pointer;
}
input[type='checkbox'] {
opacity: 0;
}
label::before {
content: '';
display: inline-block;
height: $checkbox-size;
width: $checkbox-size;
margin-left: 5px;
background-color: $color-white;
border: 1px solid #333;
border-radius: 3px;
position: absolute;
top: 3px;
right: 0;
}
label::after {
content: '';
display: inline-block;
background-color: $color-box-background;
mask-image: url('./icons/check-solid.svg');
mask-repeat: no-repeat;
width: $checkbox-check-size;
height: 100%;
position: absolute;
right: $checkbox-check-padding * 1.25;
top: $checkbox-check-padding * 2.5;
}
input[type='checkbox'] + label::after {
visibility: hidden;
}
input[type='checkbox']:checked + label::after {
visibility: visible;
}
input[type='checkbox']:focus + label::before {
@include focus-outline;
}
}

Wyświetl plik

@ -0,0 +1,33 @@
import React from 'react';
import { Styling } from '../../../utils/storybook';
import Radio from '.';
export default { title: 'Radio' };
export function radio() {
const [value, setValue] = React.useState<string | null>(null);
return (
<>
<Styling />
<Radio
id="option-1"
name="test"
value="option-1"
label="Option one"
checked={value === 'option-1'}
onChange={setValue}
/>
<Radio
id="option-2"
name="test"
value="option-2"
label="Option two"
checked={value === 'option-2'}
onChange={setValue}
/>
</>
);
}

Wyświetl plik

@ -0,0 +1,48 @@
import React from 'react';
export interface RadioProps {
id: string;
name: string;
value: string;
label: string;
checked: boolean;
disabled?: boolean;
onChange?: (value: string) => any;
}
const Radio = (props: RadioProps) => {
const onChange = () => {
if (props.onChange) {
props.onChange(props.value);
}
};
if (props.disabled) {
return (
<div className="radio">
<input
id={props.id}
type="radio"
name={props.name}
checked={props.checked}
disabled={true}
/>
<label htmlFor={props.id}>{props.label}</label>
</div>
);
}
return (
<div className="radio">
<input
id={props.id}
type="radio"
name={props.name}
onChange={onChange}
checked={props.checked}
/>
<label htmlFor={props.id}>{props.label}</label>
</div>
);
};
export default Radio;

Wyświetl plik

@ -0,0 +1,60 @@
$radio-size: 26px;
$radio-dot-size: $radio-size * 0.4;
.radio {
display: block;
line-height: $radio-size;
position: relative;
label {
display: inline-block;
text-align: right;
padding-left: 10px;
font-size: 0.8em;
font-weight: bold;
cursor: pointer;
}
input[type='radio'] {
opacity: 0;
}
label::before {
content: '';
display: inline-block;
height: $radio-size;
width: $radio-size;
background-color: $color-white;
border: 2px solid #333;
border-radius: 500rem;
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
}
label::after {
content: '';
display: inline-block;
background-color: $color-box-background;
border: 0;
border-radius: 500rem;
width: $radio-dot-size;
height: $radio-dot-size;
position: absolute;
left: $radio-size / 2 - $radio-dot-size / 2;
top: $radio-size / 2 - $radio-dot-size / 2;
}
input[type='radio'] + label::after {
visibility: hidden;
}
input[type='radio']:checked + label::after {
visibility: visible;
}
input[type='radio']:focus + label::before {
@include focus-outline;
}
}

Wyświetl plik

@ -0,0 +1,2 @@
declare module 'react-shadow';
declare module '*.scss';

Wyświetl plik

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="check" class="svg-inline--fa fa-check fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z"></path></svg>

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 504 B

Wyświetl plik

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="ellipsis-v" class="svg-inline--fa fa-ellipsis-v fa-w-6" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path fill="currentColor" d="M96 184c39.8 0 72 32.2 72 72s-32.2 72-72 72-72-32.2-72-72 32.2-72 72-72zM24 80c0 39.8 32.2 72 72 72s72-32.2 72-72S135.8 8 96 8 24 40.2 24 80zm0 352c0 39.8 32.2 72 72 72s72-32.2 72-72-32.2-72-72-72-72 32.2-72 72z"></path></svg>

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 450 B

Wyświetl plik

@ -0,0 +1,158 @@
$color-teal: #007d7e;
$color-teal-darker: darken(adjust-hue($color-teal, 1), 4);
$color-teal-dark: darken(adjust-hue($color-teal, 1), 7);
$color-blue: #71b2d4;
$color-red: #cd3238;
$color-red-dark: #b4191f;
$color-red-very-dark: #901419;
$color-orange: #e9b04d;
$color-orange-dark: #bb5b03;
$color-green: #189370;
$color-green-dark: #157b57;
$color-salmon: #f37e77;
$color-salmon-light: #fcf2f2;
$color-white: #fff;
$color-black: #000;
// darker to lighter
$color-grey-1: darken($color-white, 80);
$color-grey-2: darken($color-white, 70);
$color-grey-25: #626262;
$color-grey-3: darken($color-white, 15);
$color-grey-4: darken($color-white, 10);
$color-grey-5: darken($color-white, 2);
$color-grey-7: #f2f2f2;
$color-grey-8: #fbfbfb;
$color-fieldset-hover: $color-grey-5;
$color-input-border: $color-grey-4;
$color-input-focus: lighten(desaturate($color-teal, 40), 72);
$color-input-focus-border: lighten(saturate($color-teal, 12), 10);
$color-input-error-bg: lighten(saturate($color-red, 28), 45);
$color-link: $color-teal-darker;
$color-link-hover: $color-teal-dark;
// The focus outline color is defined without reusing a named color variable
// because it shouldnt be reused for anything else in the UI.
$color-focus-outline: #ffbf47;
$color-text-base: darken($color-white, 85);
$color-text-input: darken($color-white, 90);
// Color states
$color-state-live: #59b524;
$color-state-draft: #808080;
$color-state-absent: #ff8f11;
$color-state-live-draft: #43b1b0;
$color-box-background: $color-white;
$color-box-border: $color-grey-3;
$color-box-border-focused: $color-grey-2;
$color-box-text: $color-black;
$color-textarea-background: $color-grey-8;
$color-textarea-background-focused: #f2fcfc;
$color-textarea-border: #ccc;
$color-textarea-border-focused: #00b0b1;
$color-textarea-placeholder-text: $color-grey-2;
@mixin focus-outline {
outline: $color-focus-outline solid 3px;
}
@mixin box {
background-color: $color-box-background;
border: 1px solid $color-box-border;
padding: 25px;
font-size: 16px;
border-radius: 10px;
color: $color-box-text;
&--focused {
border: 1px solid $color-box-border-focused;
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.1);
}
textarea {
font-family: 'Open Sans', sans-serif;
font-size: 0.8em;
margin: 0;
margin-top: 5px;
padding: 10px;
width: 100%;
background-color: $color-textarea-background;
border: 2px solid $color-textarea-border;
box-sizing: border-box;
border-radius: 7px;
-moz-outline-radius: 10px;
color: $color-box-text;
&::placeholder {
font-style: italic;
color: $color-textarea-placeholder-text;
opacity: 1;
}
&:focus {
background-color: $color-textarea-background-focused;
border-color: $color-textarea-border-focused;
outline: unset;
}
}
*:focus {
@include focus-outline;
}
}
@mixin button {
background-color: inherit;
border: 1px solid $color-grey-3;
border-radius: 5px;
-moz-outline-radius: 7px;
color: $color-teal;
cursor: pointer;
text-transform: uppercase;
font-family: inherit;
font-size: 16px;
font-weight: bold;
height: 35px;
padding-left: 10px;
padding-right: 10px;
&--primary {
color: $color-white;
border: 1px solid $color-teal;
background-color: $color-teal;
}
&--red {
color: $color-white;
border: 1px solid $color-red-very-dark;
background-color: $color-red-very-dark;
}
// Disable Firefox's focus styling becase we add our own.
&::-moz-focus-inner {
border: 0;
}
}
.comments-list {
height: 100%;
width: 400px;
position: absolute;
top: 30px;
right: 30px;
z-index: 1000;
font-family: 'Open Sans', sans-serif;
pointer-events: none;
}
@import 'components/CommentHeader/style';
@import 'components/Comment/style';
@import 'components/CommentReply/style';
@import 'components/TopBar/style';
@import 'components/widgets/Checkbox/style';
@import 'components/widgets/Radio/style';

Wyświetl plik

@ -0,0 +1,341 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createStore } from 'redux';
import type { Annotation } from './utils/annotation';
import { LayoutController } from './utils/layout';
import { getOrDefault } from './utils/maps';
import { getNextCommentId, getNextReplyId } from './utils/sequences';
import { Store, reducer } from './state';
import { Comment, newCommentReply, newComment, Author } from './state/comments';
import {
addComment,
addReply,
setFocusedComment,
updateComment,
commentActionFunctions
} from './actions/comments';
import { updateGlobalSettings } from './actions/settings';
import {
selectComments,
selectCommentsForContentPathFactory,
selectCommentFactory,
selectEnabled,
selectFocused
} from './selectors';
import CommentComponent from './components/Comment';
import { CommentFormSetComponent } from './components/Form';
import TopBarComponent from './components/TopBar';
import { INITIAL_STATE as INITIAL_SETTINGS_STATE } from './state/settings';
export interface TranslatableStrings {
COMMENT: string;
SAVE: string;
SAVING: string;
CANCEL: string;
DELETE: string;
DELETING: string;
SHOW_COMMENTS: string;
EDIT: string;
REPLY: string;
RESOLVE: string;
RETRY: string;
DELETE_ERROR: string;
CONFIRM_DELETE_COMMENT: string;
SAVE_ERROR: string;
MORE_ACTIONS: string;
}
export const defaultStrings = {
COMMENT: 'Comment',
SAVE: 'Save',
SAVING: 'Saving...',
CANCEL: 'Cancel',
DELETE: 'Delete',
DELETING: 'Deleting...',
SHOW_COMMENTS: 'Show comments',
EDIT: 'Edit',
REPLY: 'Reply',
RESOLVE: 'Resolve',
RETRY: 'Retry',
DELETE_ERROR: 'Delete error',
CONFIRM_DELETE_COMMENT: 'Are you sure?',
SAVE_ERROR: 'Save error',
MORE_ACTIONS: 'More actions',
};
/* eslint-disable camelcase */
// This is done as this is serialized pretty directly from the Django model
export interface InitialCommentReply {
pk: number;
user: any;
text: string;
created_at: string;
updated_at: string;
}
export interface InitialComment {
pk: number;
user: any;
text: string;
created_at: string;
updated_at: string;
replies: InitialCommentReply[];
contentpath: string;
position: string;
}
/* eslint-enable */
// eslint-disable-next-line camelcase
const getAuthor = (authors: Map<string, {name: string, avatar_url: string}>, id: any): Author => {
const authorData = getOrDefault(authors, String(id), { name: '', avatar_url: '' });
return {
id,
name: authorData.name,
avatarUrl: authorData.avatar_url,
};
};
function renderCommentsUi(
store: Store,
layout: LayoutController,
comments: Comment[],
strings: TranslatableStrings
): React.ReactElement {
const state = store.getState();
const { commentsEnabled, user } = state.settings;
const focusedComment = state.comments.focusedComment;
let commentsToRender = comments;
if (!commentsEnabled || !user) {
commentsToRender = [];
}
// Hide all resolved/deleted comments
commentsToRender = commentsToRender.filter(({ deleted }) => !deleted);
const commentsRendered = commentsToRender.map((comment) => (
<CommentComponent
key={comment.localId}
store={store}
layout={layout}
user={user}
comment={comment}
isFocused={comment.localId === focusedComment}
strings={strings}
/>
));
return (
<>
<TopBarComponent
commentsEnabled={commentsEnabled}
store={store}
strings={strings}
/>
<ol className="comments-list">{commentsRendered}</ol>
</>
);
/* eslint-enable react/no-danger */
}
export class CommentApp {
store: Store;
layout: LayoutController;
utils = {
selectCommentsForContentPathFactory,
selectCommentFactory
}
selectors = {
selectComments,
selectEnabled,
selectFocused
}
actions = commentActionFunctions;
constructor() {
this.store = createStore(reducer, {
settings: INITIAL_SETTINGS_STATE
});
this.layout = new LayoutController();
}
// eslint-disable-next-line camelcase
setUser(userId: any, authors: Map<string, {name: string, avatar_url: string}>) {
this.store.dispatch(
updateGlobalSettings({
user: getAuthor(authors, userId)
})
);
}
updateAnnotation(
annotation: Annotation,
commentId: number
) {
this.attachAnnotationLayout(annotation, commentId);
this.store.dispatch(
updateComment(
commentId,
{ annotation: annotation }
)
);
}
attachAnnotationLayout(
annotation: Annotation,
commentId: number
) {
// Attach an annotation to an existing comment in the layout
// const layout engine know the annotation so it would position the comment correctly
this.layout.setCommentAnnotation(commentId, annotation);
}
makeComment(annotation: Annotation, contentpath: string, position = '') {
const commentId = getNextCommentId();
this.attachAnnotationLayout(annotation, commentId);
// Create the comment
this.store.dispatch(
addComment(
newComment(
contentpath,
position,
commentId,
annotation,
this.store.getState().settings.user,
Date.now(),
{
mode: 'creating',
}
)
)
);
// Focus and pin the comment
this.store.dispatch(setFocusedComment(commentId, { updatePinnedComment: true }));
return commentId;
}
renderApp(
element: HTMLElement,
outputElement: HTMLElement,
userId: any,
initialComments: InitialComment[],
// eslint-disable-next-line camelcase
authors: Map<string, {name: string, avatar_url: string}>,
translationStrings: TranslatableStrings | null
) {
let pinnedComment: number | null = null;
this.setUser(userId, authors);
const strings = translationStrings || defaultStrings;
// Check if there is "comment" query parameter.
// If this is set, the user has clicked on a "View on frontend" link of an
// individual comment. We should focus this comment and scroll to it
const urlParams = new URLSearchParams(window.location.search);
let initialFocusedCommentId: number | null = null;
const commentParams = urlParams.get('comment');
if (commentParams) {
initialFocusedCommentId = parseInt(commentParams, 10);
}
const render = () => {
const state = this.store.getState();
const commentList: Comment[] = Array.from(state.comments.comments.values());
ReactDOM.render(
<CommentFormSetComponent
comments={commentList}
remoteCommentCount={state.comments.remoteCommentCount}
/>,
outputElement
);
// Check if the pinned comment has changed
if (state.comments.pinnedComment !== pinnedComment) {
// Tell layout controller about the pinned comment
// so it is moved alongside its annotation
this.layout.setPinnedComment(state.comments.pinnedComment);
pinnedComment = state.comments.pinnedComment;
}
ReactDOM.render(
renderCommentsUi(this.store, this.layout, commentList, strings),
element,
() => {
// Render again if layout has changed (eg, a comment was added, deleted or resized)
// This will just update the "top" style attributes in the comments to get them to move
if (this.layout.refresh()) {
ReactDOM.render(
renderCommentsUi(this.store, this.layout, commentList, strings),
element
);
}
}
);
};
// Fetch existing comments
for (const comment of initialComments) {
const commentId = getNextCommentId();
// Create comment
this.store.dispatch(
addComment(
newComment(
comment.contentpath,
comment.position,
commentId,
null,
getAuthor(authors, comment.user),
Date.parse(comment.created_at),
{
remoteId: comment.pk,
text: comment.text,
}
)
)
);
// Create replies
for (const reply of comment.replies) {
this.store.dispatch(
addReply(
commentId,
newCommentReply(
getNextReplyId(),
getAuthor(authors, reply.user),
Date.parse(reply.created_at),
{ remoteId: reply.pk, text: reply.text }
)
)
);
}
// If this is the initial focused comment. Focus and pin it
// TODO: Scroll to this comment
if (initialFocusedCommentId && comment.pk === initialFocusedCommentId) {
this.store.dispatch(setFocusedComment(commentId, { updatePinnedComment: true }));
}
}
render();
this.store.subscribe(render);
// Unfocus when document body is clicked
document.body.addEventListener('click', (e) => {
if (e.target instanceof HTMLElement) {
// ignore if click target is a comment or an annotation
if (!e.target.closest('#comments, [data-annotation]')) {
// Running store.dispatch directly here seems to prevent the event from being handled anywhere else
setTimeout(() => {
this.store.dispatch(setFocusedComment(null, { updatePinnedComment: true }));
}, 1);
}
}
});
}
}
export function initCommentApp() {
return new CommentApp();
}

Wyświetl plik

@ -0,0 +1,29 @@
import { createSelector } from 'reselect';
import type { Comment } from '../state/comments';
import type { State } from '../state';
export const selectComments = (state: State) => state.comments.comments;
export const selectFocused = (state: State) => state.comments.focusedComment;
export function selectCommentsForContentPathFactory(contentpath: string) {
return createSelector(selectComments, (comments) =>
[...comments.values()].filter(
(comment: Comment) =>
comment.contentpath === contentpath && !comment.deleted
)
);
}
export function selectCommentFactory(localId: number) {
return createSelector(selectComments, (comments) => {
const comment = comments.get(localId);
if (comment !== undefined && comment.deleted) {
return undefined;
}
return comment;
}
);
}
export const selectEnabled = (state: State) => state.settings.commentsEnabled;

Wyświetl plik

@ -0,0 +1,25 @@
import { basicCommentsState } from '../__fixtures__/state';
import { INITIAL_STATE } from '../state/settings';
import { selectCommentsForContentPathFactory } from './index';
test('Select comments for contentpath', () => {
// test that the selectCommentsForContentPathFactory can generate selectors for the two
// contentpaths in basicCommentsState
const state = {
comments: basicCommentsState,
settings: INITIAL_STATE,
};
const testContentPathSelector = selectCommentsForContentPathFactory(
'test_contentpath'
);
const testContentPathSelector2 = selectCommentsForContentPathFactory(
'test_contentpath_2'
);
const selectedComments = testContentPathSelector(state);
expect(selectedComments.length).toBe(1);
expect(selectedComments[0].contentpath).toBe('test_contentpath');
const otherSelectedComments = testContentPathSelector2(state);
expect(otherSelectedComments.length).toBe(1);
expect(otherSelectedComments[0].contentpath).toBe('test_contentpath_2');
});

Wyświetl plik

@ -0,0 +1,214 @@
import { basicCommentsState } from '../__fixtures__/state';
import {
Comment,
CommentReply,
CommentReplyUpdate,
CommentUpdate,
reducer,
} from './comments';
import { createStore } from 'redux';
import * as actions from '../actions/comments';
test('Initial comments state empty', () => {
const state = createStore(reducer).getState();
expect(state.focusedComment).toBe(null);
expect(state.pinnedComment).toBe(null);
expect(state.comments.size).toBe(0);
expect(state.remoteCommentCount).toBe(0);
});
test('New comment added to state', () => {
const newComment: Comment = {
contentpath: 'test_contentpath',
position: '',
localId: 5,
annotation: null,
remoteId: null,
mode: 'default',
deleted: false,
author: { id: 1, name: 'test user' },
date: 0,
text: 'new comment',
newReply: '',
newText: '',
remoteReplyCount: 0,
replies: new Map(),
};
const commentAction = actions.addComment(newComment);
const newState = reducer(basicCommentsState, commentAction);
expect(newState.comments.get(newComment.localId)).toBe(newComment);
expect(newState.remoteCommentCount).toBe(
basicCommentsState.remoteCommentCount
);
});
test('Remote comment added to state', () => {
const newComment: Comment = {
contentpath: 'test_contentpath',
position: '',
localId: 5,
annotation: null,
remoteId: 10,
mode: 'default',
deleted: false,
author: { id: 1, name: 'test user' },
date: 0,
text: 'new comment',
newReply: '',
newText: '',
remoteReplyCount: 0,
replies: new Map(),
};
const commentAction = actions.addComment(newComment);
const newState = reducer(basicCommentsState, commentAction);
expect(newState.comments.get(newComment.localId)).toBe(newComment);
expect(newState.remoteCommentCount).toBe(
basicCommentsState.remoteCommentCount + 1
);
});
test('Existing comment updated', () => {
const commentUpdate: CommentUpdate = {
mode: 'editing',
};
const updateAction = actions.updateComment(1, commentUpdate);
const newState = reducer(basicCommentsState, updateAction);
const comment = newState.comments.get(1);
expect(comment).toBeDefined();
if (comment) {
expect(comment.mode).toBe('editing');
}
});
test('Local comment deleted', () => {
// Test that deleting a comment without a remoteId removes it from the state entirely
const deleteAction = actions.deleteComment(4);
const newState = reducer(basicCommentsState, deleteAction);
expect(newState.comments.has(4)).toBe(false);
});
test('Remote comment deleted', () => {
// Test that deleting a comment without a remoteId does not remove it from the state, but marks it as deleted
const deleteAction = actions.deleteComment(1);
const newState = reducer(basicCommentsState, deleteAction);
const comment = newState.comments.get(1);
expect(comment).toBeDefined();
if (comment) {
expect(comment.deleted).toBe(true);
}
expect(newState.focusedComment).toBe(null);
expect(newState.pinnedComment).toBe(null);
expect(newState.remoteCommentCount).toBe(
basicCommentsState.remoteCommentCount
);
});
test('Comment focused', () => {
const focusAction = actions.setFocusedComment(4);
const newState = reducer(basicCommentsState, focusAction);
expect(newState.focusedComment).toBe(4);
});
test('Invalid comment not focused', () => {
const focusAction = actions.setFocusedComment(9000, { updatePinnedComment: true });
const newState = reducer(basicCommentsState, focusAction);
expect(newState.focusedComment).toBe(basicCommentsState.focusedComment);
expect(newState.pinnedComment).toBe(basicCommentsState.pinnedComment);
});
test('Reply added', () => {
const reply: CommentReply = {
localId: 10,
remoteId: null,
mode: 'default',
author: { id: 1, name: 'test user' },
date: 0,
text: 'a new reply',
newText: '',
deleted: false,
};
const addAction = actions.addReply(1, reply);
const newState = reducer(basicCommentsState, addAction);
const comment = newState.comments.get(1);
expect(comment).toBeDefined();
if (comment) {
const stateReply = comment.replies.get(10);
expect(stateReply).toBeDefined();
if (stateReply) {
expect(stateReply).toBe(reply);
}
}
});
test('Remote reply added', () => {
const reply: CommentReply = {
localId: 10,
remoteId: 1,
mode: 'default',
author: { id: 1, name: 'test user' },
date: 0,
text: 'a new reply',
newText: '',
deleted: false,
};
const addAction = actions.addReply(1, reply);
const newState = reducer(basicCommentsState, addAction);
const originalComment = basicCommentsState.comments.get(1);
const comment = newState.comments.get(1);
expect(comment).toBeDefined();
if (comment) {
const stateReply = comment.replies.get(reply.localId);
expect(stateReply).toBeDefined();
expect(stateReply).toBe(reply);
if (originalComment) {
expect(comment.remoteReplyCount).toBe(originalComment.remoteReplyCount + 1);
}
}
});
test('Reply updated', () => {
const replyUpdate: CommentReplyUpdate = {
mode: 'editing',
};
const updateAction = actions.updateReply(1, 2, replyUpdate);
const newState = reducer(basicCommentsState, updateAction);
const comment = newState.comments.get(1);
expect(comment).toBeDefined();
if (comment) {
const reply = comment.replies.get(2);
expect(reply).toBeDefined();
if (reply) {
expect(reply.mode).toBe('editing');
}
}
});
test('Local reply deleted', () => {
// Test that the delete action deletes a reply that hasn't yet been saved to the db from the state entirely
const deleteAction = actions.deleteReply(1, 3);
const newState = reducer(basicCommentsState, deleteAction);
const comment = newState.comments.get(1);
expect(comment).toBeDefined();
if (comment) {
expect(comment.replies.has(3)).toBe(false);
}
});
test('Remote reply deleted', () => {
// Test that the delete action deletes a reply that has been saved to the db by marking it as deleted instead
const deleteAction = actions.deleteReply(1, 2);
const newState = reducer(basicCommentsState, deleteAction);
const comment = newState.comments.get(1);
const originalComment = basicCommentsState.comments.get(1);
expect(comment).toBeDefined();
expect(originalComment).toBeDefined();
if (comment && originalComment) {
expect(comment.remoteReplyCount).toBe(originalComment.remoteReplyCount);
const reply = comment.replies.get(2);
expect(reply).toBeDefined();
if (reply) {
expect(reply.deleted).toBe(true);
}
}
});

Wyświetl plik

@ -0,0 +1,242 @@
import type { Annotation } from '../utils/annotation';
import * as actions from '../actions/comments';
import { update } from './utils';
import produce, { enableMapSet, enableES5 } from 'immer';
enableES5();
enableMapSet();
export interface Author {
id: any;
name: string;
avatarUrl?: string;
}
export type CommentReplyMode =
| 'default'
| 'editing'
| 'saving'
| 'delete_confirm'
| 'deleting'
| 'deleted'
| 'save_error'
| 'delete_error';
export interface CommentReply {
localId: number;
remoteId: number | null;
mode: CommentReplyMode;
author: Author | null;
date: number;
text: string;
newText: string;
deleted: boolean;
}
export interface NewReplyOptions {
remoteId?: number | null;
mode?: CommentReplyMode;
text?: string;
}
export function newCommentReply(
localId: number,
author: Author | null,
date: number,
{
remoteId = null,
mode = 'default',
text = '',
}: NewReplyOptions
): CommentReply {
return {
localId,
remoteId,
mode,
author,
date,
text,
newText: '',
deleted: false,
};
}
export type CommentReplyUpdate = Partial<CommentReply>;
export type CommentMode =
| 'default'
| 'creating'
| 'editing'
| 'saving'
| 'delete_confirm'
| 'deleting'
| 'deleted'
| 'save_error'
| 'delete_error';
export interface Comment {
contentpath: string;
localId: number;
annotation: Annotation | null;
position: string;
remoteId: number | null;
mode: CommentMode;
deleted: boolean;
author: Author | null;
date: number;
text: string;
replies: Map<number, CommentReply>;
newReply: string;
newText: string;
remoteReplyCount: number;
}
export interface NewCommentOptions {
remoteId?: number | null;
mode?: CommentMode;
text?: string;
replies?: Map<number, CommentReply>;
}
export function newComment(
contentpath: string,
position: string,
localId: number,
annotation: Annotation | null,
author: Author | null,
date: number,
{
remoteId = null,
mode = 'default',
text = '',
replies = new Map(),
}: NewCommentOptions
): Comment {
return {
contentpath,
position,
localId,
annotation,
remoteId,
mode,
author,
date,
text,
replies,
newReply: '',
newText: '',
deleted: false,
remoteReplyCount: Array.from(replies.values()).reduce(
(n, reply) => (reply.remoteId !== null ? n + 1 : n),
0
),
};
}
export type CommentUpdate = Partial<Comment>;
export interface CommentsState {
comments: Map<number, Comment>;
focusedComment: number | null;
pinnedComment: number | null;
// This is redundant, but stored for efficiency as it will change only as the app adds its loaded comments
remoteCommentCount: number;
}
const INITIAL_STATE: CommentsState = {
comments: new Map(),
focusedComment: null,
pinnedComment: null,
remoteCommentCount: 0,
};
export const reducer = produce((draft: CommentsState, action: actions.Action) => {
/* eslint-disable no-param-reassign */
switch (action.type) {
case actions.ADD_COMMENT: {
draft.comments.set(action.comment.localId, action.comment);
if (action.comment.remoteId) {
draft.remoteCommentCount += 1;
}
break;
}
case actions.UPDATE_COMMENT: {
const comment = draft.comments.get(action.commentId);
if (comment) {
update(comment, action.update);
}
break;
}
case actions.DELETE_COMMENT: {
const comment = draft.comments.get(action.commentId);
if (!comment) {
break;
} else if (!comment.remoteId) {
// If the comment doesn't exist in the database, there's no need to keep it around locally
draft.comments.delete(action.commentId);
} else {
comment.deleted = true;
}
// Unset focusedComment if the focused comment is the one being deleted
if (draft.focusedComment === action.commentId) {
draft.focusedComment = null;
}
if (draft.pinnedComment === action.commentId) {
draft.pinnedComment = null;
}
break;
}
case actions.SET_FOCUSED_COMMENT: {
if ((action.commentId === null) || (draft.comments.has(action.commentId))) {
draft.focusedComment = action.commentId;
if (action.updatePinnedComment) {
draft.pinnedComment = action.commentId;
}
}
break;
}
case actions.ADD_REPLY: {
const comment = draft.comments.get(action.commentId);
if (!comment) {
break;
}
if (action.reply.remoteId) {
comment.remoteReplyCount += 1;
}
comment.replies.set(action.reply.localId, action.reply);
break;
}
case actions.UPDATE_REPLY: {
const comment = draft.comments.get(action.commentId);
if (!comment) {
break;
}
const reply = comment.replies.get(action.replyId);
if (!reply) {
break;
}
update(reply, action.update);
break;
}
case actions.DELETE_REPLY: {
const comment = draft.comments.get(action.commentId);
if (!comment) {
break;
}
const reply = comment.replies.get(action.replyId);
if (!reply) {
break;
}
if (!reply.remoteId) {
// The reply doesn't exist in the database, so we don't need to store it locally
comment.replies.delete(reply.localId);
} else {
reply.deleted = true;
}
break;
}
default:
break;
}
}, INITIAL_STATE);

Wyświetl plik

@ -0,0 +1,14 @@
import { combineReducers, Store as reduxStore } from 'redux';
import { reducer as commentsReducer } from './comments';
import { reducer as settingsReducer } from './settings';
import type { Action } from '../actions';
export type State = ReturnType<typeof reducer>;
export const reducer = combineReducers({
comments: commentsReducer,
settings: settingsReducer,
});
export type Store = reduxStore<State, Action>;

Wyświetl plik

@ -0,0 +1,29 @@
import * as actions from '../actions/settings';
import type { Author } from './comments';
import { update } from './utils';
import produce from 'immer';
export interface SettingsState {
user: Author | null;
commentsEnabled: boolean;
showResolvedComments: boolean;
}
export type SettingsStateUpdate = Partial<SettingsState>;
// Reducer with initial state
export const INITIAL_STATE: SettingsState = {
user: null,
commentsEnabled: true,
showResolvedComments: false,
};
export const reducer = produce((draft: SettingsState, action: actions.Action) => {
switch (action.type) {
case actions.UPDATE_GLOBAL_SETTINGS:
update(draft, action.update);
break;
default:
break;
}
}, INITIAL_STATE);

Wyświetl plik

@ -0,0 +1,3 @@
export function update<T>(base: T, updatePartial: Partial<T>): T {
return Object.assign(base, updatePartial);
}

Wyświetl plik

@ -0,0 +1,3 @@
export interface Annotation {
getDesiredPosition(focused: boolean): number;
}

Wyświetl plik

@ -0,0 +1,187 @@
import type { Annotation } from './annotation';
import { getOrDefault } from './maps';
const GAP = 20.0; // Gap between comments in pixels
const TOP_MARGIN = 100.0; // Spacing from the top to the first comment in pixels
const OFFSET = -50; // How many pixels from the annotation position should the comments be placed?
export class LayoutController {
commentElements: Map<number, HTMLElement> = new Map();
commentAnnotations: Map<number, Annotation> = new Map();
commentDesiredPositions: Map<number, number> = new Map();
commentHeights: Map<number, number> = new Map();
pinnedComment: number | null = null;
commentCalculatedPositions: Map<number, number> = new Map();
isDirty = false;
setCommentElement(commentId: number, element: HTMLElement | null) {
if (element !== null) {
this.commentElements.set(commentId, element);
} else {
this.commentElements.delete(commentId);
}
this.isDirty = true;
}
setCommentAnnotation(commentId: number, annotation: Annotation) {
this.commentAnnotations.set(commentId, annotation);
this.updateDesiredPosition(commentId);
this.isDirty = true;
}
setCommentHeight(commentId: number, height: number) {
if (this.commentHeights.get(commentId) !== height) {
this.commentHeights.set(commentId, height);
this.isDirty = true;
}
}
setPinnedComment(commentId: number | null) {
this.pinnedComment = commentId;
this.isDirty = true;
}
updateDesiredPosition(commentId: number) {
const annotation = this.commentAnnotations.get(commentId);
if (!annotation) {
return;
}
this.commentDesiredPositions.set(
commentId,
annotation.getDesiredPosition(commentId === this.pinnedComment) + OFFSET
);
}
refreshDesiredPositions() {
this.commentAnnotations.forEach((_, commentId) =>
this.updateDesiredPosition(commentId)
);
}
refresh() {
const oldDesiredPositions = new Map(this.commentDesiredPositions);
this.refreshDesiredPositions();
// It's not great to be recalculating all positions so regularly, but Wagtail's FE widgets
// aren't very constrained so could change layout in any number of ways. If we have a stable FE
// widget framework in the future, this could be used to trigger the position refresh more
// intelligently, or alternatively once comments is incorporated into the page form, a
// MutationObserver could potentially track most types of changes.
if (this.commentDesiredPositions !== oldDesiredPositions) {
this.isDirty = true;
}
if (!this.isDirty) {
return false;
}
interface Block {
position: number;
height: number;
comments: number[];
containsPinnedComment: boolean;
pinnedCommentPosition: number;
}
// Build list of blocks (starting with one for each comment)
let blocks: Block[] = Array.from(this.commentElements.keys()).map(
(commentId) => ({
position: getOrDefault(this.commentDesiredPositions, commentId, 0),
height: getOrDefault(this.commentHeights, commentId, 0),
comments: [commentId],
containsPinnedComment:
this.pinnedComment !== null && commentId === this.pinnedComment,
pinnedCommentPosition: 0,
})
);
// Sort blocks
blocks.sort(
(block, comparisonBlock) => block.position - comparisonBlock.position
);
// Resolve overlapping blocks
let overlaps = true;
while (overlaps) {
overlaps = false;
const newBlocks: Block[] = [];
let previousBlock: Block | null = null;
const pinnedCommentPosition = this.pinnedComment ?
this.commentDesiredPositions.get(this.pinnedComment) : undefined;
for (const block of blocks) {
if (previousBlock) {
if (
previousBlock.position + previousBlock.height + GAP >
block.position
) {
overlaps = true;
// Merge the blocks
previousBlock.comments.push(...block.comments);
if (block.containsPinnedComment) {
previousBlock.containsPinnedComment = true;
previousBlock.pinnedCommentPosition =
block.pinnedCommentPosition + previousBlock.height;
}
previousBlock.height += block.height;
// Make sure comments don't disappear off the top of the page
// But only if a comment isn't focused
if (
!this.pinnedComment &&
previousBlock.position < TOP_MARGIN + OFFSET
) {
previousBlock.position =
TOP_MARGIN + previousBlock.height - OFFSET;
}
// If this block contains the focused comment, position it so
// the focused comment is in it's desired position
if (
pinnedCommentPosition &&
previousBlock.containsPinnedComment
) {
previousBlock.position =
pinnedCommentPosition -
previousBlock.pinnedCommentPosition;
}
continue;
}
}
newBlocks.push(block);
previousBlock = block;
}
blocks = newBlocks;
}
// Write positions
blocks.forEach((block) => {
let currentPosition = block.position;
block.comments.forEach((commentId) => {
this.commentCalculatedPositions.set(commentId, currentPosition);
const height = this.commentHeights.get(commentId);
if (height) {
currentPosition += height + GAP;
}
});
});
this.isDirty = false;
return true;
}
getCommentPosition(commentId: number) {
if (this.commentCalculatedPositions.has(commentId)) {
return this.commentCalculatedPositions.get(commentId);
}
return this.commentDesiredPositions.get(commentId);
}
}

Wyświetl plik

@ -0,0 +1,7 @@
export function getOrDefault<K, V>(map: Map<K, V>, key: K, defaultValue: V) {
const value = map.get(key);
if (typeof value === 'undefined') {
return defaultValue;
}
return value;
}

Wyświetl plik

@ -0,0 +1,10 @@
let nextCommentId = 1;
let nextReplyId = 1;
export function getNextCommentId() {
return nextCommentId++;
}
export function getNextReplyId() {
return nextReplyId++;
}

Wyświetl plik

@ -0,0 +1,130 @@
import React from 'react';
import { Store } from '../state';
import {
addComment,
setFocusedComment,
addReply,
} from '../actions/comments';
import {
Author,
Comment,
NewCommentOptions,
newComment,
newCommentReply,
NewReplyOptions,
} from '../state/comments';
import { LayoutController } from '../utils/layout';
import { getNextCommentId } from './sequences';
import { defaultStrings } from '../main';
import CommentComponent from '../components/Comment/index';
export function RenderCommentsForStorybook({
store,
author,
}: {
store: Store;
author?: Author;
}) {
const [state, setState] = React.useState(store.getState());
store.subscribe(() => {
setState(store.getState());
});
const layout = new LayoutController();
const commentsToRender: Comment[] = Array.from(
state.comments.comments.values()
);
const commentsRendered = commentsToRender.map((comment) => (
<CommentComponent
key={comment.localId}
store={store}
layout={layout}
user={
author || {
id: 1,
name: 'Admin',
avatarUrl: 'https://gravatar.com/avatar/e31ec811942afbf7b9ce0ac5affe426f?s=200&d=robohash&r=x',
}
}
comment={comment}
isFocused={comment.localId === state.comments.focusedComment}
strings={defaultStrings}
/>
));
return (
<ol className="comments-list">{commentsRendered}</ol>
);
}
interface AddTestCommentOptions extends NewCommentOptions {
focused?: boolean;
author?: Author;
}
export function addTestComment(
store: Store,
options: AddTestCommentOptions
): number {
const commentId = getNextCommentId();
const addCommentOptions = options;
const author = options.author || {
id: 1,
name: 'Admin',
avatarUrl: 'https://gravatar.com/avatar/e31ec811942afbf7b9ce0ac5affe426f?s=200&d=robohash&r=x',
};
// We must have a remoteId unless the comment is being created
if (options.mode !== 'creating' && options.remoteId === undefined) {
addCommentOptions.remoteId = commentId;
}
// Comment must be focused if the mode is anything other than default
if (options.mode !== 'default' && options.focused === undefined) {
addCommentOptions.focused = true;
}
store.dispatch(
addComment(
newComment('test', '', commentId, null, author, Date.now(), addCommentOptions)
)
);
if (options.focused) {
store.dispatch(setFocusedComment(commentId, { updatePinnedComment: true }));
}
return commentId;
}
interface AddTestReplyOptions extends NewReplyOptions {
focused?: boolean;
author?: Author;
}
export function addTestReply(
store: Store,
commentId: number,
options: AddTestReplyOptions
) {
const addReplyOptions = options;
const author = options.author || {
id: 1,
name: 'Admin',
avatarUrl: 'https://gravatar.com/avatar/e31ec811942afbf7b9ce0ac5affe426f?s=200&d=robohash&r=x',
};
if (!options.remoteId) {
addReplyOptions.remoteId = 1;
}
store.dispatch(
addReply(commentId, newCommentReply(1, author, Date.now(), addReplyOptions))
);
}

Wyświetl plik

@ -1,6 +1,6 @@
import type { CommentApp } from 'wagtail-comment-frontend';
import type { Annotation } from 'wagtail-comment-frontend/src/utils/annotation';
import type { Comment } from 'wagtail-comment-frontend/src/state/comments';
import type { CommentApp } from '../../CommentApp/main';
import type { Annotation } from '../../CommentApp/utils/annotation';
import type { Comment } from '../../CommentApp/state/comments';
import {
DraftailEditor,
ToolbarButton,

13
package-lock.json wygenerowano
Wyświetl plik

@ -7311,6 +7311,11 @@
"integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==",
"dev": true
},
"immer": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.1.tgz",
"integrity": "sha512-7CCw1DSgr8kKYXTYOI1qMM/f5qxT5vIVMeGLDCDX8CSxsggr1Sjdoha4OhsP0AZ1UvWbyZlILHvLjaynuu02Mg=="
},
"immutable": {
"version": "3.7.6",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz",
@ -16129,14 +16134,6 @@
"xml-name-validator": "^3.0.0"
}
},
"wagtail-comment-frontend": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/wagtail-comment-frontend/-/wagtail-comment-frontend-0.0.6.tgz",
"integrity": "sha512-qdkNNK9YtEP/JOZmB2TMCag/Fq4u66OZKRsgs7zGJozbShtT+a+CMtO7lpLjRnmez6+33KCzdkaOJ1dfTXeLsg==",
"requires": {
"reselect": "^4.0.0"
}
},
"walker": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz",

Wyświetl plik

@ -95,6 +95,7 @@
"draftjs-filters": "^2.5.0",
"element-closest": "^2.0.2",
"focus-trap-react": "^3.1.0",
"immer": "^9.0.1",
"postcss-calc": "^7.0.5",
"prop-types": "^15.6.2",
"react": "^16.14.0",
@ -103,9 +104,9 @@
"react-transition-group": "^1.1.3",
"redux": "^4.0.0",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"telepath-unpack": "^0.0.3",
"uuid": "^8.3.2",
"wagtail-comment-frontend": "0.0.6",
"whatwg-fetch": "^2.0.3"
},
"scripts": {

Wyświetl plik

@ -7,7 +7,8 @@
"noUnusedParameters": true,
"strictNullChecks": true,
"esModuleInterop": true,
"allowJs": true
"allowJs": true,
"downlevelIteration": true
},
"files": [
"client/src/index.ts",