diff --git a/pal-demo/collections/Comments.ts b/pal-demo/collections/Comments.ts index 9fc65c91be2..93028c73f09 100644 --- a/pal-demo/collections/Comments.ts +++ b/pal-demo/collections/Comments.ts @@ -12,6 +12,10 @@ const Comments: CollectionConfig = { name: 'field', type: 'text', }, + { + name: 'author', + type: 'text', + }, { name: 'range', type: 'group', diff --git a/pal-demo/payload.config.ts b/pal-demo/payload.config.ts index e2bbfa71e38..c67c27d85c0 100644 --- a/pal-demo/payload.config.ts +++ b/pal-demo/payload.config.ts @@ -25,6 +25,16 @@ export default buildConfig({ type: 'text', required: true, }, + { + name: 'first body', + type: 'richText', + required: true, + }, + { + name: 'second body', + type: 'richText', + required: true, + }, ], versions: { drafts: true, diff --git a/src/admin/components/forms/field-types/RichText/RichText.tsx b/src/admin/components/forms/field-types/RichText/RichText.tsx index 49d25456a94..63a6bc00bab 100644 --- a/src/admin/components/forms/field-types/RichText/RichText.tsx +++ b/src/admin/components/forms/field-types/RichText/RichText.tsx @@ -1,6 +1,6 @@ -import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import isHotkey from 'is-hotkey'; -import { createEditor, Transforms, Node, Element as SlateElement, Text, BaseEditor, Range } from 'slate'; +import { createEditor, Transforms, Node, NodeEntry, Element as SlateElement, Text, BaseEditor, Range, Editor } from 'slate'; import { ReactEditor, Editable, withReact, Slate } from 'slate-react'; import { HistoryEditor, withHistory } from 'slate-history'; import { richText } from '../../../../../fields/validations'; @@ -25,6 +25,7 @@ import withEnterBreakOut from './plugins/withEnterBreakOut'; import './index.scss'; import { useCommentsContext } from '../../../views/Comments/context'; +import { Comment } from '../../../views/Comments/types' const defaultElements: RichTextElement[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'indent', 'link', 'relationship', 'upload']; const defaultLeaves: RichTextLeaf[] = ['bold', 'italic', 'underline', 'strikethrough', 'code']; @@ -94,16 +95,17 @@ const RichText: React.FC = (props) => { return
{children}
; }, [enabledElements, path, props]); - - const { setFieldName, setRange, setIsEditing: setIsEditingComment } = useCommentsContext(); + const { state, dispatch } = useCommentsContext(); const addComment = (fieldName: string) => (e) => { e.preventDefault(); - setFieldName(fieldName); - setIsEditingComment(true); + dispatch({ + type: 'OPEN_COMMENT', + field: fieldName, + range: state.selectedRange, + }); }; - const renderLeaf = useCallback(({ attributes, children, leaf }) => { const matchedLeafName = Object.keys(enabledLeaves).find((leafName) => leaf[leafName]); @@ -124,7 +126,12 @@ const RichText: React.FC = (props) => { } return ( - {children} + + {children} + ); }, [enabledLeaves, path, props]); @@ -175,6 +182,34 @@ const RichText: React.FC = (props) => { editor.blurSelection = editor.selection; }, [editor]); + const decorate = useCallback(([node, nodePath]: NodeEntry): Range[] => { + const ranges = []; + const decorateIntersecting = (currentRange: Range, decoration: string): void => { + if (Text.isText(node) && currentRange != null) { + const intersection = Range.intersection(currentRange, Editor.range(editor, nodePath)); + + if (intersection == null) { + return; + } + + const range = { + [decoration]: true, + ...intersection, + }; + + ranges.push(range); + } + }; + + if (state.selectedField === name) { + decorateIntersecting(state.selectedRange, 'highlighted'); + } + state.comments + .filter(({ field }: Comment) => field === name) + .forEach(({ range }: Comment) => decorateIntersecting(range, 'loaded')); + return ranges; + }, [state.selectedRange, state.comments, state.selectedField, editor, name]); + useEffect(() => { if (!loaded) { const mergedElements = mergeCustomFunctions(elements, elementTypes); @@ -289,6 +324,8 @@ const RichText: React.FC = (props) => { spellCheck readOnly={readOnly} onBlur={onBlur} + onFocus={() => dispatch({ type: 'FOCUS_FIELD', field: name })} + decorate={decorate} onKeyDown={(event) => { if (event.key === 'Enter') { if (event.shiftKey) { @@ -342,9 +379,10 @@ const RichText: React.FC = (props) => { }} onSelect={() => { const range = editor.selection; - if (!Range.isCollapsed(range)) { - setRange(range); - console.log(range); + if (Range.isExpanded(range)) { + dispatch({ type: 'UPDATE_RANGE', range }); + } else if (!state.isEditing) { + dispatch({ type: 'UPDATE_RANGE', range: null }); } }} /> @@ -355,12 +393,6 @@ const RichText: React.FC = (props) => { value={value} description={description} /> - ); diff --git a/src/admin/components/forms/field-types/RichText/index.scss b/src/admin/components/forms/field-types/RichText/index.scss index 485a0ef59e2..344168761b1 100644 --- a/src/admin/components/forms/field-types/RichText/index.scss +++ b/src/admin/components/forms/field-types/RichText/index.scss @@ -4,6 +4,14 @@ margin-bottom: base(2); display: flex; + &__highlight { + background-color: $color-green; + } + + &__loaded { + background-color: $color-light-gray; + } + &__toolbar { @include blur-bg(white); display: flex; diff --git a/src/admin/components/views/Comments/CommentElement/index.scss b/src/admin/components/views/Comments/CommentElement/index.scss new file mode 100644 index 00000000000..1ff82509118 --- /dev/null +++ b/src/admin/components/views/Comments/CommentElement/index.scss @@ -0,0 +1,34 @@ +@import '../../../../scss/styles.scss'; + +.comment { + $hover-box-shadow: inset 0 0 0 $style-stroke-width lighten($color-dark-gray, 5%); + + &__card { + // border: 1px solid $color-light-gray; + color: $color-dark-gray; + margin-bottom: $baseline; + padding: base(0.5) base(0.75); + // border-radius: $style-radius-m; + position: relative; + background: white; + + &:last-child { + margin-bottom: 0; + } + + + &:hover { + box-shadow: $hover-box-shadow; + background: rgba($color-dark-gray, .02); + } + } + + &__byline { + position: bottom; + left: base(0.5); + bottom: base(0.5); + font-size: base(0.35); + color: $color-gray; + background: $color-background-gray; + } +} diff --git a/src/admin/components/views/Comments/CommentElement/index.tsx b/src/admin/components/views/Comments/CommentElement/index.tsx new file mode 100644 index 00000000000..bad295f12d7 --- /dev/null +++ b/src/admin/components/views/Comments/CommentElement/index.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useCommentsContext } from '../context'; +import { CommentProps } from './types'; +import './index.scss'; + +const CommentElement: React.FC = ({ comment: { 'comment-content': content, range, field, author } }) => { + const baseName = 'comment'; + const { state, dispatch } = useCommentsContext(); + const highlightRange = () => { + if (!state.isEditing) { + dispatch({ type: 'HIGHLIGHT_TEXT', range, field }); + } + }; + return ( +
{ + if (e.key === 'Enter') { + highlightRange(); + } + }} + > + {content} +
+ {author && ({author})} +
+ ); +}; + +export default CommentElement; diff --git a/src/admin/components/views/Comments/CommentElement/types.ts b/src/admin/components/views/Comments/CommentElement/types.ts new file mode 100644 index 00000000000..95ecc7c2ad6 --- /dev/null +++ b/src/admin/components/views/Comments/CommentElement/types.ts @@ -0,0 +1,5 @@ +import { Comment } from '../types'; + +export interface CommentProps { + comment: Comment +} diff --git a/src/admin/components/views/Comments/context/index.tsx b/src/admin/components/views/Comments/context/index.tsx index 9c455342b10..1a438969963 100644 --- a/src/admin/components/views/Comments/context/index.tsx +++ b/src/admin/components/views/Comments/context/index.tsx @@ -1,23 +1,14 @@ -import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useReducer, useMemo} from 'react'; import { useRouteMatch } from 'react-router-dom'; import queryString from 'qs'; -import { Range } from 'slate'; import { requests } from '../../../../api'; import { useConfig } from '../../../utilities/Config'; -import { Comment } from '../types'; - - -type UpdateFn = (t: T) => void +import { CommentsAction, commentsReducer, CommentsState, initCommentsState, Thunk, thunkMiddleware } from './reducer'; interface Context { - comments: Comment[] - range: Range | null - setRange: UpdateFn - isEditing: boolean - setIsEditing: UpdateFn - fieldName: string - setFieldName: UpdateFn - reloadComments: () => void + state: CommentsState, + dispatch: React.Dispatch + reloadComments: () => (dispatch: React.Dispatch) => Promise } const CommentsContext = createContext({} as Context); @@ -25,23 +16,40 @@ const CommentsContext = createContext({} as Context); export const useCommentsContext = () => useContext(CommentsContext); export const CommentsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [isEditing, setIsEditing] = useState(false); - const [fieldName, setFieldName] = useState(''); - const [comments, setComments] = useState([]); - const [range, setRange] = useState(null); - const [isError, setIsError] = useState(false); - const [isLoading, setIsLoading] = useState(false); - + const [state, commentsDispatch] = useReducer(commentsReducer, initCommentsState); const { serverURL, routes: { api } } = useConfig(); const { params: { id } = {} } = useRouteMatch>(); - const reloadComments = useCallback(async () => { + const reloadComments = useCallback(() => async (dispatch: React.Dispatch) => { const commentQuery = { 'content-id': { equals: id, }, }; + if (!id) { + // The currently viewed content may be new and therefore not have an id. + // Load empty array of comments instead. + dispatch({ type: 'SUCCEED_LOAD_COMMENTS', comments: [] }); + return; + } + const unwrap = ({ index }: {index: number}) => index; + const dbRangeToSlateRange = (dbRange) => ({ + anchor: { + ...dbRange.anchor, + path: dbRange.anchor.path.map(unwrap), + }, + focus: { + ...dbRange.focus, + path: dbRange.focus.path.map(unwrap), + }, + }); + const dbCommentToSlate = (dbComment) => { + return { + ...dbComment, + range: dbRangeToSlateRange(dbComment.range), + }; + }; const url = `${serverURL}${api}/comments`; const search = queryString.stringify({ 'fallback-locale': 'null', depth: 0, draft: 'true', where: commentQuery, @@ -49,35 +57,28 @@ export const CommentsProvider: React.FC<{ children: React.ReactNode }> = ({ chil try { const response = await requests.get(`${url}?${search}`); - if (response.status > 201) { - setIsError(true); - } + // if (response.status > 201) { + // setIsError(true); + // } const json = await response.json(); - setComments(json.docs ?? []); + const comments = json.docs ? json.docs.map(dbCommentToSlate) : []; + dispatch({ type: 'SUCCEED_LOAD_COMMENTS', comments }); - console.log(json); - setIsLoading(false); + // setIsLoading(false); } catch (error) { - console.log(error); - setIsError(true); - setIsLoading(false); + // setIsError(true); + // setIsLoading(false); } - - console.log(api, serverURL, commentQuery); }, [api, serverURL, id]); - console.log(comments); + const enhancedDispatch = useMemo(() => thunkMiddleware(commentsDispatch), [commentsDispatch]); + return ( diff --git a/src/admin/components/views/Comments/context/reducer/actions.ts b/src/admin/components/views/Comments/context/reducer/actions.ts new file mode 100644 index 00000000000..e6a3c9d770e --- /dev/null +++ b/src/admin/components/views/Comments/context/reducer/actions.ts @@ -0,0 +1,79 @@ +import { Range } from 'slate'; +import { Comment } from '../../types'; + +interface OpenComment { + type: 'OPEN_COMMENT' + range: Range + field: string +} + +interface UpdateComment { + type: 'UPDATE_COMMENT' + text: string +} + +interface CancelComment { + type: 'CANCEL_COMMENT' +} + +interface DeleteComment { + type: 'DELETE_COMMENT' + id: string +} + +interface AttemptSaveComment { + type: 'ATTEMPT_SAVE_COMMENT' + comment: Omit +} + +interface FailSaveComment { + type: 'FAIL_SAVE_COMMENT' +} + +interface SucceedSaveComment { + type: 'SUCCEED_SAVE_COMMENT' +} + +interface AttemptLoadComments { + type: 'ATTEMPT_LOAD_COMMENTS' +} + +interface FailLoadComments { + type: 'FAIL_LOAD_COMMENTS' +} + +interface SucceedLoadComments { + type: 'SUCCEED_LOAD_COMMENTS' + comments: Comment[] +} + +interface UpdateRange { + type: 'UPDATE_RANGE' + range: Range +} + +interface HighlightText { + type: 'HIGHLIGHT_TEXT' + range: Range + field: string +} + +interface FocusField { + type: 'FOCUS_FIELD' + field: string +} + +export type CommentsAction = + | OpenComment + | UpdateComment + | CancelComment + | DeleteComment + | AttemptSaveComment + | FailSaveComment + | SucceedSaveComment + | AttemptLoadComments + | FailLoadComments + | SucceedLoadComments + | UpdateRange + | HighlightText + | FocusField diff --git a/src/admin/components/views/Comments/context/reducer/index.ts b/src/admin/components/views/Comments/context/reducer/index.ts new file mode 100644 index 00000000000..3a74bc73cb2 --- /dev/null +++ b/src/admin/components/views/Comments/context/reducer/index.ts @@ -0,0 +1,3 @@ +export * from './reducer'; +export * from './actions'; +export * from './middleware'; diff --git a/src/admin/components/views/Comments/context/reducer/middleware.ts b/src/admin/components/views/Comments/context/reducer/middleware.ts new file mode 100644 index 00000000000..1adf1eac68f --- /dev/null +++ b/src/admin/components/views/Comments/context/reducer/middleware.ts @@ -0,0 +1,12 @@ +import React from 'react'; +import { CommentsAction } from './actions'; + +export type Thunk = (dispatch: React.Dispatch) => void + +export const thunkMiddleware = (dispatch: React.Dispatch) => (action: CommentsAction | Thunk) => { + if (typeof action === 'function') { + return action(dispatch); + } + + return dispatch(action); +}; diff --git a/src/admin/components/views/Comments/context/reducer/reducer.ts b/src/admin/components/views/Comments/context/reducer/reducer.ts new file mode 100644 index 00000000000..13913d69a49 --- /dev/null +++ b/src/admin/components/views/Comments/context/reducer/reducer.ts @@ -0,0 +1,75 @@ +import { Range } from 'slate'; +import { Comment } from '../../types'; +import { CommentsAction } from './actions'; + +export interface CommentsState { + comments: Comment[] + selectedRange: Range | null + selectedField: string | null + text: string | null + isEditing: boolean +} + +export const initCommentsState: CommentsState = { + comments: [], + selectedRange: null, + selectedField: null, + text: null, + isEditing: false, +}; + +export const commentsReducer = (state: CommentsState, action: CommentsAction): CommentsState => { + switch (action.type) { + case 'OPEN_COMMENT': + return { + ...state, + selectedRange: action.range, + selectedField: action.field, + isEditing: true, + }; + case 'UPDATE_COMMENT': + return { + ...state, + text: action.text, + }; + case 'CANCEL_COMMENT': + return { + ...state, + selectedRange: null, + selectedField: null, + text: null, + isEditing: false, + }; + case 'SUCCEED_LOAD_COMMENTS': + return { + ...state, + comments: action.comments, + }; + case 'SUCCEED_SAVE_COMMENT': + return { + ...state, + selectedRange: null, + selectedField: null, + text: null, + isEditing: false, + }; + case 'UPDATE_RANGE': + return { + ...state, + selectedRange: action.range, + }; + case 'HIGHLIGHT_TEXT': + return { + ...state, + selectedField: action.field, + selectedRange: action.range, + }; + case 'FOCUS_FIELD': + return { + ...state, + selectedField: action.field, + }; + default: + return { ...state }; + } +}; diff --git a/src/admin/components/views/Comments/index.scss b/src/admin/components/views/Comments/index.scss new file mode 100644 index 00000000000..cb8a9206223 --- /dev/null +++ b/src/admin/components/views/Comments/index.scss @@ -0,0 +1,31 @@ +@import '../../../scss/styles.scss'; + +.comments { + margin-top: base(.5); + padding-left: base(1.5); + padding-right: base(1); + display: flex; + flex-direction: column; + + input { + @include formInput; + margin-top: $baseline; + } + + &__tray { + display: flex; + + > * { + width: calc(50% - #{base(.5)}); + } + + > *:first-child { + margin-right: base(.5); + } + + > *:last-child { + margin-left: base(.5); + } + } + +} diff --git a/src/admin/components/views/Comments/index.tsx b/src/admin/components/views/Comments/index.tsx index db1b290a1b2..cc5e105d437 100644 --- a/src/admin/components/views/Comments/index.tsx +++ b/src/admin/components/views/Comments/index.tsx @@ -1,45 +1,52 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useRef, useEffect, useCallback } from 'react'; import { Range } from 'slate'; import { requests } from '../../../api'; import { useConfig } from '../../utilities/Config'; +import CommentElement from './CommentElement'; import { useCommentsContext } from './context'; import { CommentsProp, Comment } from './types'; -const renderComment = ({ 'comment-content': content }, i: number) =>
  • {content}
  • ; +import './index.scss'; +import Button from '../../elements/Button'; +import { useAuth } from '../../utilities/Auth'; +const renderComment = (comment: Comment) => ( + +); const CommentsView: React.FC = (props) => { const { contentId, } = props; + const baseName = 'comments'; + const { - comments, - isEditing, - setIsEditing, - fieldName: field, + state, + dispatch, reloadComments, - range, } = useCommentsContext(); - + const { user } = useAuth(); + const inputRef = useRef(null); const { serverURL, routes: { api } } = useConfig(); - const saveComment = useCallback(async (comment: Comment) => { + const saveComment = useCallback(async (comment: Omit) => { const action = `${serverURL}${api}/comments`; - const indexWrap = (index) => ({ index }); - const slateToPayloadRange = ({ anchor, focus }: Range) => { - return { - anchor: { - ...anchor, - path: anchor.path.map(indexWrap), - }, - focus: { - ...focus, - path: focus.path.map(indexWrap), - }, - }; - }; + const indexWrap = (index: number) => ({ index }); + const slateToPayloadRange = ({ anchor, focus }: Range) => ({ + anchor: { + ...anchor, + path: anchor.path.map(indexWrap), + }, + focus: { + ...focus, + path: focus.path.map(indexWrap), + }, + }); await requests.post(action, { body: JSON.stringify({ @@ -47,36 +54,41 @@ const CommentsView: React.FC = (props) => { field: comment.field, 'comment-content': comment['comment-content'], range: slateToPayloadRange(comment.range), + author: comment.author, }), headers: { 'Content-Type': 'application/json', }, }); - }, [serverURL, api]); + dispatch({ type: 'SUCCEED_SAVE_COMMENT' }); + dispatch(reloadComments()); + }, [dispatch, serverURL, api, reloadComments]); useEffect(() => { - reloadComments(); - }, [reloadComments]); + dispatch(reloadComments()); + }, [dispatch, reloadComments]); - const [content, setContent] = useState(''); + useEffect(() => { + if (state.isEditing) { + inputRef.current.focus(); + } + }, [state.isEditing]); const resetState = () => { - setIsEditing(false); - setContent(''); + dispatch({ type: 'CANCEL_COMMENT' }); }; const handleSave = (evt) => { evt.preventDefault(); const comment = { 'content-id': contentId, - field, - 'comment-content': content, - range, + field: state.selectedField, + 'comment-content': state.text, + range: state.selectedRange, + author: user.email, }; saveComment(comment); - reloadComments(); - resetState(); }; const handleCancel = (evt) => { @@ -84,35 +96,65 @@ const CommentsView: React.FC = (props) => { resetState(); }; + const openComment = (e) => { + e.preventDefault(); + dispatch({ + type: 'OPEN_COMMENT', + field: state.selectedField, + range: state.selectedRange, + }); + }; + return ( -
      - {comments.map(renderComment)} - {isEditing +
      +
      + {state.comments.map(renderComment)} +
      + {state.selectedRange && !state.isEditing + ? ( + + ) + : null} + {state.isEditing ? ( -
    • + setContent(e.target.value))} + value={state.text ?? ''} + onChange={(e) => dispatch({ type: 'UPDATE_COMMENT', text: e.target.value })} + onFocus={() => dispatch({ type: 'UPDATE_RANGE', range: state.selectedRange })} /> -
      - - +
      -
    • + ) : null} -
    + ); }; diff --git a/src/admin/components/views/Comments/types.ts b/src/admin/components/views/Comments/types.ts index 402381aca70..de92bd0de80 100644 --- a/src/admin/components/views/Comments/types.ts +++ b/src/admin/components/views/Comments/types.ts @@ -1,12 +1,14 @@ import { Range } from 'slate'; export interface Comment { - 'comment-content': string - field: string - 'content-id': string - range: Range + id: string, + 'comment-content': string + field: string + 'content-id': string + range: Range + author: string } export type CommentsProp = { - contentId: string + contentId: string } diff --git a/src/admin/components/views/collections/Edit/Default.tsx b/src/admin/components/views/collections/Edit/Default.tsx index e4714638b53..6afbae2402e 100644 --- a/src/admin/components/views/collections/Edit/Default.tsx +++ b/src/admin/components/views/collections/Edit/Default.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { Link, useRouteMatch } from 'react-router-dom'; import format from 'date-fns/format'; import { useConfig } from '../../../utilities/Config'; @@ -32,6 +32,7 @@ import { getNextStage } from '../../../utilities/Workflow'; import './index.scss'; import { post } from '../../../../../workflows/baseFields'; import CommentsView from '../../Comments'; +import { useCommentsContext } from '../../Comments/context'; const baseClass = 'collection-edit'; @@ -39,6 +40,7 @@ const DefaultEditView: React.FC = (props) => { const { params: { id } = {} } = useRouteMatch>(); const { admin: { dateFormat }, routes: { admin } } = useConfig(); const { publishedDoc } = useDocumentInfo(); + const { dispatch, reloadComments } = useCommentsContext(); const { collection, @@ -79,6 +81,10 @@ const DefaultEditView: React.FC = (props) => { isEditing && `${baseClass}--is-editing`, ].filter(Boolean).join(' '); + useEffect(() => { + dispatch(reloadComments()); + }, [dispatch, reloadComments]); + const operation = isEditing ? 'update' : 'create';