diff --git a/.scratch.md b/.scratch.md new file mode 100644 index 00000000000..dfa2d8f9350 --- /dev/null +++ b/.scratch.md @@ -0,0 +1,23 @@ +We want to have comments +Attached to RTF +Anchored to spec indices +Threading ? +We don't have persisting comment on changes + +## Thoughts +Comment collection? + - we store the id of the content + - start and end index of anchor + - content of comment + - name of the field (this wouldn't be scalable) + +Comments as linked content + - defining comment as separate content, and link to article or page + - links to list of comments + +Comments part of an existing content +- defining comment data model within content (ie article or page) +- defining a wrapper to contain a list of comments. + +- simliar to workflow, allow enabling comments for a content type +- commenting will be auto-attached for any RTF within the content diff --git a/pal-demo/collections/Comments.ts b/pal-demo/collections/Comments.ts new file mode 100644 index 00000000000..9fc65c91be2 --- /dev/null +++ b/pal-demo/collections/Comments.ts @@ -0,0 +1,68 @@ +import { CollectionConfig } from '../../src/collections/config/types'; + +const Comments: CollectionConfig = { + slug: 'comments', + fields: [ + { + name: 'content-id', + type: 'text', + index: true, + }, + { + name: 'field', + type: 'text', + }, + { + name: 'range', + type: 'group', + fields: [ + { + name: 'anchor', + type: 'group', + fields: [ + { + name: 'path', + type: 'array', + fields: [ + { + name: 'index', + type: 'number', + }, + ], + }, + { + name: 'offset', + type: 'number', + }, + ], + }, + { + name: 'focus', + type: 'group', + fields: [ + { + name: 'path', + type: 'array', + fields: [ + { + name: 'index', + type: 'number', + }, + ], + }, + { + name: 'offset', + type: 'number', + }, + ], + }, + ], + }, + { + name: 'comment-content', + type: 'text', + }, + ], +}; + +export default Comments; diff --git a/pal-demo/payload.config.ts b/pal-demo/payload.config.ts index 46cea411d5e..e2bbfa71e38 100644 --- a/pal-demo/payload.config.ts +++ b/pal-demo/payload.config.ts @@ -6,6 +6,7 @@ import Workflows from './globals/Workflows'; import { Logo } from './components/Logo'; import { Icon } from './components/Icon'; +import Comments from './collections/Comments'; export default buildConfig({ // By default, Payload will boot up normally @@ -15,6 +16,7 @@ export default buildConfig({ collections: [ Admin, PublicUser, + Comments, { slug: 'pages', fields: [ @@ -30,7 +32,7 @@ export default buildConfig({ workflow: true, }, { - slug: 'articles', + slug: 'posts', fields: [ { name: 'title', @@ -39,7 +41,7 @@ export default buildConfig({ }, { name: 'body', - type: 'text', + type: 'richText', required: true, }, ], diff --git a/src/admin/components/forms/RenderFields/index.tsx b/src/admin/components/forms/RenderFields/index.tsx index 3ed586fb97a..0f0d3f2c124 100644 --- a/src/admin/components/forms/RenderFields/index.tsx +++ b/src/admin/components/forms/RenderFields/index.tsx @@ -19,6 +19,8 @@ const RenderFields: React.FC = (props) => { permissions, readOnly: readOnlyOverride, className, + addComment, + setIsEditingComment, } = props; const [hasRendered, setHasRendered] = useState(false); @@ -30,6 +32,7 @@ const RenderFields: React.FC = (props) => { const shouldRender = isIntersecting || isAboveViewport; + useEffect(() => { if (shouldRender && !hasRendered) { setHasRendered(true); @@ -95,6 +98,7 @@ const RenderFields: React.FC = (props) => { readOnly, }, permissions: fieldPermissions, + addComment }} /> ); diff --git a/src/admin/components/forms/field-types/RichText/RichText.tsx b/src/admin/components/forms/field-types/RichText/RichText.tsx index 1778acbcc4b..49d25456a94 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 isHotkey from 'is-hotkey'; -import { createEditor, Transforms, Node, Element as SlateElement, Text, BaseEditor } from 'slate'; +import { createEditor, Transforms, Node, Element as SlateElement, Text, BaseEditor, Range } from 'slate'; import { ReactEditor, Editable, withReact, Slate } from 'slate-react'; import { HistoryEditor, withHistory } from 'slate-history'; import { richText } from '../../../../../fields/validations'; @@ -24,6 +24,7 @@ import mergeCustomFunctions from './mergeCustomFunctions'; import withEnterBreakOut from './plugins/withEnterBreakOut'; import './index.scss'; +import { useCommentsContext } from '../../../views/Comments/context'; const defaultElements: RichTextElement[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'indent', 'link', 'relationship', 'upload']; const defaultLeaves: RichTextLeaf[] = ['bold', 'italic', 'underline', 'strikethrough', 'code']; @@ -93,6 +94,16 @@ const RichText: React.FC = (props) => { return
{children}
; }, [enabledElements, path, props]); + + const { setFieldName, setRange, setIsEditing: setIsEditingComment } = useCommentsContext(); + + const addComment = (fieldName: string) => (e) => { + e.preventDefault(); + setFieldName(fieldName); + setIsEditingComment(true); + }; + + const renderLeaf = useCallback(({ attributes, children, leaf }) => { const matchedLeafName = Object.keys(enabledLeaves).find((leafName) => leaf[leafName]); @@ -329,6 +340,13 @@ const RichText: React.FC = (props) => { } }); }} + onSelect={() => { + const range = editor.selection; + if (!Range.isCollapsed(range)) { + setRange(range); + console.log(range); + } + }} /> @@ -337,6 +355,12 @@ const RichText: React.FC = (props) => { value={value} description={description} /> + ); diff --git a/src/admin/components/forms/field-types/RichText/types.ts b/src/admin/components/forms/field-types/RichText/types.ts index 512d0f8634b..39bbe9e3f8a 100644 --- a/src/admin/components/forms/field-types/RichText/types.ts +++ b/src/admin/components/forms/field-types/RichText/types.ts @@ -3,6 +3,7 @@ import { RichTextField } from '../../../../../fields/config/types'; export type Props = Omit & { path?: string + addComment: (name: string) => (evt: React.MouseEvent) => void } export interface BlurSelectionEditor extends BaseEditor { diff --git a/src/admin/components/views/Account/Default.tsx b/src/admin/components/views/Account/Default.tsx index 147208c4da1..b2b2b49406e 100644 --- a/src/admin/components/views/Account/Default.tsx +++ b/src/admin/components/views/Account/Default.tsx @@ -123,6 +123,7 @@ const DefaultAccount: React.FC = (props) => { fieldSchema={fields} /> +

Comment

  • diff --git a/src/admin/components/views/Comments/context/index.tsx b/src/admin/components/views/Comments/context/index.tsx new file mode 100644 index 00000000000..9c455342b10 --- /dev/null +++ b/src/admin/components/views/Comments/context/index.tsx @@ -0,0 +1,87 @@ +import React, { createContext, useCallback, useContext, useEffect, useState } 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 + +interface Context { + comments: Comment[] + range: Range | null + setRange: UpdateFn + isEditing: boolean + setIsEditing: UpdateFn + fieldName: string + setFieldName: UpdateFn + reloadComments: () => void +} + +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 { serverURL, routes: { api } } = useConfig(); + const { params: { id } = {} } = useRouteMatch>(); + + + const reloadComments = useCallback(async () => { + const commentQuery = { + 'content-id': { + equals: id, + }, + }; + const url = `${serverURL}${api}/comments`; + const search = queryString.stringify({ + 'fallback-locale': 'null', depth: 0, draft: 'true', where: commentQuery, + }); + try { + const response = await requests.get(`${url}?${search}`); + + if (response.status > 201) { + setIsError(true); + } + + const json = await response.json(); + setComments(json.docs ?? []); + + console.log(json); + setIsLoading(false); + } catch (error) { + console.log(error); + setIsError(true); + setIsLoading(false); + } + + console.log(api, serverURL, commentQuery); + }, [api, serverURL, id]); + + console.log(comments); + + return ( + + {children} + + ); +}; diff --git a/src/admin/components/views/Comments/index.tsx b/src/admin/components/views/Comments/index.tsx new file mode 100644 index 00000000000..db1b290a1b2 --- /dev/null +++ b/src/admin/components/views/Comments/index.tsx @@ -0,0 +1,119 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Range } from 'slate'; +import { requests } from '../../../api'; +import { useConfig } from '../../utilities/Config'; +import { useCommentsContext } from './context'; +import { CommentsProp, Comment } from './types'; + +const renderComment = ({ 'comment-content': content }, i: number) =>
  • {content}
  • ; + + +const CommentsView: React.FC = (props) => { + const { + contentId, + } = props; + + const { + comments, + isEditing, + setIsEditing, + fieldName: field, + reloadComments, + range, + } = useCommentsContext(); + + + const { serverURL, routes: { api } } = useConfig(); + + const saveComment = useCallback(async (comment: Comment) => { + 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), + }, + }; + }; + + await requests.post(action, { + body: JSON.stringify({ + 'content-id': comment['content-id'], + field: comment.field, + 'comment-content': comment['comment-content'], + range: slateToPayloadRange(comment.range), + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }, [serverURL, api]); + + useEffect(() => { + reloadComments(); + }, [reloadComments]); + + const [content, setContent] = useState(''); + + const resetState = () => { + setIsEditing(false); + setContent(''); + }; + + const handleSave = (evt) => { + evt.preventDefault(); + const comment = { + 'content-id': contentId, + field, + 'comment-content': content, + range, + }; + + saveComment(comment); + reloadComments(); + resetState(); + }; + + const handleCancel = (evt) => { + evt.preventDefault(); + resetState(); + }; + + return ( +
      + {comments.map(renderComment)} + {isEditing + ? ( +
    • + setContent(e.target.value))} + /> +
      + + +
      +
    • + ) + : null} +
    + ); +}; + +export default CommentsView; diff --git a/src/admin/components/views/Comments/types.ts b/src/admin/components/views/Comments/types.ts new file mode 100644 index 00000000000..402381aca70 --- /dev/null +++ b/src/admin/components/views/Comments/types.ts @@ -0,0 +1,12 @@ +import { Range } from 'slate'; + +export interface Comment { + 'comment-content': string + field: string + 'content-id': string + range: Range +} + +export type CommentsProp = { + contentId: string +} diff --git a/src/admin/components/views/collections/Edit/Default.tsx b/src/admin/components/views/collections/Edit/Default.tsx index a89a9419c00..e4714638b53 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 from 'react'; +import React, { useCallback } from 'react'; import { Link, useRouteMatch } from 'react-router-dom'; import format from 'date-fns/format'; import { useConfig } from '../../../utilities/Config'; @@ -31,6 +31,7 @@ import { getNextStage } from '../../../utilities/Workflow'; import './index.scss'; import { post } from '../../../../../workflows/baseFields'; +import CommentsView from '../../Comments'; const baseClass = 'collection-edit'; @@ -80,6 +81,7 @@ const DefaultEditView: React.FC = (props) => { const operation = isEditing ? 'update' : 'create'; + return (
    {isLoading && ( @@ -172,14 +174,18 @@ const DefaultEditView: React.FC = (props) => { )} {collection.workflow && nextStage !== post - ? - : } + /> + )} )} {!collection.versions?.drafts && ( @@ -215,6 +221,9 @@ const DefaultEditView: React.FC = (props) => { fieldSchema={fields} />
    + {isEditing && (
      {!hideAPIURL && ( diff --git a/src/admin/components/views/collections/Edit/index.tsx b/src/admin/components/views/collections/Edit/index.tsx index f192839328c..0b844c91ce4 100644 --- a/src/admin/components/views/collections/Edit/index.tsx +++ b/src/admin/components/views/collections/Edit/index.tsx @@ -15,6 +15,7 @@ import { IndexProps } from './types'; import { StepNavItem } from '../../../elements/StepNav/types'; import { useDocumentInfo } from '../../../utilities/DocumentInfo'; import { Stage } from '../../../utilities/Workflow'; +import { CommentsProvider } from '../../Comments/context'; const EditView: React.FC = (props) => { const { collection: incomingCollection, isEditing } = props; @@ -63,23 +64,24 @@ const EditView: React.FC = (props) => { ); const [{ data: collectionsMetaData }] = usePayloadAPI( - (isEditing ? `${serverURL}${api}/globals/collectionsMeta` : null), + (isEditing ? `${serverURL}${api}/globals/collectionsMeta` : null), { initialParams: { 'fallback-locale': 'null', depth: 1, draft: 'true' } }, ); - const collectionMeta = collectionsMetaData[slug] - const hasWorkflow = collectionMeta !== null && collectionMeta !== undefined - const collectionStages = hasWorkflow ? collectionMeta.value?.stages : [] + const collectionMeta = collectionsMetaData[slug]; + const hasWorkflow = collectionMeta !== null && collectionMeta !== undefined; + const collectionStages = hasWorkflow ? collectionMeta.value?.stages : []; const [{ data: userData }] = usePayloadAPI( - (isEditing ? `${serverURL}${api}/admins/${user.id}` : null), + (isEditing ? `${serverURL}${api}/admins/${user.id}` : null), { initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } }, ); - const {roles: userRoles = []} = userData + const { roles: userRoles = [] } = userData; const dataToRender = (locationState as Record)?.data || data; + useEffect(() => { const nav: StepNavItem[] = [{ url: `${admin}/collections/${slug}`, @@ -133,44 +135,45 @@ const EditView: React.FC = (props) => { const getCurrentStage = (): Stage | null => { if (collectionStages.length === 0) { - return null + return null; } - const currentStageIndex = collectionStages.findIndex(({stage}: Stage) => stage.name === data["_workflow_stage"]) - - return collectionStages[currentStageIndex] || null - } + const currentStageIndex = collectionStages.findIndex(({ stage }: Stage) => stage.name === data._workflow_stage); + return collectionStages[currentStageIndex] || null; + }; const currentStage = getCurrentStage(); const collectionPermissions = permissions?.collections?.[slug]; const apiURL = `${serverURL}${api}/${slug}/${id}${collection.versions.drafts ? '?draft=true' : ''}`; const action = `${serverURL}${api}/${slug}${isEditing ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`; - const hasStagePermission = !collection.workflow || userRoles.some((role: string) => role === currentStage?.stage?.approver || role === 'admin') + const hasStagePermission = !collection.workflow || userRoles.some((role: string) => role === currentStage?.stage?.approver || role === 'admin'); const hasSavePermission = (isEditing && collectionPermissions?.update?.permission) || (!isEditing && collectionPermissions?.create?.permission); return ( - + + + ); }; diff --git a/src/admin/components/views/collections/Edit/types.ts b/src/admin/components/views/collections/Edit/types.ts index 7b82f530c48..d5d39c0d106 100644 --- a/src/admin/components/views/collections/Edit/types.ts +++ b/src/admin/components/views/collections/Edit/types.ts @@ -3,6 +3,7 @@ import { CollectionPermission } from '../../../../../auth/types'; import { Document } from '../../../../../types'; import { Fields } from '../../../forms/Form/types'; import { Stage } from '../../../utilities/Workflow'; +import { Comment } from '../../Comments/types'; export type IndexProps = { collection: SanitizedCollectionConfig @@ -21,6 +22,6 @@ export type Props = IndexProps & { hasStagePermission: boolean hasWorkflow: boolean autosaveEnabled: boolean - workflowStages: Stage[]; - currentStage?: string; + workflowStages: Stage[] + currentStage?: string }