From f1cb46fb18ac539b9694b142051a15c1fb0b4142 Mon Sep 17 00:00:00 2001 From: Herman Wikner Date: Fri, 29 Sep 2023 12:41:13 +0200 Subject: [PATCH] feat: validate comments against schema and document value --- .../__workshop__/CommentsListStory.tsx | 81 +++++++--- .../__workshop__/CommentsProviderStory.tsx | 8 - .../components/list/CommentThreadLayout.tsx | 13 +- .../comments/components/list/CommentsList.tsx | 104 +++++-------- .../components/list/CommentsListItem.tsx | 16 +- .../core/comments/components/list/styles.ts | 11 ++ .../comments/context/CommentsProvider.tsx | 43 +++--- .../comments/hooks/useFieldCommentsCount.ts | 6 +- packages/sanity/src/core/comments/index.ts | 3 +- packages/sanity/src/core/comments/types.ts | 17 ++- .../comments/utils/buildCommentBreadcrumbs.ts | 139 +++++++++++++----- .../comments/utils/buildCommentThreadItems.ts | 55 +++++++ .../src/desk/comments/field/CommentField.tsx | 2 +- .../comments/inspector/CommentsInspector.tsx | 32 +--- .../panes/document/DocumentPaneProvider.tsx | 2 +- 15 files changed, 340 insertions(+), 192 deletions(-) create mode 100644 packages/sanity/src/core/comments/components/list/styles.ts create mode 100644 packages/sanity/src/core/comments/utils/buildCommentThreadItems.ts diff --git a/packages/sanity/src/core/comments/__workshop__/CommentsListStory.tsx b/packages/sanity/src/core/comments/__workshop__/CommentsListStory.tsx index a4011d2b8491..2a6d946abe9b 100644 --- a/packages/sanity/src/core/comments/__workshop__/CommentsListStory.tsx +++ b/packages/sanity/src/core/comments/__workshop__/CommentsListStory.tsx @@ -2,7 +2,13 @@ import React, {useCallback, useState} from 'react' import {useBoolean, useSelect} from '@sanity/ui-workshop' import {CommentsList} from '../components' import {useCurrentUser} from '../../store' -import {CommentDocument, CommentCreatePayload, CommentEditPayload, CommentStatus} from '../types' +import { + CommentDocument, + CommentCreatePayload, + CommentEditPayload, + CommentStatus, + CommentThreadItem, +} from '../types' const BASE: CommentDocument = { _id: '1', @@ -46,16 +52,19 @@ const BASE: CommentDocument = { ], } -const PROPS = [ - { - ...BASE, - }, -] +const PROPS: CommentThreadItem = { + parentComment: BASE, + breadcrumbs: [], + commentsCount: 1, + fieldPath: 'test', + replies: [], + threadId: '1', +} const STATUS_OPTIONS: Record = {open: 'open', resolved: 'resolved'} export default function CommentsListStory() { - const [state, setState] = useState(PROPS) + const [state, setState] = useState(PROPS) const error = useBoolean('Error', false, 'Props') || null const loading = useBoolean('Loading', false, 'Props') || false @@ -70,38 +79,66 @@ export default function CommentsListStory() { ...BASE, ...payload, _createdAt: new Date().toISOString(), - _id: `${state.length + 1}`, + _id: `${state.commentsCount + 1}`, authorId: currentUser?.id || 'pP5s3g90N', parentCommentId: payload.parentCommentId, } - setState((prev) => [...prev, comment]) + setState((prev) => { + return { + ...prev, + replies: [...prev.replies, comment], + } + }) }, - [currentUser?.id, state.length], + [currentUser?.id, state.commentsCount], ) const handleEdit = useCallback( (id: string, payload: CommentEditPayload) => { - setState((prev) => - prev.map((item) => { - if (item._id === id) { - return { - ...item, + const isParentEdit = id === state.parentComment._id + + if (isParentEdit) { + setState((prev) => { + return { + ...prev, + parentComment: { + ...prev.parentComment, ...payload, _updatedAt: new Date().toISOString(), - } + }, } + }) + } + + setState((prev) => { + return { + ...prev, + replies: prev.replies.map((item) => { + if (item._id === id) { + return { + ...item, + ...payload, + _updatedAt: new Date().toISOString(), + } + } - return item - }), - ) + return item + }), + } + }) }, - [setState], + [state.parentComment._id], ) const handleDelete = useCallback( (id: string) => { - setState((prev) => prev.filter((item) => item._id !== id)) + setState((prev) => { + return { + ...prev, + replies: prev.replies.filter((item) => item._id !== id), + } + }) }, [setState], ) @@ -110,7 +147,7 @@ export default function CommentsListStory() { return ( - path.split('.').map((p) => ({ - title: p, - invalid: false, - isArrayItem: false, - })) - } /> ) } diff --git a/packages/sanity/src/core/comments/components/list/CommentThreadLayout.tsx b/packages/sanity/src/core/comments/components/list/CommentThreadLayout.tsx index 54f1862370c3..9351b9dbbe1d 100644 --- a/packages/sanity/src/core/comments/components/list/CommentThreadLayout.tsx +++ b/packages/sanity/src/core/comments/components/list/CommentThreadLayout.tsx @@ -4,14 +4,14 @@ import React, {useCallback, useRef, useState} from 'react' import {uuid} from '@sanity/uuid' import * as PathUtils from '@sanity/util/paths' import styled from 'styled-components' -import {WarningOutlineIcon} from '@sanity/icons' +import {ChevronRightIcon, WarningOutlineIcon} from '@sanity/icons' import {AddCommentIcon} from '../icons' import {MentionOptionsHookValue} from '../../hooks' import {CommentInputHandle} from '../pte' import {CommentMessage, CommentCreatePayload, CommentBreadcrumbs} from '../../types' import {TextTooltip} from '../TextTooltip' -import {ThreadCard} from './CommentsListItem' import {CreateNewThreadInput} from './CreateNewThreadInput' +import {ThreadCard} from './styles' const BreadcrumbsFlex = styled(Flex)` min-height: 25px; @@ -98,7 +98,14 @@ export function CommentThreadLayout(props: CommentThreadLayoutProps) { {!hasInvalidField && ( - + + + + } + > {breadcrumbs?.map((p, index) => { const idx = `${p.title}-${index}` diff --git a/packages/sanity/src/core/comments/components/list/CommentsList.tsx b/packages/sanity/src/core/comments/components/list/CommentsList.tsx index b18d86683945..3638d95cac04 100644 --- a/packages/sanity/src/core/comments/components/list/CommentsList.tsx +++ b/packages/sanity/src/core/comments/components/list/CommentsList.tsx @@ -3,11 +3,10 @@ import {BoundaryElementProvider, Container, Flex, Spinner, Stack, Text} from '@s import {CurrentUser, Path} from '@sanity/types' import * as PathUtils from '@sanity/util/paths' import { - CommentBreadcrumbs, CommentCreatePayload, - CommentDocument, CommentEditPayload, CommentStatus, + CommentThreadItem, } from '../../types' import {MentionOptionsHookValue} from '../../hooks' import {CommentsListItem} from './CommentsListItem' @@ -21,12 +20,12 @@ const SCROLL_INTO_VIEW_OPTIONS: ScrollIntoViewOptions = { } interface GroupedComments { - [field: string]: CommentDocument[] + [field: string]: CommentThreadItem[] } -function groupComments(comments: CommentDocument[]) { +function groupThreads(comments: CommentThreadItem[]) { return comments.reduce((acc, comment) => { - const field = comment.target?.path?.field + const field = comment.fieldPath if (!acc[field]) { acc[field] = [] @@ -38,24 +37,12 @@ function groupComments(comments: CommentDocument[]) { }, {} as GroupedComments) } -function getReplies(parentCommentId: string, group: CommentDocument[]) { - const replies = group.filter((c) => c.parentCommentId === parentCommentId) - - // The default sort order is by date, descending (newest first). - // However, inside a thread, we want the order to be ascending (oldest first). - // So we reverse the array here. - const orderedReplies = [...replies].reverse() - - return orderedReplies -} - /** * @beta * @hidden */ export interface CommentsListProps { - buildCommentBreadcrumbs?: (fieldPath: string) => CommentBreadcrumbs - comments: CommentDocument[] + comments: CommentThreadItem[] currentUser: CurrentUser error: Error | null loading: boolean @@ -86,7 +73,6 @@ export const CommentsList = forwardRef(fu ref, ) { const { - buildCommentBreadcrumbs, comments, currentUser, error, @@ -120,23 +106,18 @@ export const CommentsList = forwardRef(fu [scrollToComment], ) - const groupedComments = useMemo(() => { - const filteredComments = comments - // 1. Get all comments that are not replies and are in the current view (open or resolved) - .filter((c) => !c.parentCommentId) - // 2. Get all replies to each parent comment and add them to the array - // eslint-disable-next-line max-nested-callbacks - .map((c) => [c, ...comments.filter((c2) => c2.parentCommentId === c._id)]) - .flat() - - return Object.entries(groupComments(filteredComments)) - }, [comments]) - - const showComments = !loading && !error && groupedComments.length > 0 - const showEmptyState = !loading && !error && groupedComments.length === 0 + const showComments = !loading && !error && comments.length > 0 + const showEmptyState = !loading && !error && comments.length === 0 const showError = error const showLoading = loading && !error + // We group the threads so that they can be rendered together under the + // same breadcrumbs. This is to avoid having the same breadcrumbs repeated + // for every single comment thread. Also, we don't want to have threads pointing + // to the same field to be rendered separately in the list since that makes it + // harder to get an overview of the comments about a specific field. + const groupedThreads = useMemo(() => Object.entries(groupThreads(comments)), [comments]) + return ( (fu space={4} > - {groupedComments.map(([fieldPath, group]) => { - const parentComments = group.filter((c) => !c.parentCommentId) - - // The threadId is used to identify the thread in the DOM, so we can scroll to it. - // todo: validate this approach - const threadId = group[0].threadId - - const breadcrumbs = buildCommentBreadcrumbs?.(fieldPath) - const hasInvalidField = breadcrumbs?.some((b) => b.invalid === true) - - // If the breadcrumb is invalid, the field might have been remove from the - // the schema, or an array item might have been removed. In that case, we don't - // want to render any button to open the field. - const _onPathFocus = hasInvalidField ? undefined : onPathFocus + {groupedThreads?.map(([fieldPath, group]) => { + // Since all threads in the group point to the same field, the breadcrumbs will be + // the same for all of them. Therefore, we can just pick the first one. + const breadcrumbs = group[0].breadcrumbs return ( - + - {parentComments.map((comment) => { - const replies = getReplies(comment._id, group) + {group.map((item) => { + // The default sort order is by date, descending (newest first). + // However, inside a thread, we want the order to be ascending (oldest first). + // So we reverse the array here. + // We use slice() to avoid mutating the original array. + const replies = item.replies.slice().reverse() return ( - + + + ) })} diff --git a/packages/sanity/src/core/comments/components/list/CommentsListItem.tsx b/packages/sanity/src/core/comments/components/list/CommentsListItem.tsx index 77676f83eefc..5423ec384caf 100644 --- a/packages/sanity/src/core/comments/components/list/CommentsListItem.tsx +++ b/packages/sanity/src/core/comments/components/list/CommentsListItem.tsx @@ -14,21 +14,13 @@ import { import {MentionOptionsHookValue} from '../../hooks' import {SpacerAvatar} from '../avatars' import {CommentsListItemLayout} from './CommentsListItemLayout' +import {ThreadCard} from './styles' const EMPTY_ARRAY: [] = [] const MAX_COLLAPSED_REPLIES = 5 -export const ThreadCard = styled(Card).attrs(({tone}) => ({ - padding: 3, - radius: 3, - sizing: 'border', - tone: tone || 'transparent', -}))` - // ... -` - -const RootCard = styled(ThreadCard)` +const StyledThreadCard = styled(ThreadCard)` // When hovering over the thread root we want to display the parent comments menu. // The data-root-menu attribute is used to target the menu and is applied in // the CommentsListItemLayout component. @@ -137,7 +129,7 @@ export function CommentsListItem(props: CommentsListItemProps) { return ( - + @@ -200,7 +192,7 @@ export function CommentsListItem(props: CommentsListItemProps) { )} - + ) } diff --git a/packages/sanity/src/core/comments/components/list/styles.ts b/packages/sanity/src/core/comments/components/list/styles.ts new file mode 100644 index 000000000000..743ffec53af6 --- /dev/null +++ b/packages/sanity/src/core/comments/components/list/styles.ts @@ -0,0 +1,11 @@ +import {Card, CardProps} from '@sanity/ui' +import styled from 'styled-components' + +export const ThreadCard = styled(Card).attrs(({tone}) => ({ + padding: 3, + radius: 3, + sizing: 'border', + tone: tone || 'transparent', +}))` + // ... +` diff --git a/packages/sanity/src/core/comments/context/CommentsProvider.tsx b/packages/sanity/src/core/comments/context/CommentsProvider.tsx index cce31fbdbe08..249ddd69f7c7 100644 --- a/packages/sanity/src/core/comments/context/CommentsProvider.tsx +++ b/packages/sanity/src/core/comments/context/CommentsProvider.tsx @@ -1,15 +1,21 @@ import React, {memo, useMemo, useState} from 'react' import {SanityDocument} from '@sanity/client' -import {CommentDocument, CommentStatus, CommentsContextValue} from '../types' +import {CommentStatus, CommentsContextValue} from '../types' import {useCommentOperations, useCommentsEnabled, useMentionOptions} from '../hooks' import {useCommentsStore} from '../store' import {useSchema} from '../../hooks' import {useCurrentUser} from '../../store' import {useWorkspace} from '../../studio' import {getPublishedId} from '../../util' +import {buildCommentThreadItems} from '../utils/buildCommentThreadItems' import {CommentsContext} from './CommentsContext' -const EMPTY_ARRAY: CommentDocument[] = [] +const EMPTY_ARRAY: [] = [] + +const EMPTY_COMMENTS_DATA = { + open: EMPTY_ARRAY, + resolved: EMPTY_ARRAY, +} /** * @beta @@ -17,18 +23,11 @@ const EMPTY_ARRAY: CommentDocument[] = [] */ export interface CommentsProviderProps { children: React.ReactNode - /** The document value is being used to: - * - Attach a comment to a document using the _id - * - Check user permissions with grants filter to determine if a user can be mentioned (see `useMentionOptions`) - */ documentValue: SanityDocument } const EMPTY_COMMENTS = { - data: { - open: EMPTY_ARRAY, - resolved: EMPTY_ARRAY, - }, + data: EMPTY_COMMENTS_DATA, error: null, loading: false, } @@ -131,11 +130,21 @@ function CommentsProviderInner(props: Omit) { }, }) - const commentsByStatus = useMemo(() => { - const open = data?.filter((c) => c.status === 'open') || EMPTY_ARRAY - const resolved = data?.filter((c) => c.status === 'resolved') || EMPTY_ARRAY - return {open, resolved} - }, [data]) + const threadItemsByStatus = useMemo(() => { + if (!schemaType || !currentUser) return EMPTY_COMMENTS_DATA + + const threadItems = buildCommentThreadItems({ + comments: data, + schemaType, + currentUser, + documentValue, + }) + + return { + open: threadItems.filter((item) => item.parentComment.status === 'open'), + resolved: threadItems.filter((item) => item.parentComment.status === 'resolved'), + } + }, [currentUser, data, documentValue, schemaType]) const ctxValue = useMemo( () => @@ -143,7 +152,7 @@ function CommentsProviderInner(props: Omit) { status, setStatus, comments: { - data: commentsByStatus, + data: threadItemsByStatus, error, loading, }, @@ -162,7 +171,6 @@ function CommentsProviderInner(props: Omit) { mentionOptions, }) satisfies CommentsContextValue, [ - commentsByStatus, error, loading, mentionOptions, @@ -171,6 +179,7 @@ function CommentsProviderInner(props: Omit) { operation.remove, operation.update, status, + threadItemsByStatus, ], ) diff --git a/packages/sanity/src/core/comments/hooks/useFieldCommentsCount.ts b/packages/sanity/src/core/comments/hooks/useFieldCommentsCount.ts index e66fcfb1e080..3fe9e2b99021 100644 --- a/packages/sanity/src/core/comments/hooks/useFieldCommentsCount.ts +++ b/packages/sanity/src/core/comments/hooks/useFieldCommentsCount.ts @@ -12,5 +12,9 @@ export function useFieldCommentsCount(path: Path): number { const {comments} = useComments() const stringPath = PathUtils.toString(path) - return comments.data.open.filter((comment) => comment.target.path?.field === stringPath).length + const count = comments.data.open + .map((c) => (c.fieldPath === stringPath ? c.commentsCount : 0)) + .reduce((acc, val) => acc + val, 0) + + return count || 0 } diff --git a/packages/sanity/src/core/comments/index.ts b/packages/sanity/src/core/comments/index.ts index 709804b95be5..15a3a31293a0 100644 --- a/packages/sanity/src/core/comments/index.ts +++ b/packages/sanity/src/core/comments/index.ts @@ -18,6 +18,5 @@ export type { CommentEditPayload, CommentMessage, CommentStatus, + CommentThreadItem, } from './types' - -export {buildCommentBreadcrumbs} from './utils' diff --git a/packages/sanity/src/core/comments/types.ts b/packages/sanity/src/core/comments/types.ts index 5adebf07aa4f..e0226a9c6f4d 100644 --- a/packages/sanity/src/core/comments/types.ts +++ b/packages/sanity/src/core/comments/types.ts @@ -19,6 +19,19 @@ export interface MentionOptionUser extends User { canBeMentioned: boolean } +/** + * @beta + * @hidden + */ +export interface CommentThreadItem { + breadcrumbs: CommentBreadcrumbs + commentsCount: number + fieldPath: string + parentComment: CommentDocument + replies: CommentDocument[] + threadId: string +} + /** * @beta * @hidden @@ -26,8 +39,8 @@ export interface MentionOptionUser extends User { export interface CommentsContextValue { comments: { data: { - open: CommentDocument[] - resolved: CommentDocument[] + open: CommentThreadItem[] + resolved: CommentThreadItem[] } error: Error | null loading: boolean diff --git a/packages/sanity/src/core/comments/utils/buildCommentBreadcrumbs.ts b/packages/sanity/src/core/comments/utils/buildCommentBreadcrumbs.ts index 2e259a1bcc63..a801d7fa3459 100644 --- a/packages/sanity/src/core/comments/utils/buildCommentBreadcrumbs.ts +++ b/packages/sanity/src/core/comments/utils/buildCommentBreadcrumbs.ts @@ -1,9 +1,21 @@ -import {SchemaType, ObjectField, isObjectSchemaType, isArraySchemaType} from '@sanity/types' -import {findIndex, startCase} from 'lodash' +import { + SchemaType, + ObjectField, + isObjectSchemaType, + CurrentUser, + ArraySchemaType, + ConditionalPropertyCallbackContext, + ObjectSchemaType, + ObjectFieldType, + PathSegment, +} from '@sanity/types' +import {findIndex} from 'lodash' import * as PathUtils from '@sanity/util/paths' +import {SanityDocument} from '@sanity/client' import {getValueAtPath} from '../../field' import {getSchemaTypeTitle} from '../../schema' import {CommentBreadcrumbs} from '../types' +import {resolveConditionalProperty} from '../../form/store/conditional-property' function getSchemaField( schemaType: SchemaType, @@ -12,19 +24,8 @@ function getSchemaField( const paths = PathUtils.fromString(fieldPath) const firstPath = paths[0] - if (isArraySchemaType(schemaType)) { - const pathWithoutKeys = paths.filter((seg) => !seg.hasOwnProperty('_key')).toString() - - const field = - isObjectSchemaType(schemaType?.of[0]) && - schemaType?.of[0]?.fields && - schemaType?.of[0]?.fields.find((f: ObjectField) => f.name === pathWithoutKeys) - - return field ? field : undefined - } - if (firstPath && isObjectSchemaType(schemaType)) { - const field = schemaType.fields.find((f) => f.name === firstPath) + const field = schemaType?.fields?.find((f) => f.name === firstPath) if (field) { const nextPath = PathUtils.toString(paths.slice(1)) @@ -40,7 +41,7 @@ function getSchemaField( return undefined } -function findArrayItemIndex(array: unknown[], pathSegment: any): number | false { +function findArrayItemIndex(array: unknown[], pathSegment: PathSegment): number | false { if (typeof pathSegment === 'number') { return pathSegment } @@ -49,41 +50,50 @@ function findArrayItemIndex(array: unknown[], pathSegment: any): number | false } interface BuildCommentBreadcrumbsProps { - documentValue: unknown + documentValue: Partial | null fieldPath: string schemaType: SchemaType + currentUser: CurrentUser } /** * @beta * @hidden + * + * This function builds a breadcrumb trail for a given comment using its field path. + * It will validate each segment of the path against the document value and/or schema type. + * The path is invalid if: + * - The field is hidden by a conditional field + * - The field is not found in the schema type + * - The field is not found in the document value (array items only) */ export function buildCommentBreadcrumbs(props: BuildCommentBreadcrumbsProps): CommentBreadcrumbs { - const {schemaType, fieldPath, documentValue} = props + const {currentUser, schemaType, fieldPath, documentValue} = props const paths = PathUtils.fromString(fieldPath) const fieldPaths: CommentBreadcrumbs = [] + let currentSchemaType: ArraySchemaType | ObjectFieldType | null = null + paths.forEach((seg, index) => { - const currentPath = PathUtils.toString(paths.slice(0, index + 1)) - const isArraySegment = seg.hasOwnProperty('_key') - const field = getSchemaField(schemaType, currentPath) + const currentPath = paths.slice(0, index + 1) const previousPath = paths.slice(0, index) - if (field) { - const title = getSchemaTypeTitle(field?.type) + const field = getSchemaField(schemaType, PathUtils.toString(currentPath)) + const isKeySegment = seg.hasOwnProperty('_key') - fieldPaths.push({ - invalid: false, - isArrayItem: false, - title, - }) + const parentValue = getValueAtPath(documentValue, previousPath) + const currentValue = getValueAtPath(documentValue, currentPath) - return + const conditionalContext: ConditionalPropertyCallbackContext = { + document: documentValue as SanityDocument, + currentUser, + parent: parentValue, + value: currentValue, } - if (isArraySegment) { - const valueAtPath = getValueAtPath(documentValue, previousPath) as unknown[] - const arrayItemIndex = findArrayItemIndex(valueAtPath, seg) + if (isKeySegment) { + const previousValue = getValueAtPath(documentValue, previousPath) as unknown[] + const arrayItemIndex = findArrayItemIndex(previousValue, seg) fieldPaths.push({ invalid: arrayItemIndex === false, @@ -94,15 +104,74 @@ export function buildCommentBreadcrumbs(props: BuildCommentBreadcrumbsProps): Co return } - if (!field) { - const invalid = !PathUtils.toString(previousPath)?.hasOwnProperty('_key') + if (field?.type) { + const hidden = resolveConditionalProperty(field.type.hidden, conditionalContext) + + fieldPaths.push({ + invalid: hidden, + isArrayItem: false, + title: getSchemaTypeTitle(field.type), + }) + + currentSchemaType = field.type + + return + } + + if (currentSchemaType?.jsonType === 'array') { + const arrayValue: any = getValueAtPath(documentValue, previousPath) + const objectType = arrayValue?._type + + const objectField = currentSchemaType?.of?.find( + (type) => type.name === objectType, + ) as ObjectSchemaType + + const currentField = objectField?.fields?.find( + (f) => f.name === seg, + ) as ObjectField + + if (!currentField) { + fieldPaths.push({ + invalid: true, + isArrayItem: false, + title: 'Unknown field', + }) + + return + } + + const currentTitle = getSchemaTypeTitle(currentField?.type) + + const objectFieldHidden = resolveConditionalProperty( + objectField?.type?.hidden, + conditionalContext, + ) + + const currentFieldHidden = resolveConditionalProperty( + currentField?.type.hidden, + conditionalContext, + ) + + const isHidden = objectFieldHidden || currentFieldHidden fieldPaths.push({ - invalid, + invalid: isHidden, isArrayItem: false, - title: startCase(seg.toString()), + title: currentTitle, }) + + currentSchemaType = currentField?.type + + return } + + // If we get here, the field is not found in the schema type + // or the document value so we'll mark it as invalid. + fieldPaths.push({ + invalid: true, + isArrayItem: false, + title: 'Unknown field', + }) }) return fieldPaths diff --git a/packages/sanity/src/core/comments/utils/buildCommentThreadItems.ts b/packages/sanity/src/core/comments/utils/buildCommentThreadItems.ts new file mode 100644 index 000000000000..ce8f5c92b713 --- /dev/null +++ b/packages/sanity/src/core/comments/utils/buildCommentThreadItems.ts @@ -0,0 +1,55 @@ +import {SanityDocument} from '@sanity/client' +import {SchemaType, CurrentUser} from '@sanity/types' +import {CommentDocument, CommentThreadItem} from '../types' +import {buildCommentBreadcrumbs} from './buildCommentBreadcrumbs' + +const EMPTY_ARRAY: [] = [] + +interface BuildCommentThreadItemsProps { + comments: CommentDocument[] | null + currentUser: CurrentUser + documentValue: Partial | null + schemaType: SchemaType +} + +/** + * This function formats comments into a structure that is easier to work with in the UI. + * It also validates each comment against the document value and schema type to ensure + * that the comment is valid. If the comment is invalid, it will be omitted from the + * returned array. + */ +export function buildCommentThreadItems(props: BuildCommentThreadItemsProps): CommentThreadItem[] { + const {currentUser, schemaType, documentValue, comments = EMPTY_ARRAY} = props + const parentComments = comments?.filter((c) => !c.parentCommentId) || EMPTY_ARRAY + + const items = parentComments + .map((c) => { + const crumbs = buildCommentBreadcrumbs({ + currentUser, + documentValue, + fieldPath: c.target.path.field, + schemaType, + }) + + const hasInvalidBreadcrumb = crumbs.some((bc) => bc.invalid) + + if (hasInvalidBreadcrumb) return undefined + + const replies = comments?.filter((r) => r.parentCommentId === c._id) || EMPTY_ARRAY + + // Add one to the replies count to account for the parent comment + const commentsCount = replies.length + 1 + + return { + breadcrumbs: crumbs, + commentsCount, + fieldPath: c.target.path.field, + parentComment: c, + replies, + threadId: c.threadId, + } + }) + .filter(Boolean) as CommentThreadItem[] + + return items || EMPTY_ARRAY +} diff --git a/packages/sanity/src/desk/comments/field/CommentField.tsx b/packages/sanity/src/desk/comments/field/CommentField.tsx index 8d39fe204e7f..0e1e52a5b6d9 100644 --- a/packages/sanity/src/desk/comments/field/CommentField.tsx +++ b/packages/sanity/src/desk/comments/field/CommentField.tsx @@ -76,7 +76,7 @@ function CommentFieldInner(props: FieldProps) { useEffect(() => { const threadId = comments.data.open.find( - (comment) => comment.target?.path?.field === props.path.toString(), + (comment) => comment.fieldPath === props.path.toString(), )?.threadId if (threadId) { diff --git a/packages/sanity/src/desk/comments/inspector/CommentsInspector.tsx b/packages/sanity/src/desk/comments/inspector/CommentsInspector.tsx index 7d5961589b89..834ace39ed58 100644 --- a/packages/sanity/src/desk/comments/inspector/CommentsInspector.tsx +++ b/packages/sanity/src/desk/comments/inspector/CommentsInspector.tsx @@ -1,12 +1,11 @@ import {Flex} from '@sanity/ui' -import React, {useCallback, useEffect, useRef, useState} from 'react' +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {Path} from '@sanity/types' import {useDocumentPane} from '../../panes/document/useDocumentPane' import {usePaneRouter} from '../../components' import {EMPTY_PARAMS} from '../../constants' import {CommentsInspectorHeader} from './CommentsInspectorHeader' import { - buildCommentBreadcrumbs, CommentCreatePayload, CommentDeleteDialog, CommentEditPayload, @@ -16,7 +15,6 @@ import { DocumentInspectorProps, useComments, useCurrentUser, - useSchema, useUnique, } from 'sanity' @@ -26,14 +24,15 @@ interface CommentToDelete { } export function CommentsInspector(props: DocumentInspectorProps) { - const {onClose, documentType} = props + const {onClose} = props const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [commentToDelete, setCommentToDelete] = useState(null) const [deleteLoading, setDeleteLoading] = useState(false) const [deleteError, setDeleteError] = useState(null) - const {onPathOpen, onFocus, displayed} = useDocumentPane() + const {onPathOpen, onFocus} = useDocumentPane() + const currentUser = useCurrentUser() const paneRouter = usePaneRouter() const params = useUnique(paneRouter.params) || (EMPTY_PARAMS as Partial<{comment?: string}>) @@ -41,21 +40,7 @@ export function CommentsInspector(props: DocumentInspectorProps) { const {comments, create, edit, mentionOptions, remove, update, status, setStatus} = useComments() - const schema = useSchema() - const schemaType = schema.get(documentType) - - const handleBuildCommentBreadcrumbs = useCallback( - (fieldPath: string) => { - if (!schemaType) return [] - - return buildCommentBreadcrumbs({ - fieldPath, - schemaType, - documentValue: displayed, - }) - }, - [displayed, schemaType], - ) + const currentComments = useMemo(() => comments.data[status], [comments, status]) const commentsListHandleRef = useRef(null) const didScrollDown = useRef(false) @@ -120,10 +105,10 @@ export function CommentsInspector(props: DocumentInspectorProps) { setShowDeleteDialog(true) setCommentToDelete({ commentId: id, - isParent: comments.data[status].filter((c) => c.parentCommentId === id).length > 0, + isParent: currentComments.some((c) => c.parentComment._id === id), }) }, - [comments.data, status], + [currentComments], ) const handleDeleteConfirm = useCallback( @@ -158,8 +143,7 @@ export function CommentsInspector(props: DocumentInspectorProps) { {currentUser && ( { )} - {children} + {children} )