diff --git a/entry_types/scrolled/package/src/review/NewThreadForm.js b/entry_types/scrolled/package/src/review/NewThreadForm.js
index bec4dba95e..fbebb9cd5d 100644
--- a/entry_types/scrolled/package/src/review/NewThreadForm.js
+++ b/entry_types/scrolled/package/src/review/NewThreadForm.js
@@ -1,26 +1,24 @@
-import React, {useCallback, useState} from 'react';
-
-import {useSectionPermaIdOfSubject} from 'pageflow-scrolled/entryState';
+import React, {useCallback} from 'react';
import {useI18n} from '../frontend/i18n';
-import {postCreateCommentThreadMessage} from './postMessage';
-import {useSubjectQuote} from './subjectQuote';
+import {useCreateCommentThread} from './ReviewStateProvider';
+import {useDraftedBody} from './useDraftedBody';
import {autoGrow, autoResize} from './autoGrow';
import {isSubmitShortcut} from './submitShortcut';
import SendIcon from './images/send.svg';
+import SpinnerIcon from '../frontend/icons/spinner.svg';
import styles from './NewThreadForm.module.css';
export function NewThreadForm({subjectType, subjectId, subjectRange, onSubmit}) {
const {t} = useI18n({locale: 'ui'});
- const [body, setBody] = useState('');
- const hasText = body.trim().length > 0;
- const sectionPermaId = useSectionPermaIdOfSubject({subjectType, subjectId});
+ const {body, setBody, submitting} = useDraftedBody({subjectType, subjectId});
+ const hasText = body.trim().length > 0;
- // Recorded now since the commented text can change afterwards, leaving the
- // comment without the wording it referred to.
- const quote = useSubjectQuote({subjectType, subjectId, subjectRange});
+ const createCommentThread = useCreateCommentThread({
+ subjectType, subjectId, subjectRange
+ });
// preventScroll keeps focus from yanking the page to the top before the
// portaled popover has been positioned by floating-ui.
@@ -47,33 +45,33 @@ export function NewThreadForm({subjectType, subjectId, subjectRange, onSubmit})
}
function createThread() {
- if (!hasText) return;
+ if (!hasText || submitting) return;
- postCreateCommentThreadMessage({
- subjectType, subjectId, subjectRange, sectionPermaId, body, quote
- });
- setBody('');
+ createCommentThread(body);
if (onSubmit) onSubmit();
}
return (
-
diff --git a/entry_types/scrolled/package/src/review/NewThreadForm.module.css b/entry_types/scrolled/package/src/review/NewThreadForm.module.css
index 0bd6f904b7..401412b9f5 100644
--- a/entry_types/scrolled/package/src/review/NewThreadForm.module.css
+++ b/entry_types/scrolled/package/src/review/NewThreadForm.module.css
@@ -42,6 +42,10 @@
font-size: space(3);
}
+.input:disabled {
+ color: var(--ui-on-surface-color-light);
+}
+
.submitButton {
display: flex;
align-items: center;
@@ -55,3 +59,23 @@
padding: space(1.5) space(3);
cursor: pointer;
}
+
+.submitButton:disabled {
+ cursor: default;
+ opacity: 0.65;
+}
+
+.spinner {
+ width: 16px;
+ height: 16px;
+ animation: spin 0.75s linear infinite;
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/entry_types/scrolled/package/src/review/ReplyForm.js b/entry_types/scrolled/package/src/review/ReplyForm.js
index d7683aa525..9df398c286 100644
--- a/entry_types/scrolled/package/src/review/ReplyForm.js
+++ b/entry_types/scrolled/package/src/review/ReplyForm.js
@@ -1,22 +1,24 @@
-import React, {useState} from 'react';
+import React from 'react';
import {useI18n} from '../frontend/i18n';
-import {postCreateCommentMessage} from './postMessage';
-import {useSubjectQuote} from './subjectQuote';
+import {useCreateComment} from './ReviewStateProvider';
+import {useDraftedBody} from './useDraftedBody';
import {autoGrow, autoResize} from './autoGrow';
import {isSubmitShortcut} from './submitShortcut';
import SendIcon from './images/send.svg';
+import SpinnerIcon from '../frontend/icons/spinner.svg';
import styles from './ReplyForm.module.css';
export function ReplyForm({threadId, subjectType, subjectId, subjectRange}) {
const {t} = useI18n({locale: 'ui'});
- const [body, setBody] = useState('');
+
+ const {body, setBody, submitting} = useDraftedBody({threadId});
const hasText = body.trim().length > 0;
- // Each reply records the wording it responds to, so a thread spanning
- // several edits keeps every comment next to its own version of the text.
- const quote = useSubjectQuote({subjectType, subjectId, subjectRange});
+ const createComment = useCreateComment({
+ threadId, subjectType, subjectId, subjectRange
+ });
function handleChange(e) {
setBody(e.target.value);
@@ -36,30 +38,33 @@ export function ReplyForm({threadId, subjectType, subjectId, subjectRange}) {
}
function createReply() {
- if (!hasText) return;
+ if (!hasText || submitting) return;
- postCreateCommentMessage({threadId, body, quote});
- setBody('');
+ createComment(body);
}
return (
-
diff --git a/entry_types/scrolled/package/src/review/ReplyForm.module.css b/entry_types/scrolled/package/src/review/ReplyForm.module.css
index 9231bfaffd..a686b51c1c 100644
--- a/entry_types/scrolled/package/src/review/ReplyForm.module.css
+++ b/entry_types/scrolled/package/src/review/ReplyForm.module.css
@@ -25,11 +25,14 @@
.actions {
display: flex;
align-items: center;
- justify-content: space-between;
+ justify-content: flex-end;
margin-top: space(1.5);
}
+/* Pushed left by its own margin rather than by space-between, so that the
+ send button stays in place while submitting hides the hint. */
.hint {
+ margin-right: auto;
color: var(--ui-on-surface-color-light);
font-size: space(3);
}
@@ -47,3 +50,27 @@
padding: space(1.5) space(3);
cursor: pointer;
}
+
+.input:disabled {
+ color: var(--ui-on-surface-color-light);
+}
+
+.submitButton:disabled {
+ cursor: default;
+ opacity: 0.65;
+}
+
+.spinner {
+ width: 16px;
+ height: 16px;
+ animation: spin 0.75s linear infinite;
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/entry_types/scrolled/package/src/review/ReviewMessageHandler.js b/entry_types/scrolled/package/src/review/ReviewMessageHandler.js
index 81cd63eead..ff12e54a51 100644
--- a/entry_types/scrolled/package/src/review/ReviewMessageHandler.js
+++ b/entry_types/scrolled/package/src/review/ReviewMessageHandler.js
@@ -1,6 +1,7 @@
import {
postReviewStateResetMessage,
- postReviewStateThreadChangeMessage
+ postReviewStateThreadChangeMessage,
+ postReviewStateDraftsChangeMessage
} from './postMessage';
export const ReviewMessageHandler = {
@@ -20,6 +21,9 @@ export const ReviewMessageHandler = {
else if (type === 'UPDATE_THREAD') {
session.updateThread(payload);
}
+ else if (type === 'SET_COMMENT_DRAFT') {
+ session.setDraft(payload);
+ }
}
function handleReset(state) {
@@ -30,15 +34,21 @@ export const ReviewMessageHandler = {
postReviewStateThreadChangeMessage(targetWindow, thread);
}
+ function handleDraftsChange(drafts) {
+ postReviewStateDraftsChangeMessage(targetWindow, drafts);
+ }
+
window.addEventListener('message', handleMessage);
session.on('reset', handleReset);
session.on('change:thread', handleThreadChange);
+ session.on('change:drafts', handleDraftsChange);
return {
dispose() {
window.removeEventListener('message', handleMessage);
session.off('reset', handleReset);
session.off('change:thread', handleThreadChange);
+ session.off('change:drafts', handleDraftsChange);
}
};
}
diff --git a/entry_types/scrolled/package/src/review/ReviewStateProvider.js b/entry_types/scrolled/package/src/review/ReviewStateProvider.js
index 7969ab0ffc..194eed6539 100644
--- a/entry_types/scrolled/package/src/review/ReviewStateProvider.js
+++ b/entry_types/scrolled/package/src/review/ReviewStateProvider.js
@@ -1,11 +1,23 @@
-import React, {createContext, useContext, useEffect, useMemo, useReducer} from 'react';
+import React, {
+ createContext, useCallback, useContext, useEffect, useMemo, useReducer
+} from 'react';
+
+import {useSectionPermaIdOfSubject} from 'pageflow-scrolled/entryState';
+
+import {
+ postCreateCommentMessage,
+ postCreateCommentThreadMessage,
+ postSetCommentDraftMessage
+} from './postMessage';
+import {useSubjectQuote} from './subjectQuote';
const ReviewStateContext = createContext(null);
+const CommentDraftsContext = createContext(null);
-export function ReviewStateProvider({initialState, children}) {
+export function ReviewStateProvider({initialState, initialDrafts, setDraft, children}) {
const [state, dispatch] = useReducer(
reducer,
- initialState,
+ {initialState, initialDrafts},
initState
);
@@ -21,24 +33,99 @@ export function ReviewStateProvider({initialState, children}) {
else if (type === 'REVIEW_STATE_THREAD_CHANGE') {
dispatch({type: 'UPSERT_THREAD', payload});
}
+ else if (type === 'REVIEW_STATE_DRAFTS_CHANGE') {
+ dispatch({type: 'SET_DRAFTS', payload});
+ }
}
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
+ // Drafts live in their own context so that storing one does not
+ // invalidate the thread state consumers derive their lists from.
const value = useMemo(() => ({
currentUser: state.currentUser,
commentThreads: Object.values(state.threads)
- }), [state]);
+ }), [state.currentUser, state.threads]);
+
+ // Marking the draft pending rather than waiting for the session to
+ // report it back keeps the form in one state from the submit onwards:
+ // shown, disabled and holding the text.
+ const createThread = useCallback(payload => {
+ const {subjectType, subjectId, body} = payload;
+
+ dispatch({type: 'SET_DRAFT', payload: {
+ subjectType, subjectId, body, pending: true
+ }});
+
+ postCreateCommentThreadMessage(payload);
+ }, []);
+
+ const createComment = useCallback(payload => {
+ const {threadId, body} = payload;
+
+ dispatch({type: 'SET_DRAFT', payload: {threadId, body, pending: true}});
+
+ postCreateCommentMessage(payload);
+ }, []);
+
+ // The editor sidebar passes a direct write instead of relying on the
+ // message: its ReviewMessageHandler is disposed together with the view,
+ // which would drop the draft stored while the view is going away.
+ const draftsValue = useMemo(() => ({
+ drafts: state.drafts,
+ setDraft: setDraft || postSetCommentDraftMessage,
+ createThread,
+ createComment
+ }), [state.drafts, setDraft, createThread, createComment]);
return (
- {children}
+
+ {children}
+
);
}
+export function useCommentDraft({threadId, subjectType, subjectId}) {
+ const {drafts, setDraft} = useContext(CommentDraftsContext);
+
+ return [
+ drafts[draftKey({threadId, subjectType, subjectId})],
+ useCallback(
+ body => setDraft(threadId ? {threadId, body} : {subjectType, subjectId, body}),
+ [setDraft, threadId, subjectType, subjectId]
+ )
+ ];
+}
+
+export function useCreateCommentThread({subjectType, subjectId, subjectRange}) {
+ const {createThread} = useContext(CommentDraftsContext);
+
+ const sectionPermaId = useSectionPermaIdOfSubject({subjectType, subjectId});
+
+ // Recorded now since the commented text can change afterwards, leaving the
+ // comment without the wording it referred to.
+ const quote = useSubjectQuote({subjectType, subjectId, subjectRange});
+
+ return useCallback(body => createThread({
+ subjectType, subjectId, subjectRange, sectionPermaId, body, quote
+ }), [createThread, subjectType, subjectId, subjectRange, sectionPermaId, quote]);
+}
+
+export function useCreateComment({threadId, subjectType, subjectId, subjectRange}) {
+ const {createComment} = useContext(CommentDraftsContext);
+
+ // Each reply records the wording it responds to, so a thread spanning
+ // several edits keeps every comment next to its own version of the text.
+ const quote = useSubjectQuote({subjectType, subjectId, subjectRange});
+
+ return useCallback(body => createComment({threadId, body, quote}),
+ [createComment, threadId, quote]);
+}
+
export function useCommentThread(threadId) {
const context = useContext(ReviewStateContext);
return context?.commentThreads.find(t => t.id === threadId);
@@ -69,13 +156,14 @@ export function matchesResolution(thread, resolution) {
(resolution === 'resolved' && !!thread.resolvedAt);
}
-function initState(initialState) {
+function initState({initialState, initialDrafts}) {
+ const empty = {currentUser: null, threads: {}, drafts: initialDrafts || {}};
+
if (initialState) {
- return reducer({currentUser: null, threads: {}},
- {type: 'RESET', payload: initialState});
+ return reducer(empty, {type: 'RESET', payload: initialState});
}
- return {currentUser: null, threads: {}};
+ return empty;
}
function reducer(state, action) {
@@ -86,11 +174,26 @@ function reducer(state, action) {
threads[thread.id] = thread;
});
+ // Refetching threads leaves unsent drafts untouched.
return {
+ ...state,
currentUser: action.payload.currentUser,
threads
};
}
+ case 'SET_DRAFTS':
+ return {
+ ...state,
+ drafts: action.payload
+ };
+ case 'SET_DRAFT':
+ return {
+ ...state,
+ drafts: {
+ ...state.drafts,
+ [draftKey(action.payload)]: action.payload
+ }
+ };
case 'UPSERT_THREAD':
return {
...state,
@@ -103,3 +206,8 @@ function reducer(state, action) {
return state;
}
}
+
+// Replies are drafted per thread, new threads per subject.
+function draftKey({threadId, subjectType, subjectId}) {
+ return threadId ? `Thread:${threadId}` : `${subjectType}:${subjectId}`;
+}
diff --git a/entry_types/scrolled/package/src/review/Thread.js b/entry_types/scrolled/package/src/review/Thread.js
index 3cc07c48bc..214a3bc165 100644
--- a/entry_types/scrolled/package/src/review/Thread.js
+++ b/entry_types/scrolled/package/src/review/Thread.js
@@ -5,6 +5,7 @@ import {useI18n} from '../frontend/i18n';
import {AvatarStack} from './Avatar';
import {Comment} from './Comment';
import {ReplyForm} from './ReplyForm';
+import {useCommentDraft} from './ReviewStateProvider';
import {useSubjectQuote} from './subjectQuote';
import {commentsWithOutdatedQuote} from './outdatedQuotes';
import {useScrollHighlightedThreadIntoView} from './scrollHighlightedThreadIntoView';
@@ -14,10 +15,16 @@ import ResolveIcon from './images/resolve.svg';
import UnresolveIcon from './images/unresolve.svg';
import styles from './Thread.module.css';
-export function Thread({thread, collapsed, onToggle, onResolve, onClick, highlighted, interactive = true}) {
+export function Thread({thread, collapsed: collapsedProp, onToggle, onResolve, onClick, highlighted, interactive = true}) {
const {t} = useI18n({locale: 'ui'});
const firstComment = thread.comments[0];
const replies = thread.comments.slice(1);
+
+ // Collapsing hides the reply form, which would leave a drafted reply
+ // out of reach.
+ const [replyDraft] = useCommentDraft({threadId: thread.id});
+ const collapsed = collapsedProp && !replyDraft;
+
const repliesCollapsed = collapsed && replies.length > 0;
const currentQuote = useSubjectQuote(thread);
diff --git a/entry_types/scrolled/package/src/review/ThreadList.js b/entry_types/scrolled/package/src/review/ThreadList.js
index 6702bb99e9..e99b41e87f 100644
--- a/entry_types/scrolled/package/src/review/ThreadList.js
+++ b/entry_types/scrolled/package/src/review/ThreadList.js
@@ -2,6 +2,7 @@ import React, {useMemo, useState} from 'react';
import classNames from 'classnames';
import {useI18n} from '../frontend/i18n';
+import {useCommentDraft} from './ReviewStateProvider';
import {useLocatedCommentThreadsForSubject} from './useLocatedCommentThreadsForSubject';
import {Thread} from './Thread';
import {NewThreadForm} from './NewThreadForm';
@@ -35,16 +36,23 @@ export function ThreadList({subjectType, subjectId, subjectRange, filter, highli
highlightedThreadId.includes(thread.id) :
thread.id === highlightedThreadId;
+ const noThreads = activeThreads.length === 0 && resolvedThreads.length === 0;
+
+ const [draft] = useCommentDraft({subjectType, subjectId});
const [expandedThreadId, setExpandedThreadId] = useState(null);
- const [formToggled, setFormToggled] = useState(null);
const [resolvedToggled, setResolvedToggled] = useState(null);
+ const [formToggled, setFormToggled] = useState(
+ showNewFormProp !== undefined ? showNewFormProp :
+ expandResolved ? noThreads : activeThreads.length === 0
+ );
const showResolved = resolvedToggled !== null ? resolvedToggled : !!expandResolved;
- const noThreads = activeThreads.length === 0 && resolvedThreads.length === 0;
- const showNewForm = formToggled !== null ? formToggled :
- showNewFormProp !== undefined ? showNewFormProp :
- expandResolved ? noThreads : activeThreads.length === 0;
+ // An unsent draft reopens the form and keeps it open while the thread is
+ // being created. Callers passing showNewForm={false} suppress the form
+ // entirely: the editor sidebar lists compose new threads in a view of
+ // their own.
+ const showNewForm = showNewFormProp !== false && (!!draft || formToggled);
function toggleThread(threadId) {
setExpandedThreadId(expandedThreadId === threadId ? null : threadId);
diff --git a/entry_types/scrolled/package/src/review/postMessage.js b/entry_types/scrolled/package/src/review/postMessage.js
index 8f1ccef233..ac6d8b45bb 100644
--- a/entry_types/scrolled/package/src/review/postMessage.js
+++ b/entry_types/scrolled/package/src/review/postMessage.js
@@ -17,6 +17,13 @@ export function postCreateCommentMessage({threadId, body, quote}) {
);
}
+export function postSetCommentDraftMessage(draft) {
+ window.top.postMessage(
+ {type: 'SET_COMMENT_DRAFT', payload: draft},
+ window.location.origin
+ );
+}
+
export function postUpdateThreadMessage({threadId, resolved}) {
window.top.postMessage(
{type: 'UPDATE_THREAD', payload: {threadId, resolved}},
@@ -37,3 +44,10 @@ export function postReviewStateThreadChangeMessage(targetWindow, thread) {
window.location.origin
);
}
+
+export function postReviewStateDraftsChangeMessage(targetWindow, drafts) {
+ targetWindow.postMessage(
+ {type: 'REVIEW_STATE_DRAFTS_CHANGE', payload: drafts},
+ window.location.origin
+ );
+}
diff --git a/entry_types/scrolled/package/src/review/useDraftedBody.js b/entry_types/scrolled/package/src/review/useDraftedBody.js
new file mode 100644
index 0000000000..d0d1534f81
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/useDraftedBody.js
@@ -0,0 +1,49 @@
+import {useEffect, useRef, useState} from 'react';
+
+import {useCommentDraft} from './ReviewStateProvider';
+
+// Storing the draft only once the form goes away is deliberate: whether a
+// draft exists decides whether the thread list keeps the form open, which
+// would otherwise make the form disappear from under a reviewer clearing
+// the text to start over.
+export function useDraftedBody(draftOf) {
+ const [draft, setDraft] = useCommentDraft(draftOf);
+
+ // Read when mounting only: echoing the stored draft back into the
+ // textarea would make its value lag behind typing.
+ const [body, setBody] = useState(() => draft?.body || '');
+ const pending = !!draft?.pending;
+
+ const latest = useRef();
+ latest.current = {body, pending, setDraft};
+
+ const wasPending = useRef(false);
+
+ useEffect(() => {
+ if (pending) {
+ wasPending.current = true;
+ }
+ else if (wasPending.current) {
+ wasPending.current = false;
+
+ // Forms that outlive their submit - the reply form stays around for
+ // the next reply - start over once the session has dropped the
+ // draft it created the comment from. A failed attempt keeps the
+ // draft, and with it the text.
+ if (!draft) setBody('');
+ }
+ }, [pending, draft]);
+
+ useEffect(() => () => {
+ const {body, pending, setDraft} = latest.current;
+
+ // The session drops the draft it created the comment from, so storing
+ // the text of a pending draft here would resurrect it. A failed
+ // attempt leaves a draft that is no longer pending.
+ if (pending) return;
+
+ setDraft(body);
+ }, []);
+
+ return {body, setBody, submitting: pending};
+}
diff --git a/package/spec/review/ReviewSession-spec.js b/package/spec/review/ReviewSession-spec.js
index 4c0383bf32..89d41f53ef 100644
--- a/package/spec/review/ReviewSession-spec.js
+++ b/package/spec/review/ReviewSession-spec.js
@@ -112,6 +112,57 @@ describe('ReviewSession', () => {
);
});
+ it('emits create:thread after createThread', async () => {
+ const request = jest.fn()
+ .mockResolvedValueOnce({
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: []
+ })
+ .mockResolvedValueOnce({
+ id: 1,
+ subjectType: 'ContentElement',
+ subjectId: 10,
+ comments: [{id: 100, body: 'Looks good!', creatorId: 42}]
+ });
+
+ const session = new ReviewSession({entryId: 5, request});
+ await session.fetch();
+
+ const listener = jest.fn();
+ session.on('create:thread', listener);
+
+ await session.createThread({
+ subjectType: 'ContentElement',
+ subjectId: 10,
+ body: 'Looks good!'
+ });
+
+ expect(listener).toHaveBeenCalledWith(
+ expect.objectContaining({id: 1, subjectType: 'ContentElement'})
+ );
+ });
+
+ it('does not emit create:thread when updating a thread', async () => {
+ const request = jest.fn()
+ .mockResolvedValueOnce({
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [{id: 1, subjectType: 'ContentElement', subjectId: 10, comments: []}]
+ })
+ .mockResolvedValueOnce({
+ id: 1, subjectType: 'ContentElement', subjectId: 10, resolved: true, comments: []
+ });
+
+ const session = new ReviewSession({entryId: 5, request});
+ await session.fetch();
+
+ const listener = jest.fn();
+ session.on('create:thread', listener);
+
+ await session.updateThread({threadId: 1, resolved: true});
+
+ expect(listener).not.toHaveBeenCalled();
+ });
+
it('includes subject_range in createThread request', async () => {
const subjectRange = {
anchor: {path: [0, 0], offset: 5},
@@ -755,4 +806,180 @@ describe('ReviewSession', () => {
expect(listener).not.toHaveBeenCalled();
});
});
+
+ describe('drafts', () => {
+ function setupSession({request} = {}) {
+ return new ReviewSession({
+ entryId: 5,
+ request: request || jest.fn(),
+ initialState: {currentUser: {id: 42, name: 'Alice'}, commentThreads: []}
+ });
+ }
+
+ it('stores a draft per subject and emits change:drafts', () => {
+ const session = setupSession();
+ const listener = jest.fn();
+ session.on('change:drafts', listener);
+
+ session.setDraft({
+ subjectType: 'ContentElement', subjectId: 10, body: 'Half a thought'
+ });
+
+ expect(session.drafts).toEqual({
+ 'ContentElement:10': {
+ subjectType: 'ContentElement',
+ subjectId: 10,
+ body: 'Half a thought',
+ pending: false
+ }
+ });
+ expect(listener).toHaveBeenCalledWith(session.drafts);
+ });
+
+ it('keeps drafts of replies separate from those of new threads', () => {
+ const session = setupSession();
+
+ session.setDraft({
+ subjectType: 'ContentElement', subjectId: 10, body: 'A new topic'
+ });
+ session.setDraft({threadId: 10, body: 'A reply'});
+
+ expect(session.drafts).toEqual({
+ 'ContentElement:10': expect.objectContaining({body: 'A new topic'}),
+ 'Thread:10': {threadId: 10, body: 'A reply', pending: false}
+ });
+ });
+
+ it('discards the draft of a reply when the body is blank', () => {
+ const session = setupSession();
+ session.setDraft({threadId: 10, body: 'A reply'});
+
+ session.setDraft({threadId: 10, body: ''});
+
+ expect(session.drafts).toEqual({});
+ });
+
+ it('shares one draft between the ranges of a subject', () => {
+ const session = setupSession();
+
+ session.setDraft({
+ subjectType: 'ContentElement', subjectId: 10, body: 'About one phrase'
+ });
+ session.setDraft({
+ subjectType: 'ContentElement', subjectId: 10, body: 'About another'
+ });
+
+ expect(Object.keys(session.drafts)).toEqual(['ContentElement:10']);
+ expect(session.drafts['ContentElement:10'].body).toEqual('About another');
+ });
+
+ it('discards the draft when the body is blank', () => {
+ const session = setupSession();
+ session.setDraft({
+ subjectType: 'ContentElement', subjectId: 10, body: 'Half a thought'
+ });
+
+ const listener = jest.fn();
+ session.on('change:drafts', listener);
+
+ session.setDraft({subjectType: 'ContentElement', subjectId: 10, body: ' '});
+
+ expect(session.drafts).toEqual({});
+ expect(listener).toHaveBeenCalledWith({});
+ });
+
+ it('marks the draft pending while the thread is being created', async () => {
+ let respond;
+ const request = jest.fn(() => new Promise(resolve => {respond = resolve}));
+ const session = setupSession({request});
+
+ const promise = session.createThread({
+ subjectType: 'ContentElement', subjectId: 10, body: 'Looks good!'
+ });
+
+ expect(session.drafts['ContentElement:10']).toEqual({
+ subjectType: 'ContentElement',
+ subjectId: 10,
+ body: 'Looks good!',
+ pending: true
+ });
+
+ respond({id: 1, subjectType: 'ContentElement', subjectId: 10, comments: []});
+ await promise;
+
+ expect(session.drafts).toEqual({});
+ });
+
+ it('drops the draft only after announcing the created thread', async () => {
+ const request = jest.fn().mockResolvedValue({
+ id: 1, subjectType: 'ContentElement', subjectId: 10, comments: []
+ });
+ const session = setupSession({request});
+
+ const events = [];
+ session.on('create:thread', () => events.push('create:thread'));
+ session.on('change:drafts', () => events.push('change:drafts'));
+
+ await session.createThread({
+ subjectType: 'ContentElement', subjectId: 10, body: 'Looks good!'
+ });
+
+ expect(events).toEqual(['change:drafts', 'create:thread', 'change:drafts']);
+ });
+
+ it('marks the draft of a reply pending while it is being created', async () => {
+ let respond;
+ const request = jest.fn(() => new Promise(resolve => {respond = resolve}));
+ const session = new ReviewSession({
+ entryId: 5,
+ request,
+ initialState: {
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [{
+ id: 7, subjectType: 'ContentElement', subjectId: 10, comments: []
+ }]
+ }
+ });
+
+ const promise = session.createComment({threadId: 7, body: 'A reply'});
+
+ expect(session.drafts['Thread:7']).toEqual({
+ threadId: 7, body: 'A reply', pending: true
+ });
+
+ respond({id: 100, body: 'A reply'});
+ await promise;
+
+ expect(session.drafts).toEqual({});
+ });
+
+ it('keeps the draft of a reply and clears pending when creating fails', async () => {
+ const error = new Error('500 Internal Server Error');
+ const session = setupSession({request: jest.fn().mockRejectedValue(error)});
+
+ await expect(session.createComment({threadId: 7, body: 'A reply'}))
+ .rejects.toThrow(error);
+
+ expect(session.drafts['Thread:7']).toEqual({
+ threadId: 7, body: 'A reply', pending: false
+ });
+ });
+
+ it('keeps the draft and clears pending when creating fails', async () => {
+ const error = new Error('500 Internal Server Error');
+ const session = setupSession({request: jest.fn().mockRejectedValue(error)});
+
+ await expect(session.createThread({
+ subjectType: 'ContentElement', subjectId: 10, body: 'Looks good!'
+ })).rejects.toThrow(error);
+
+ expect(session.drafts['ContentElement:10']).toEqual({
+ subjectType: 'ContentElement',
+ subjectId: 10,
+ body: 'Looks good!',
+ pending: false
+ });
+ expect(session.state.commentThreads).toEqual([]);
+ });
+ });
});
diff --git a/package/src/review/ReviewSession.js b/package/src/review/ReviewSession.js
index 79dd05e11e..379338e27f 100644
--- a/package/src/review/ReviewSession.js
+++ b/package/src/review/ReviewSession.js
@@ -5,15 +5,36 @@ export class ReviewSession {
this._entryId = entryId;
this._request = request;
this._state = initialState;
+ this._drafts = {};
}
get state() {
return this._state;
}
+ // Unsent comment texts, not keyed by subject range: a draft written
+ // about one phrase of a text block is offered again when commenting on
+ // another phrase of the same block.
+ get drafts() {
+ return this._drafts;
+ }
+
+ setDraft({body, ...of}) {
+ if (body.trim().length) {
+ this._writeDraft({...of, body});
+ }
+ else {
+ this._deleteDraft(of);
+ }
+ }
+
async createThread({
subjectType, subjectId, subjectRange, sectionPermaId, body, quote
}) {
+ // Written even when the form never stored a draft, so that the text
+ // outlives a failed attempt.
+ this._writeDraft({subjectType, subjectId, body, pending: true});
+
const thread = await this._request({
url: `/review/entries/${this._entryId}/comment_threads`,
method: 'POST',
@@ -26,10 +47,20 @@ export class ReviewSession {
comment: {body, ...(quote && {quote})}
}
}
+ }).catch(error => {
+ this._writeDraft({subjectType, subjectId, body, pending: false});
+ // Rethrown so that a failure still shows up as an unhandled
+ // rejection rather than only as a form that became editable again.
+ throw error;
});
this._upsertThread(thread);
this.trigger('change:thread', thread);
+ this.trigger('create:thread', thread);
+
+ // Dropped last so that the form closing does not briefly reveal a
+ // list without the thread.
+ this._deleteDraft({subjectType, subjectId});
}
async updateThread({threadId, resolved}) {
@@ -44,10 +75,15 @@ export class ReviewSession {
}
async createComment({threadId, body, quote}) {
+ this._writeDraft({threadId, body, pending: true});
+
const comment = await this._request({
url: `/review/entries/${this._entryId}/comment_threads/${threadId}/comments`,
method: 'POST',
payload: {comment: {body, ...(quote && {quote})}}
+ }).catch(error => {
+ this._writeDraft({threadId, body, pending: false});
+ throw error;
});
const thread = this._findThread(threadId);
@@ -61,6 +97,8 @@ export class ReviewSession {
this._upsertThread(updatedThread);
this.trigger('change:thread', updatedThread);
}
+
+ this._deleteDraft({threadId});
}
diffSubjectRangeUpdates(ranges) {
@@ -126,6 +164,26 @@ export class ReviewSession {
this.trigger('reset', this._state);
}
+ _writeDraft({body, pending = false, ...of}) {
+ this._drafts = {
+ ...this._drafts,
+ [draftKey(of)]: {...of, body, pending}
+ };
+
+ this.trigger('change:drafts', this._drafts);
+ }
+
+ _deleteDraft(of) {
+ const key = draftKey(of);
+
+ if (!(key in this._drafts)) return;
+
+ this._drafts = {...this._drafts};
+ delete this._drafts[key];
+
+ this.trigger('change:drafts', this._drafts);
+ }
+
_findThread(id) {
return this._state?.commentThreads.find(t => t.id === id);
}
@@ -157,6 +215,11 @@ export class ReviewSession {
Object.assign(ReviewSession.prototype, BackboneEvents);
+// Replies are drafted per thread, new threads per subject.
+function draftKey({threadId, subjectType, subjectId}) {
+ return threadId ? `Thread:${threadId}` : `${subjectType}:${subjectId}`;
+}
+
function sameRange(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}