Skip to content

Commit

Permalink
feat: validate comments against schema and document value
Browse files Browse the repository at this point in the history
  • Loading branch information
hermanwikner committed Sep 29, 2023
1 parent ae01704 commit f1cb46f
Show file tree
Hide file tree
Showing 15 changed files with 340 additions and 192 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<CommentStatus, CommentStatus> = {open: 'open', resolved: 'resolved'}

export default function CommentsListStory() {
const [state, setState] = useState<CommentDocument[]>(PROPS)
const [state, setState] = useState<CommentThreadItem>(PROPS)

const error = useBoolean('Error', false, 'Props') || null
const loading = useBoolean('Loading', false, 'Props') || false
Expand All @@ -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],
)
Expand All @@ -110,7 +147,7 @@ export default function CommentsListStory() {

return (
<CommentsList
comments={emptyState ? [] : state}
comments={emptyState ? [] : [state]}
currentUser={currentUser}
error={error ? new Error('Something went wrong') : null}
loading={loading}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,6 @@ function Inner() {
onNewThreadCreate={create.execute}
onReply={create.execute}
status="open"
// eslint-disable-next-line react/jsx-no-bind
buildCommentBreadcrumbs={(path) =>
path.split('.').map((p) => ({
title: p,
invalid: false,
isArrayItem: false,
}))
}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -98,7 +98,14 @@ export function CommentThreadLayout(props: CommentThreadLayoutProps) {
{!hasInvalidField && (
<BreadcrumbsFlex align="center" gap={2} paddingX={1} sizing="border">
<Stack flex={1}>
<Breadcrumbs maxLength={3}>
<Breadcrumbs
maxLength={3}
separator={
<Text muted size={1}>
<ChevronRightIcon />
</Text>
}
>
{breadcrumbs?.map((p, index) => {
const idx = `${p.title}-${index}`

Expand Down
104 changes: 40 additions & 64 deletions packages/sanity/src/core/comments/components/list/CommentsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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] = []
Expand All @@ -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
Expand Down Expand Up @@ -86,7 +73,6 @@ export const CommentsList = forwardRef<CommentsListHandle, CommentsListProps>(fu
ref,
) {
const {
buildCommentBreadcrumbs,
comments,
currentUser,
error,
Expand Down Expand Up @@ -120,23 +106,18 @@ export const CommentsList = forwardRef<CommentsListHandle, CommentsListProps>(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 (
<Flex
direction="column"
Expand Down Expand Up @@ -193,50 +174,45 @@ export const CommentsList = forwardRef<CommentsListHandle, CommentsListProps>(fu
space={4}
>
<BoundaryElementProvider element={boundaryElement}>
{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 (
<Stack as="li" data-thread-id={threadId} key={fieldPath}>
<Stack as="li" key={fieldPath}>
<CommentThreadLayout
breadcrumbs={breadcrumbs}
canCreateNewThread={status === 'open'}
currentUser={currentUser}
hasInvalidField={hasInvalidField}
key={fieldPath}
mentionOptions={mentionOptions}
onNewThreadCreate={onNewThreadCreate}
path={PathUtils.fromString(fieldPath)}
>
{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 (
<CommentsListItem
canReply={status === 'open' && !hasInvalidField}
currentUser={currentUser}
key={comment?._id}
mentionOptions={mentionOptions}
onDelete={onDelete}
onEdit={onEdit}
onPathFocus={_onPathFocus}
onReply={onReply}
onStatusChange={onStatusChange}
parentComment={comment}
replies={replies}
/>
<Stack data-thread-id={item.threadId} key={item.threadId}>
<CommentsListItem
canReply={status === 'open'}
currentUser={currentUser}
key={item.parentComment._id}
mentionOptions={mentionOptions}
onDelete={onDelete}
onEdit={onEdit}
onPathFocus={onPathFocus}
onReply={onReply}
onStatusChange={onStatusChange}
parentComment={item.parentComment}
replies={replies}
/>
</Stack>
)
})}
</CommentThreadLayout>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CardProps>(({tone}) => ({
padding: 3,
radius: 3,
sizing: 'border',
tone: tone || 'transparent',
}))<CardProps>`
// ...
`

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.
Expand Down Expand Up @@ -137,7 +129,7 @@ export function CommentsListItem(props: CommentsListItemProps) {

return (
<Stack space={2}>
<RootCard>
<StyledThreadCard>
<Stack paddingBottom={canReply ? undefined : 1}>
<Stack as="ul" space={4}>
<Stack as="li">
Expand Down Expand Up @@ -200,7 +192,7 @@ export function CommentsListItem(props: CommentsListItemProps) {
)}
</Stack>
</Stack>
</RootCard>
</StyledThreadCard>
</Stack>
)
}
Loading

0 comments on commit f1cb46f

Please sign in to comment.