From 188f408d6ef8752449096dc915e5785314574d32 Mon Sep 17 00:00:00 2001 From: Herman Wikner Date: Mon, 11 Sep 2023 18:33:26 +0200 Subject: [PATCH 001/102] feat: comments wip --- .../fieldActions/commentFieldAction.tsx | 22 -- dev/test-studio/sanity.config.ts | 172 +++++++++- packages/sanity/package.json | 1 + .../__workshop__/CommentInputStory.tsx | 45 +++ .../__workshop__/CommentsListStory.tsx | 127 +++++++ .../__workshop__/CommentsProviderStory.tsx | 51 +++ .../__workshop__/MentionOptionsHookStory.tsx | 28 ++ .../__workshop__/MentionsMenuStory.tsx | 32 ++ .../src/core/comments/__workshop__/index.ts | 34 ++ .../components/CommentDeleteDialog.tsx | 70 ++++ .../core/comments/components/TextTooltip.tsx | 34 ++ .../src/core/comments/components/constants.ts | 4 + .../components/icons/AddCommentIcon.tsx | 32 ++ .../comments/components/icons/MentionIcon.tsx | 27 ++ .../comments/components/icons/SendIcon.tsx | 27 ++ .../core/comments/components/icons/index.ts | 3 + .../src/core/comments/components/index.ts | 4 + .../components/list/CommentThreadLayout.tsx | 114 +++++++ .../comments/components/list/CommentsList.tsx | 200 +++++++++++ .../components/list/CommentsListItem.tsx | 197 +++++++++++ .../list/CommentsListItemLayout.tsx | 321 ++++++++++++++++++ .../components/list/CreateNewThreadInput.tsx | 46 +++ .../comments/components/list/constants.tsx | 26 ++ .../core/comments/components/list/index.ts | 2 + .../components/mentions/MentionsMenu.tsx | 127 +++++++ .../components/mentions/MentionsMenuItem.tsx | 53 +++ .../comments/components/mentions/index.ts | 1 + .../comments/components/pte/Serializer.tsx | 68 ++++ .../pte/blocks/MentionInlineBlock.tsx | 57 ++++ .../components/pte/blocks/NormalBlock.tsx | 21 ++ .../comments/components/pte/blocks/index.ts | 2 + .../pte/comment-input/CommentInput.tsx | 126 +++++++ .../pte/comment-input/CommentInputInner.tsx | 148 ++++++++ .../comment-input/CommentInputProvider.tsx | 220 ++++++++++++ .../components/pte/comment-input/Editable.tsx | 134 ++++++++ .../components/pte/comment-input/index.ts | 3 + .../pte/comment-input/useCommentInput.ts | 12 + .../pte/comment-input/useCursorElement.ts | 86 +++++ .../core/comments/components/pte/config.ts | 33 ++ .../src/core/comments/components/pte/index.ts | 2 + .../comments/components/pte/render/index.ts | 2 + .../components/pte/render/renderBlock.tsx | 9 + .../components/pte/render/renderChild.tsx | 15 + .../core/comments/context/CommentsContext.ts | 4 + .../comments/context/CommentsProvider.tsx | 156 +++++++++ .../sanity/src/core/comments/context/index.ts | 2 + packages/sanity/src/core/comments/helpers.ts | 9 + .../sanity/src/core/comments/hooks/index.ts | 6 + .../hooks/use-mention-options/helpers.ts | 77 +++++ .../hooks/use-mention-options/index.ts | 1 + .../use-mention-options/useMentionOptions.ts | 109 ++++++ .../comments/hooks/useCommentOperations.ts | 170 ++++++++++ .../src/core/comments/hooks/useComments.ts | 17 + .../core/comments/hooks/useCommentsClient.ts | 21 ++ .../core/comments/hooks/useCommentsEnabled.ts | 58 ++++ .../comments/hooks/useFieldCommentsCount.ts | 23 ++ .../comments/hooks/useNotificationTarget.ts | 60 ++++ packages/sanity/src/core/comments/index.ts | 14 + .../sanity/src/core/comments/store/index.ts | 1 + .../sanity/src/core/comments/store/reducer.ts | 113 ++++++ .../core/comments/store/useCommentsStore.ts | 133 ++++++++ packages/sanity/src/core/comments/types.ts | 145 ++++++++ .../src/core/config/configPropertyReducers.ts | 31 ++ .../sanity/src/core/config/prepareConfig.ts | 11 + packages/sanity/src/core/config/types.ts | 16 + .../form/components/formField/FormField.tsx | 6 +- .../formField/FormFieldBaseHeader.tsx | 86 +++-- .../components/formField/FormFieldSet.tsx | 7 +- .../src/core/form/hooks/useDidUpdate.ts | 5 + packages/sanity/src/core/form/index.ts | 1 + .../CrossDatasetReferenceInput.tsx | 2 +- .../inputs/ReferenceInput/ReferenceField.tsx | 101 +++--- .../studio/inputResolver/fieldResolver.tsx | 3 + .../sanity/src/core/form/types/fieldProps.ts | 9 + packages/sanity/src/core/hooks/index.ts | 1 + .../useFeatureEnabled.ts | 5 +- packages/sanity/src/core/index.ts | 2 + .../src/core/store/_legacy/project/types.ts | 2 + .../src/desk/comments/field/CommentField.tsx | 87 +++++ .../comments/field/CommentFieldButton.tsx | 187 ++++++++++ .../sanity/src/desk/comments/field/index.ts | 1 + packages/sanity/src/desk/comments/index.ts | 1 + .../comments/inspector/CommentsInspector.tsx | 146 ++++++++ .../inspector/CommentsInspectorHeader.tsx | 88 +++++ .../src/desk/comments/inspector/index.ts | 31 ++ packages/sanity/src/desk/comments/plugin.ts | 15 + packages/sanity/src/desk/deskTool.ts | 6 +- .../panes/document/DocumentPaneProvider.tsx | 31 +- .../src/desk/panes/document/constants.ts | 1 + packages/sanity/src/desk/router.ts | 2 +- 90 files changed, 4612 insertions(+), 129 deletions(-) delete mode 100644 dev/test-studio/fieldActions/commentFieldAction.tsx create mode 100644 packages/sanity/src/core/comments/__workshop__/CommentInputStory.tsx create mode 100644 packages/sanity/src/core/comments/__workshop__/CommentsListStory.tsx create mode 100644 packages/sanity/src/core/comments/__workshop__/CommentsProviderStory.tsx create mode 100644 packages/sanity/src/core/comments/__workshop__/MentionOptionsHookStory.tsx create mode 100644 packages/sanity/src/core/comments/__workshop__/MentionsMenuStory.tsx create mode 100644 packages/sanity/src/core/comments/__workshop__/index.ts create mode 100644 packages/sanity/src/core/comments/components/CommentDeleteDialog.tsx create mode 100644 packages/sanity/src/core/comments/components/TextTooltip.tsx create mode 100644 packages/sanity/src/core/comments/components/constants.ts create mode 100644 packages/sanity/src/core/comments/components/icons/AddCommentIcon.tsx create mode 100644 packages/sanity/src/core/comments/components/icons/MentionIcon.tsx create mode 100644 packages/sanity/src/core/comments/components/icons/SendIcon.tsx create mode 100644 packages/sanity/src/core/comments/components/icons/index.ts create mode 100644 packages/sanity/src/core/comments/components/index.ts create mode 100644 packages/sanity/src/core/comments/components/list/CommentThreadLayout.tsx create mode 100644 packages/sanity/src/core/comments/components/list/CommentsList.tsx create mode 100644 packages/sanity/src/core/comments/components/list/CommentsListItem.tsx create mode 100644 packages/sanity/src/core/comments/components/list/CommentsListItemLayout.tsx create mode 100644 packages/sanity/src/core/comments/components/list/CreateNewThreadInput.tsx create mode 100644 packages/sanity/src/core/comments/components/list/constants.tsx create mode 100644 packages/sanity/src/core/comments/components/list/index.ts create mode 100644 packages/sanity/src/core/comments/components/mentions/MentionsMenu.tsx create mode 100644 packages/sanity/src/core/comments/components/mentions/MentionsMenuItem.tsx create mode 100644 packages/sanity/src/core/comments/components/mentions/index.ts create mode 100644 packages/sanity/src/core/comments/components/pte/Serializer.tsx create mode 100644 packages/sanity/src/core/comments/components/pte/blocks/MentionInlineBlock.tsx create mode 100644 packages/sanity/src/core/comments/components/pte/blocks/NormalBlock.tsx create mode 100644 packages/sanity/src/core/comments/components/pte/blocks/index.ts create mode 100644 packages/sanity/src/core/comments/components/pte/comment-input/CommentInput.tsx create mode 100644 packages/sanity/src/core/comments/components/pte/comment-input/CommentInputInner.tsx create mode 100644 packages/sanity/src/core/comments/components/pte/comment-input/CommentInputProvider.tsx create mode 100644 packages/sanity/src/core/comments/components/pte/comment-input/Editable.tsx create mode 100644 packages/sanity/src/core/comments/components/pte/comment-input/index.ts create mode 100644 packages/sanity/src/core/comments/components/pte/comment-input/useCommentInput.ts create mode 100644 packages/sanity/src/core/comments/components/pte/comment-input/useCursorElement.ts create mode 100644 packages/sanity/src/core/comments/components/pte/config.ts create mode 100644 packages/sanity/src/core/comments/components/pte/index.ts create mode 100644 packages/sanity/src/core/comments/components/pte/render/index.ts create mode 100644 packages/sanity/src/core/comments/components/pte/render/renderBlock.tsx create mode 100644 packages/sanity/src/core/comments/components/pte/render/renderChild.tsx create mode 100644 packages/sanity/src/core/comments/context/CommentsContext.ts create mode 100644 packages/sanity/src/core/comments/context/CommentsProvider.tsx create mode 100644 packages/sanity/src/core/comments/context/index.ts create mode 100644 packages/sanity/src/core/comments/helpers.ts create mode 100644 packages/sanity/src/core/comments/hooks/index.ts create mode 100644 packages/sanity/src/core/comments/hooks/use-mention-options/helpers.ts create mode 100644 packages/sanity/src/core/comments/hooks/use-mention-options/index.ts create mode 100644 packages/sanity/src/core/comments/hooks/use-mention-options/useMentionOptions.ts create mode 100644 packages/sanity/src/core/comments/hooks/useCommentOperations.ts create mode 100644 packages/sanity/src/core/comments/hooks/useComments.ts create mode 100644 packages/sanity/src/core/comments/hooks/useCommentsClient.ts create mode 100644 packages/sanity/src/core/comments/hooks/useCommentsEnabled.ts create mode 100644 packages/sanity/src/core/comments/hooks/useFieldCommentsCount.ts create mode 100644 packages/sanity/src/core/comments/hooks/useNotificationTarget.ts create mode 100644 packages/sanity/src/core/comments/index.ts create mode 100644 packages/sanity/src/core/comments/store/index.ts create mode 100644 packages/sanity/src/core/comments/store/reducer.ts create mode 100644 packages/sanity/src/core/comments/store/useCommentsStore.ts create mode 100644 packages/sanity/src/core/comments/types.ts rename packages/sanity/src/core/{form/inputs/CrossDatasetReferenceInput => hooks}/useFeatureEnabled.ts (92%) create mode 100644 packages/sanity/src/desk/comments/field/CommentField.tsx create mode 100644 packages/sanity/src/desk/comments/field/CommentFieldButton.tsx create mode 100644 packages/sanity/src/desk/comments/field/index.ts create mode 100644 packages/sanity/src/desk/comments/index.ts create mode 100644 packages/sanity/src/desk/comments/inspector/CommentsInspector.tsx create mode 100644 packages/sanity/src/desk/comments/inspector/CommentsInspectorHeader.tsx create mode 100644 packages/sanity/src/desk/comments/inspector/index.ts create mode 100644 packages/sanity/src/desk/comments/plugin.ts diff --git a/dev/test-studio/fieldActions/commentFieldAction.tsx b/dev/test-studio/fieldActions/commentFieldAction.tsx deleted file mode 100644 index 6bb6b356923..00000000000 --- a/dev/test-studio/fieldActions/commentFieldAction.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import {CommentIcon} from '@sanity/icons' -import {useCallback} from 'react' -import {defineDocumentFieldAction} from 'sanity' -import {defineActionItem} from './define' - -export const commentAction = defineDocumentFieldAction({ - name: 'test/comment', - useAction({documentId, documentType, path}) { - const onAction = useCallback(() => { - // eslint-disable-next-line no-console - console.log('comment', {documentId, documentType, path}) - }, [documentId, documentType, path]) - - return defineActionItem({ - type: 'action', - icon: CommentIcon, - onAction, - title: 'Comment', - renderAsButton: true, - }) - }, -}) diff --git a/dev/test-studio/sanity.config.ts b/dev/test-studio/sanity.config.ts index 12c708ed827..55e56fcc0f9 100644 --- a/dev/test-studio/sanity.config.ts +++ b/dev/test-studio/sanity.config.ts @@ -1,4 +1,4 @@ -import {BookIcon} from '@sanity/icons' +import {BookIcon, CheckmarkCircleIcon, CircleIcon} from '@sanity/icons' import {visionTool} from '@sanity/vision' import {defineConfig, definePlugin} from 'sanity' import {deskTool} from 'sanity/desk' @@ -39,7 +39,6 @@ import {vercelTheme} from './themes/vercel' import {GoogleLogo, TailwindLogo, VercelLogo} from './components/workspaceLogos' import {copyAction} from './fieldActions/copyAction' import {assistFieldActionGroup} from './fieldActions/assistFieldActionGroup' -import {commentAction} from './fieldActions/commentFieldAction' import {customInspector} from './inspectors/custom' import {pasteAction} from './fieldActions/pasteAction' @@ -70,7 +69,7 @@ const sharedSettings = definePlugin({ }, unstable_fieldActions: (prev, ctx) => { if (['fieldActionsTest', 'stringsTest'].includes(ctx.documentType)) { - return [...prev, commentAction, assistFieldActionGroup, copyAction, pasteAction] + return [...prev, assistFieldActionGroup, copyAction, pasteAction] } return prev @@ -132,6 +131,173 @@ export default defineConfig([ plugins: [sharedSettings()], basePath: '/test', }, + // Temporary comments (metacontent) workspace + { + name: 'default-metacontent-comments', + title: 'Comments (metacontent)', + projectId: 'ppsg7ml5', + dataset: 'test-metacontent-comments', + plugins: [deskTool(), visionTool()], + schema: { + types: [ + { + name: 'comment', + type: 'document', + title: 'Comment', + fields: [ + { + name: 'threadId', + title: 'Thread ID', + type: 'string', + }, + { + name: 'parentCommentId', + title: 'Parent comment ID', + type: 'string', + }, + { + name: 'authorId', + title: 'Author ID', + type: 'string', + }, + { + name: 'editedAt', + title: 'Last edited', + type: 'date', + }, + { + name: 'message', + title: 'Message', + type: 'array', + of: [ + { + type: 'block', + marks: { + annotations: [ + { + name: 'mention', + type: 'object', + title: 'Mention', + fields: [ + { + name: 'userId', + type: 'string', + }, + ], + }, + ], + }, + }, + ], + }, + { + name: 'status', + title: 'Status', + type: 'string', + options: { + layout: 'radio', + list: ['open', 'resolved'], + }, + }, + { + name: 'workspace', + title: 'Workspace', + type: 'string', + }, + { + name: 'target', + title: 'Target', + type: 'object', + fields: [ + { + name: 'documentId', + title: 'Document ID', + type: 'string', + readOnly: true, + }, + { + name: 'documentType', + title: 'Document type', + type: 'string', + readOnly: true, + }, + { + name: 'path', + title: 'Path', + type: 'object', + fields: [ + { + name: 'field', + title: 'Field', + type: 'string', + }, + ], + }, + ], + options: { + collapsible: true, + }, + }, + { + name: 'context', + title: 'Context', + type: 'object', + fields: [ + { + name: 'type', + title: 'Type', + type: 'string', + }, + { + name: 'name', + title: 'Name', + type: 'string', + }, + ], + options: { + collapsible: true, + }, + }, + { + name: 'notification', + title: 'Notification', + type: 'object', + fields: [ + { + name: 'title', + title: 'Title', + type: 'string', + }, + { + name: 'url', + title: 'URL', + type: 'string', + }, + ], + options: { + collapsible: true, + }, + }, + ], + preview: { + select: { + id: '_id', + status: 'status', + threadId: 'threadId', + }, + prepare({id, status, threadId}) { + return { + media: status === 'resolved' ? CheckmarkCircleIcon : CircleIcon, + subtitle: `Comment: ${id}`, + title: `Thread: ${threadId}`, + } + }, + }, + }, + ], + }, + basePath: '/test-metacontent-comments', + }, { name: 'tsdoc', title: 'tsdoc', diff --git a/packages/sanity/package.json b/packages/sanity/package.json index cb04a1e2115..d05df7be6a5 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -151,6 +151,7 @@ "@sanity/logos": "^2.0.2", "@sanity/mutator": "3.18.1", "@sanity/portable-text-editor": "3.18.1", + "@portabletext/react": "^3.0.0", "@sanity/schema": "3.18.1", "@sanity/types": "3.18.1", "@sanity/ui": "^1.8.3", diff --git a/packages/sanity/src/core/comments/__workshop__/CommentInputStory.tsx b/packages/sanity/src/core/comments/__workshop__/CommentInputStory.tsx new file mode 100644 index 00000000000..f8b7bccaad1 --- /dev/null +++ b/packages/sanity/src/core/comments/__workshop__/CommentInputStory.tsx @@ -0,0 +1,45 @@ +import React, {useState} from 'react' +import {Card, Container, Flex} from '@sanity/ui' +import {PortableTextBlock} from '@sanity/types' +import {useBoolean} from '@sanity/ui-workshop' +import {CommentInput} from '../components' +import {CommentMessageSerializer} from '../components/pte' +import {useCurrentUser} from '../../store' + +export default function CommentsInputStory() { + const [value, setValue] = useState(null) + const currentUser = useCurrentUser() + const expandOnFocus = useBoolean('Expand on focus', false, 'Props') + + if (!currentUser) return null + + return ( + + + + { + // ... + }} + onSubmit={() => { + // ... + }} + /> + + + + + + + + + + + + ) +} diff --git a/packages/sanity/src/core/comments/__workshop__/CommentsListStory.tsx b/packages/sanity/src/core/comments/__workshop__/CommentsListStory.tsx new file mode 100644 index 00000000000..a4011d2b849 --- /dev/null +++ b/packages/sanity/src/core/comments/__workshop__/CommentsListStory.tsx @@ -0,0 +1,127 @@ +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' + +const BASE: CommentDocument = { + _id: '1', + _type: 'comment', + _createdAt: new Date().toISOString(), + _updatedAt: '2021-05-04T14:54:37Z', + authorId: 'p8U8TipFc', + status: 'open', + _rev: '1', + + threadId: '1', + + target: { + documentType: 'article', + path: { + field: JSON.stringify([]), + }, + document: { + _dataset: '1', + _projectId: '1', + _ref: '1', + _type: 'crossDatasetReference', + _weak: true, + }, + }, + message: [ + { + _type: 'block', + _key: '36a3f0d3832d', + style: 'normal', + markDefs: [], + children: [ + { + _type: 'span', + _key: '89014dd684ce', + text: 'My first comment', + marks: [], + }, + ], + }, + ], +} + +const PROPS = [ + { + ...BASE, + }, +] + +const STATUS_OPTIONS: Record = {open: 'open', resolved: 'resolved'} + +export default function CommentsListStory() { + const [state, setState] = useState(PROPS) + + const error = useBoolean('Error', false, 'Props') || null + const loading = useBoolean('Loading', false, 'Props') || false + const emptyState = useBoolean('Empty', false, 'Props') || false + const status = useSelect('Status', STATUS_OPTIONS, 'open', 'Props') || 'open' + + const currentUser = useCurrentUser() + + const handleReplySubmit = useCallback( + (payload: CommentCreatePayload) => { + const comment: CommentDocument = { + ...BASE, + ...payload, + _createdAt: new Date().toISOString(), + _id: `${state.length + 1}`, + authorId: currentUser?.id || 'pP5s3g90N', + parentCommentId: payload.parentCommentId, + } + + setState((prev) => [...prev, comment]) + }, + [currentUser?.id, state.length], + ) + + const handleEdit = useCallback( + (id: string, payload: CommentEditPayload) => { + setState((prev) => + prev.map((item) => { + if (item._id === id) { + return { + ...item, + ...payload, + _updatedAt: new Date().toISOString(), + } + } + + return item + }), + ) + }, + [setState], + ) + + const handleDelete = useCallback( + (id: string) => { + setState((prev) => prev.filter((item) => item._id !== id)) + }, + [setState], + ) + + if (!currentUser) return null + + return ( + { + // ... + }} + /> + ) +} diff --git a/packages/sanity/src/core/comments/__workshop__/CommentsProviderStory.tsx b/packages/sanity/src/core/comments/__workshop__/CommentsProviderStory.tsx new file mode 100644 index 00000000000..6b90f88fbc2 --- /dev/null +++ b/packages/sanity/src/core/comments/__workshop__/CommentsProviderStory.tsx @@ -0,0 +1,51 @@ +/* eslint-disable react/jsx-handler-names */ +import React, {useMemo} from 'react' +import {useString} from '@sanity/ui-workshop' +import {useCurrentUser} from '../../store' +import {CommentsList} from '../components' +import {CommentsProvider} from '../context' +import {useComments} from '../hooks' + +export default function CommentsProviderStory() { + const _type = useString('_type', 'author') || 'author' + const _id = useString('_id', 'grrm') || 'grrm' + + const documentValue = useMemo( + () => ({ + _id, + _type, + _createdAt: '2021-01-01T00:00:00Z', + _updatedAt: '2021-01-01T00:00:00Z', + _rev: '', + }), + [_id, _type], + ) + + return ( + + + + ) +} + +function Inner() { + const {comments, create, edit, mentionOptions, remove} = useComments() + const currentUser = useCurrentUser() + + if (!currentUser) return null + + return ( + + ) +} diff --git a/packages/sanity/src/core/comments/__workshop__/MentionOptionsHookStory.tsx b/packages/sanity/src/core/comments/__workshop__/MentionOptionsHookStory.tsx new file mode 100644 index 00000000000..fa1a32751d0 --- /dev/null +++ b/packages/sanity/src/core/comments/__workshop__/MentionOptionsHookStory.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import {Card, Code} from '@sanity/ui' +import {useMentionOptions} from '../hooks' + +const DOCUMENT = { + _id: '1e1744ab-43d5-4fff-8a2a-28c58bf0434a', + _type: 'author', + _rev: '1', + _createdAt: '2021-05-04T14:54:37Z', + _updatedAt: '2021-05-04T14:54:37Z', +} + +export default function MentionOptionsHookStory() { + const {data, loading} = useMentionOptions({ + documentValue: DOCUMENT, + }) + + if (loading) return
Loading...
+ if (!data) return
No data
+ + return ( + + + {JSON.stringify(data, null, 2)} + + + ) +} diff --git a/packages/sanity/src/core/comments/__workshop__/MentionsMenuStory.tsx b/packages/sanity/src/core/comments/__workshop__/MentionsMenuStory.tsx new file mode 100644 index 00000000000..948b09c9578 --- /dev/null +++ b/packages/sanity/src/core/comments/__workshop__/MentionsMenuStory.tsx @@ -0,0 +1,32 @@ +import {Container, Flex} from '@sanity/ui' +import React from 'react' +import {MentionsMenu} from '../components/mentions' +import {useMentionOptions} from '../hooks' + +const DOC = { + documentValue: { + _id: 'xyz123', + _type: 'author', + _rev: '1', + _createdAt: '2021-05-04T14:54:37Z', + _updatedAt: '2021-05-04T14:54:37Z', + }, +} + +export default function MentionsMenuStory() { + const {data, loading} = useMentionOptions(DOC) + + return ( + + + { + //... + }} + /> + + + ) +} diff --git a/packages/sanity/src/core/comments/__workshop__/index.ts b/packages/sanity/src/core/comments/__workshop__/index.ts new file mode 100644 index 00000000000..9c0ab1e89c7 --- /dev/null +++ b/packages/sanity/src/core/comments/__workshop__/index.ts @@ -0,0 +1,34 @@ +import {defineScope} from '@sanity/ui-workshop' +import {lazy} from 'react' + +export default defineScope({ + name: 'core/comments', + title: 'comments', + stories: [ + { + name: 'comments-provider', + title: 'CommentsProvider', + component: lazy(() => import('./CommentsProviderStory')), + }, + { + name: 'comments-input', + title: 'CommentsInput', + component: lazy(() => import('./CommentInputStory')), + }, + { + name: 'mention-options-hook', + title: 'useMentionOptions', + component: lazy(() => import('./MentionOptionsHookStory')), + }, + { + name: 'comments-list', + title: 'CommentsList', + component: lazy(() => import('./CommentsListStory')), + }, + { + name: 'mentions-menu', + title: 'MentionsMenu', + component: lazy(() => import('./MentionsMenuStory')), + }, + ], +}) diff --git a/packages/sanity/src/core/comments/components/CommentDeleteDialog.tsx b/packages/sanity/src/core/comments/components/CommentDeleteDialog.tsx new file mode 100644 index 00000000000..a943b3aa7d7 --- /dev/null +++ b/packages/sanity/src/core/comments/components/CommentDeleteDialog.tsx @@ -0,0 +1,70 @@ +import {Dialog, Grid, Button, Stack, Text} from '@sanity/ui' +import React, {useCallback} from 'react' + +const DIALOG_COPY: Record< + 'thread' | 'comment', + {title: string; body: string; confirmButtonText: string} +> = { + thread: { + title: 'Delete this comment thread?', + body: 'All comments in this thread will be deleted, and once deleted cannot be recovered.', + confirmButtonText: 'Delete thread', + }, + comment: { + title: 'Delete this comment?', + body: 'Once deleted, a comment cannot be recovered.', + confirmButtonText: 'Delete comment', + }, +} + +/** + * @beta + * @hidden + */ +export interface CommentDeleteDialogProps { + commentId: string + error: Error | null + isParent: boolean + loading: boolean + onClose: () => void + onConfirm: (id: string) => void +} + +/** + * @beta + * @hidden + */ +export function CommentDeleteDialog(props: CommentDeleteDialogProps) { + const {isParent, onClose, commentId, onConfirm, loading, error} = props + const {title, body, confirmButtonText} = DIALOG_COPY[isParent ? 'thread' : 'comment'] + + const handleDelete = useCallback(() => { + onConfirm(commentId) + }, [commentId, onConfirm]) + + return ( + + + ) +} diff --git a/packages/sanity/src/core/comments/components/TextTooltip.tsx b/packages/sanity/src/core/comments/components/TextTooltip.tsx new file mode 100644 index 00000000000..dbb6ea2fd96 --- /dev/null +++ b/packages/sanity/src/core/comments/components/TextTooltip.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import {Box, Flex, Text, Tooltip} from '@sanity/ui' +import styled from 'styled-components' + +const TextBox = styled(Box)` + // There is currently an issue with the Tooltip component in @sanity/ui where it + // animates the tooltip width on mount when it needs to reposition itself. + // Adding a the CSS below to the tooltip content prevents this from happening. + width: max-content; +` + +interface TextTooltipProps { + children: React.ReactNode + text?: string +} + +export function TextTooltip(props: TextTooltipProps) { + const {children, text} = props + + return ( + + {text} + + } + > + {children} + + ) +} diff --git a/packages/sanity/src/core/comments/components/constants.ts b/packages/sanity/src/core/comments/components/constants.ts new file mode 100644 index 00000000000..2377a8c1d06 --- /dev/null +++ b/packages/sanity/src/core/comments/components/constants.ts @@ -0,0 +1,4 @@ +import {FlexProps} from '@sanity/ui' + +export const FLEX_GAP: FlexProps['gap'] = 3 +export const AVATAR_SIZE = 25 diff --git a/packages/sanity/src/core/comments/components/icons/AddCommentIcon.tsx b/packages/sanity/src/core/comments/components/icons/AddCommentIcon.tsx new file mode 100644 index 00000000000..585eb47adf8 --- /dev/null +++ b/packages/sanity/src/core/comments/components/icons/AddCommentIcon.tsx @@ -0,0 +1,32 @@ +import React, {forwardRef} from 'react' + +export const AddCommentIcon = forwardRef(function Icon( + props: React.SVGProps, + ref: React.Ref, +) { + return ( + + + + + ) +}) diff --git a/packages/sanity/src/core/comments/components/icons/MentionIcon.tsx b/packages/sanity/src/core/comments/components/icons/MentionIcon.tsx new file mode 100644 index 00000000000..60c1e0a6173 --- /dev/null +++ b/packages/sanity/src/core/comments/components/icons/MentionIcon.tsx @@ -0,0 +1,27 @@ +import React, {forwardRef} from 'react' + +export const MentionIcon = forwardRef(function Icon( + props: React.SVGProps, + ref: React.Ref, +) { + return ( + + + + + ) +}) diff --git a/packages/sanity/src/core/comments/components/icons/SendIcon.tsx b/packages/sanity/src/core/comments/components/icons/SendIcon.tsx new file mode 100644 index 00000000000..ad0535013e5 --- /dev/null +++ b/packages/sanity/src/core/comments/components/icons/SendIcon.tsx @@ -0,0 +1,27 @@ +import React, {forwardRef} from 'react' + +export const SendIcon = forwardRef(function Icon( + props: React.SVGProps, + ref: React.Ref, +) { + return ( + + + + ) +}) diff --git a/packages/sanity/src/core/comments/components/icons/index.ts b/packages/sanity/src/core/comments/components/icons/index.ts new file mode 100644 index 00000000000..31d5cbb43aa --- /dev/null +++ b/packages/sanity/src/core/comments/components/icons/index.ts @@ -0,0 +1,3 @@ +export * from './SendIcon' +export * from './MentionIcon' +export * from './AddCommentIcon' diff --git a/packages/sanity/src/core/comments/components/index.ts b/packages/sanity/src/core/comments/components/index.ts new file mode 100644 index 00000000000..4795461be5e --- /dev/null +++ b/packages/sanity/src/core/comments/components/index.ts @@ -0,0 +1,4 @@ +export * from './pte' +export * from './list' +export * from './CommentDeleteDialog' +export * from './TextTooltip' diff --git a/packages/sanity/src/core/comments/components/list/CommentThreadLayout.tsx b/packages/sanity/src/core/comments/components/list/CommentThreadLayout.tsx new file mode 100644 index 00000000000..1324c18d045 --- /dev/null +++ b/packages/sanity/src/core/comments/components/list/CommentThreadLayout.tsx @@ -0,0 +1,114 @@ +import {CurrentUser, Path} from '@sanity/types' +import {Box, Breadcrumbs, Button, Flex, Stack, Text} from '@sanity/ui' +import {startCase} from 'lodash' +import React, {useCallback, useRef, useState} from 'react' +import {uuid} from '@sanity/uuid' +import * as PathUtils from '@sanity/util/paths' +import {AddCommentIcon} from '../icons' +import {MentionOptionsHookValue} from '../../hooks' +import {CommentInputHandle} from '../pte' +import {CommentMessage, CommentCreatePayload} from '../../types' +import {TextTooltip} from '../TextTooltip' +import {CreateNewThreadInput} from './CreateNewThreadInput' +import {ThreadCard} from './CommentsListItem' + +interface CommentThreadLayoutProps { + children: React.ReactNode + currentUser: CurrentUser + mentionOptions: MentionOptionsHookValue + onNewThreadCreate: (payload: CommentCreatePayload) => void + path: Path +} + +export function CommentThreadLayout(props: CommentThreadLayoutProps) { + const {children, path, currentUser, mentionOptions, onNewThreadCreate} = props + const createNewThreadInputRef = useRef(null) + const [displayNewThreadInput, setDisplayNewThreadInput] = useState(false) + + const onCreateNewThreadClick = useCallback(() => { + setDisplayNewThreadInput(true) + + // We need to wait for the next tick to focus the input + const raf = requestAnimationFrame(() => { + createNewThreadInputRef.current?.focus() + createNewThreadInputRef.current?.scrollTo() + }) + + return () => { + cancelAnimationFrame(raf) + } + }, []) + + const handleNewThreadCreateDiscard = useCallback(() => { + setDisplayNewThreadInput(false) + }, []) + + const handleNewThreadCreate = useCallback( + (payload: CommentMessage) => { + const nextComment: CommentCreatePayload = { + fieldPath: PathUtils.toString(path), + message: payload, + parentCommentId: undefined, + status: 'open', + // Since this is a new comment, we generate a new thread ID + threadId: uuid(), + } + + onNewThreadCreate?.(nextComment) + setDisplayNewThreadInput(false) + }, + [onNewThreadCreate, path], + ) + + return ( + + + + + {path.map((p, index) => { + const pathSegment = p.toString() + const idx = `${pathSegment}-${index}` + + // If the path segment is an object, we don't want to render it since it + // is not human readable, e.g: {_key: 'xyz} + if (typeof p === 'object') return null + + return ( + + + {startCase(pathSegment)} + + + ) + })} + + + + + ) } diff --git a/packages/sanity/src/desk/comments/inspector/CommentsInspectorHeader.tsx b/packages/sanity/src/desk/comments/inspector/CommentsInspectorHeader.tsx index 4ce3e409b29..0ab1178c08f 100644 --- a/packages/sanity/src/desk/comments/inspector/CommentsInspectorHeader.tsx +++ b/packages/sanity/src/desk/comments/inspector/CommentsInspectorHeader.tsx @@ -56,7 +56,7 @@ export function CommentsInspectorHeader(props: CommentsInspectorHeaderProps) { /> } menu={ - + } + popover={{placement: 'bottom-end'}} /> } popover={POPOVER_PROPS} diff --git a/packages/sanity/src/desk/comments/src/components/list/CommentsListStatus.tsx b/packages/sanity/src/desk/comments/src/components/list/CommentsListStatus.tsx new file mode 100644 index 00000000000..6de29a1a092 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/list/CommentsListStatus.tsx @@ -0,0 +1,86 @@ +import {Flex, Container, Stack, Spinner, Text} from '@sanity/ui' +import React from 'react' +import {CommentStatus} from '../../types' + +interface EmptyStateMessage { + title: string + message: React.ReactNode +} + +export const EMPTY_STATE_MESSAGES: Record = { + open: { + title: 'No open comments yet', + message: ( + + Open comments on this document
+ will be shown here. +
+ ), + }, + resolved: { + title: 'No resolved comments yet', + message: ( + <> + Resolved comments on this document
+ will be shown here. + + ), + }, +} + +interface CommentsListStatusProps { + error: Error | null + hasNoComments: boolean + loading: boolean + status: CommentStatus +} + +export function CommentsListStatus(props: CommentsListStatusProps) { + const {status, error, loading, hasNoComments} = props + + if (error) { + return ( + + + + Something went wrong + + + + ) + } + + if (loading) { + return ( + + + + + + Loading comments... + + + + ) + } + + if (hasNoComments) { + return ( + + + + + {EMPTY_STATE_MESSAGES[status].title} + + + + {EMPTY_STATE_MESSAGES[status].message} + + + + + ) + } + + return null +} diff --git a/packages/sanity/src/desk/comments/src/components/list/constants.tsx b/packages/sanity/src/desk/comments/src/components/list/constants.tsx deleted file mode 100644 index 9ff17e687f5..00000000000 --- a/packages/sanity/src/desk/comments/src/components/list/constants.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react' -import {CommentStatus} from '../../types' - -export const EMPTY_STATE_MESSAGES: Record< - CommentStatus, - {title: string; message: React.ReactNode} -> = { - open: { - title: 'No open comments yet', - message: ( - - Open comments on this document
- will be shown here. -
- ), - }, - resolved: { - title: 'No resolved comments yet', - message: ( - <> - Resolved comments on this document
- will be shown here. - - ), - }, -} From a424d45221acd89a5c398f9c23f3d355d5874377 Mon Sep 17 00:00:00 2001 From: Herman Wikner Date: Fri, 13 Oct 2023 11:49:59 +0200 Subject: [PATCH 047/102] refactor: export `useMentionOptions` options interface --- .../comments/src/hooks/use-mention-options/useMentionOptions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sanity/src/desk/comments/src/hooks/use-mention-options/useMentionOptions.ts b/packages/sanity/src/desk/comments/src/hooks/use-mention-options/useMentionOptions.ts index 27f1434fce0..b01f7ec785c 100644 --- a/packages/sanity/src/desk/comments/src/hooks/use-mention-options/useMentionOptions.ts +++ b/packages/sanity/src/desk/comments/src/hooks/use-mention-options/useMentionOptions.ts @@ -19,7 +19,7 @@ const INITIAL_STATE: MentionOptionsHookValue = { loading: true, } -interface MentionHookOptions { +export interface MentionHookOptions { documentValue: SanityDocument | null } From 1d576545de242878c70a481869bee8ff5b3f955d Mon Sep 17 00:00:00 2001 From: Herman Wikner Date: Fri, 13 Oct 2023 11:50:30 +0200 Subject: [PATCH 048/102] refactor: update comment workshop stories --- .../src/__workshop__/CommentsListStory.tsx | 206 +++++++++++------- .../__workshop__/CommentsProviderStory.tsx | 10 +- 2 files changed, 133 insertions(+), 83 deletions(-) diff --git a/packages/sanity/src/desk/comments/src/__workshop__/CommentsListStory.tsx b/packages/sanity/src/desk/comments/src/__workshop__/CommentsListStory.tsx index df571071e3d..292f8b1407a 100644 --- a/packages/sanity/src/desk/comments/src/__workshop__/CommentsListStory.tsx +++ b/packages/sanity/src/desk/comments/src/__workshop__/CommentsListStory.tsx @@ -1,15 +1,35 @@ -import React, {useCallback, useState} from 'react' +import React, {useCallback, useMemo, useState} from 'react' import {useBoolean, useSelect} from '@sanity/ui-workshop' +import {Schema} from '@sanity/schema' +import {uuid} from '@sanity/uuid' +import {Container, Flex} from '@sanity/ui' import {CommentsList} from '../components' -import { - CommentDocument, - CommentCreatePayload, - CommentEditPayload, - CommentStatus, - CommentThreadItem, -} from '../types' +import {CommentDocument, CommentCreatePayload, CommentEditPayload, CommentStatus} from '../types' +import {buildCommentThreadItems} from '../utils/buildCommentThreadItems' +import {useMentionOptions} from '../hooks' import {useCurrentUser} from 'sanity' +const noop = () => { + // noop +} + +const schema = Schema.compile({ + name: 'default', + types: [ + { + type: 'document', + name: 'article', + fields: [ + { + name: 'title', + type: 'string', + title: 'My string title', + }, + ], + }, + ], +}) + const BASE: CommentDocument = { _id: '1', _type: 'comment', @@ -24,7 +44,7 @@ const BASE: CommentDocument = { target: { documentType: 'article', path: { - field: JSON.stringify([]), + field: 'title', }, document: { _dataset: '1', @@ -52,19 +72,20 @@ const BASE: CommentDocument = { ], } -const PROPS: CommentThreadItem = { - parentComment: BASE, - breadcrumbs: [], - commentsCount: 1, - fieldPath: 'test', - replies: [], - threadId: '1', +const MENTION_HOOK_OPTIONS = { + documentValue: { + _type: 'author', + _id: 'grrm', + _createdAt: '2021-05-04T14:54:37Z', + _rev: '1', + _updatedAt: '2021-05-04T14:54:37Z', + }, } const STATUS_OPTIONS: Record = {open: 'open', resolved: 'resolved'} export default function CommentsListStory() { - const [state, setState] = useState(PROPS) + const [state, setState] = useState([BASE]) const error = useBoolean('Error', false, 'Props') || null const loading = useBoolean('Loading', false, 'Props') || false @@ -73,95 +94,122 @@ export default function CommentsListStory() { const currentUser = useCurrentUser() + const mentionOptions = useMentionOptions(MENTION_HOOK_OPTIONS) + const handleReplySubmit = useCallback( (payload: CommentCreatePayload) => { - const comment: CommentDocument = { + const reply: CommentDocument = { ...BASE, ...payload, _createdAt: new Date().toISOString(), - _id: `${state.commentsCount + 1}`, + _id: uuid(), authorId: currentUser?.id || 'pP5s3g90N', parentCommentId: payload.parentCommentId, } - setState((prev) => { - return { - ...prev, - replies: [...prev.replies, comment], - } - }) + setState((prev) => [reply, ...prev]) }, - [currentUser?.id, state.commentsCount], + [currentUser?.id], ) - const handleEdit = useCallback( - (id: string, payload: CommentEditPayload) => { - const isParentEdit = id === state.parentComment._id - - if (isParentEdit) { - setState((prev) => { + const handleEdit = useCallback((id: string, payload: CommentEditPayload) => { + setState((prev) => { + return prev.map((item) => { + if (item._id === id) { return { - ...prev, - parentComment: { - ...prev.parentComment, - ...payload, - _updatedAt: new Date().toISOString(), - }, + ...item, + ...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 }) - }, - [state.parentComment._id], - ) + }) + }, []) const handleDelete = useCallback( (id: string) => { + setState((prev) => prev.filter((item) => item._id !== id)) + }, + [setState], + ) + + const handleNewThreadCreate = useCallback( + (payload: CommentCreatePayload) => { + const comment: CommentDocument = { + ...BASE, + ...payload, + _createdAt: new Date().toISOString(), + _id: uuid(), + authorId: currentUser?.id || 'pP5s3g90N', + } + + setState((prev) => [comment, ...prev]) + }, + [currentUser?.id], + ) + + const handleStatusChange = useCallback( + (id: string, newStatus: CommentStatus) => { setState((prev) => { - return { - ...prev, - replies: prev.replies.filter((item) => item._id !== id), - } + return prev.map((item) => { + if (item._id === id) { + return { + ...item, + status: newStatus, + _updatedAt: new Date().toISOString(), + } + } + + if (item.parentCommentId === id) { + return { + ...item, + status: newStatus, + _updatedAt: new Date().toISOString(), + } + } + + return item + }) }) }, [setState], ) + const threadItems = useMemo(() => { + if (!currentUser || emptyState) return [] + + const items = buildCommentThreadItems({ + comments: state.filter((item) => item.status === status), + currentUser, + documentValue: {}, + schemaType: schema.get('article'), + }) + + return items + }, [currentUser, emptyState, state, status]) + if (!currentUser) return null return ( - { - // ... - }} - onNewThreadCreate={() => { - // ... - }} - /> + + + + + ) } diff --git a/packages/sanity/src/desk/comments/src/__workshop__/CommentsProviderStory.tsx b/packages/sanity/src/desk/comments/src/__workshop__/CommentsProviderStory.tsx index 403e5179e6d..29d8047302d 100644 --- a/packages/sanity/src/desk/comments/src/__workshop__/CommentsProviderStory.tsx +++ b/packages/sanity/src/desk/comments/src/__workshop__/CommentsProviderStory.tsx @@ -2,7 +2,7 @@ import React from 'react' import {useString} from '@sanity/ui-workshop' import {CommentsList} from '../components' -import {CommentsProvider} from '../context' +import {CommentsProvider, CommentsSetupProvider} from '../context' import {useComments} from '../hooks' import {useCurrentUser} from 'sanity' @@ -11,9 +11,11 @@ export default function CommentsProviderStory() { const _id = useString('_id', 'grrm') || 'grrm' return ( - - - + + + + + ) } From 802e5d182f3766e724c0da7f2d004ffec7affd89 Mon Sep 17 00:00:00 2001 From: Robin Pyon Date: Mon, 16 Oct 2023 11:13:31 +0100 Subject: [PATCH 049/102] fix: attach workspace title to comments notification context (#4992) --- .../comments/src/hooks/useCommentOperations.ts | 11 ++++++++--- .../comments/src/hooks/useNotificationTarget.ts | 14 ++++++++------ packages/sanity/src/desk/comments/src/types.ts | 5 +++-- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/sanity/src/desk/comments/src/hooks/useCommentOperations.ts b/packages/sanity/src/desk/comments/src/hooks/useCommentOperations.ts index 10ec29f83cf..a3f32446e48 100644 --- a/packages/sanity/src/desk/comments/src/hooks/useCommentOperations.ts +++ b/packages/sanity/src/desk/comments/src/hooks/useCommentOperations.ts @@ -52,7 +52,7 @@ export function useCommentOperations( const authorId = currentUser?.id - const {title, url, toolName} = useNotificationTarget({ + const {documentTitle, toolName, url, workspaceTitle} = useNotificationTarget({ documentId, documentType, }) @@ -76,7 +76,11 @@ export function useCommentOperations( payload: { workspace, }, - notification: {title, url}, + notification: { + documentTitle, + url, + workspaceTitle, + }, tool: toolName, }, target: { @@ -114,14 +118,15 @@ export function useCommentOperations( client, dataset, documentId, + documentTitle, documentType, onCreate, onCreateError, projectId, - title, toolName, url, workspace, + workspaceTitle, ], ) diff --git a/packages/sanity/src/desk/comments/src/hooks/useNotificationTarget.ts b/packages/sanity/src/desk/comments/src/hooks/useNotificationTarget.ts index 9f6150cf918..dc6386dcb08 100644 --- a/packages/sanity/src/desk/comments/src/hooks/useNotificationTarget.ts +++ b/packages/sanity/src/desk/comments/src/hooks/useNotificationTarget.ts @@ -10,9 +10,10 @@ interface NotificationTargetHookOptions { } interface NotificationTargetHookValue { - url: string - title: string + documentTitle: string toolName: string + url: string + workspaceTitle: string } /** @internal */ @@ -21,7 +22,7 @@ export function useNotificationTarget( ): NotificationTargetHookValue { const {documentId, documentType} = opts || {} const schemaType = useSchema().get(documentType) - const {basePath, tools} = useWorkspace() + const {basePath, title: workspaceTitle, tools} = useWorkspace() const activeToolName = useRouterState( useCallback( @@ -42,7 +43,7 @@ export function useNotificationTarget( }, [documentId, documentPreviewStore, schemaType]) const {published, draft} = previewState || {} - const notificationTitle = (draft?.title || published?.title || 'Sanity document') as string + const documentTitle = (draft?.title || published?.title || 'Sanity document') as string const currentUrl = new URL(window.location.href) const deskToolSegment = currentUrl.pathname.split('/').slice(2, 3).join('') @@ -50,8 +51,9 @@ export function useNotificationTarget( const notificationUrl = currentUrl.toString() return { - url: notificationUrl, - title: notificationTitle, + documentTitle, toolName: activeTool?.name || '', + url: notificationUrl, + workspaceTitle, } } diff --git a/packages/sanity/src/desk/comments/src/types.ts b/packages/sanity/src/desk/comments/src/types.ts index 05413eed104..8c4e7ac8a9c 100644 --- a/packages/sanity/src/desk/comments/src/types.ts +++ b/packages/sanity/src/desk/comments/src/types.ts @@ -72,9 +72,10 @@ export interface CommentPath { interface CommentContext { tool: string payload?: Record - notification: { - title: string + notification?: { + documentTitle: string url: string + workspaceTitle: string } } From d75cb5f73ebe9dac543154d63b87aa988785e6d4 Mon Sep 17 00:00:00 2001 From: Herman Wikner Date: Wed, 18 Oct 2023 12:57:23 +0200 Subject: [PATCH 050/102] feat: add highlight/scroll logic + general improvements --- .../comments/plugin/field/CommentField.tsx | 239 ++++++++++++++---- .../plugin/field/CommentFieldButton.tsx | 10 +- .../plugin/inspector/CommentsInspector.tsx | 74 ++++-- .../src/__workshop__/CommentsListStory.tsx | 1 + .../__workshop__/CommentsProviderStory.tsx | 9 +- .../src/components/list/CommentsList.tsx | 30 ++- .../src/components/list/CommentsListItem.tsx | 72 ++++-- .../list/CommentsListItemContextMenu.tsx | 143 +++++++++++ .../list/CommentsListItemLayout.tsx | 213 ++++------------ .../pte/blocks/MentionInlineBlock.tsx | 5 +- .../pte/comment-input/CommentInputInner.tsx | 26 +- .../src/context/comments/CommentsProvider.tsx | 42 ++- .../comments/src/context/comments/types.ts | 17 ++ 13 files changed, 600 insertions(+), 281 deletions(-) create mode 100644 packages/sanity/src/desk/comments/src/components/list/CommentsListItemContextMenu.tsx diff --git a/packages/sanity/src/desk/comments/plugin/field/CommentField.tsx b/packages/sanity/src/desk/comments/plugin/field/CommentField.tsx index c45ec8e7bba..55dd23ddee2 100644 --- a/packages/sanity/src/desk/comments/plugin/field/CommentField.tsx +++ b/packages/sanity/src/desk/comments/plugin/field/CommentField.tsx @@ -1,7 +1,11 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react' +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {uuid} from '@sanity/uuid' import * as PathUtils from '@sanity/util/paths' import {PortableTextBlock} from '@sanity/types' +import {Stack, useBoundaryElement, useClickOutside} from '@sanity/ui' +import styled, {css, keyframes} from 'styled-components' +import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed' +import {useInView} from 'framer-motion' import {COMMENTS_INSPECTOR_NAME} from '../../../panes/document/constants' import {useDocumentPane} from '../../../panes/document/useDocumentPane' import { @@ -11,7 +15,7 @@ import { CommentCreatePayload, } from '../../src' import {CommentFieldButton} from './CommentFieldButton' -import {FieldProps, useCurrentUser, pathToString} from 'sanity' +import {FieldProps, useCurrentUser} from 'sanity' export function CommentField(props: FieldProps) { const {documentId, documentType} = useDocumentPane() @@ -28,96 +32,230 @@ export function CommentField(props: FieldProps) { return } +const SCROLL_INTO_VIEW_OPTIONS: ScrollIntoViewOptions = { + behavior: 'smooth', + block: 'start', + inline: 'nearest', +} + +const fadeInKeyFrame = keyframes` + 0% { + opacity: 0; + } + 20% { + opacity: 1; + } + 80% { + opacity: 1; + } + 100% { + opacity: 0; + } +` + +const FieldStack = styled(Stack)(({theme}) => { + const {radius, space, color} = theme.sanity + const bg = color.button.bleed.primary.pressed.bg + + return css` + position: relative; + + &[data-highlight='true'] { + &:after { + content: ''; + top: -${space[2]}px; + border-radius: ${radius[3]}px; + animation: ${fadeInKeyFrame} 1.5s forwards; + left: -${space[2]}px; + bottom: -${space[2]}px; + right: -${space[2]}px; + pointer-events: none; + position: absolute; + z-index: 1; + background-color: ${bg}; + mix-blend-mode: ${color.dark ? 'screen' : 'multiply'}; + } + } + ` +}) + function CommentFieldInner(props: FieldProps) { const [open, setOpen] = useState(false) const [value, setValue] = useState(null) + const [shouldScrollToThread, setShouldScrollToThread] = useState(false) + const rootElementRef = useRef(null) + + const {element: boundaryElement} = useBoundaryElement() + const {openInspector, inspector} = useDocumentPane() const currentUser = useCurrentUser() - const {create, status, setStatus, comments} = useComments() - + const {create, status, setStatus, comments, selectedPath, setSelectedPath} = useComments() const count = useFieldCommentsCount(props.path) - const hasComments = Boolean(count > 0) + const inView = useInView(rootElementRef) + + const hasComments = Boolean(count > 0) const currentComments = useMemo(() => comments.data[status], [comments.data, status]) - const [shouldScrollToThread, setShouldScrollToThread] = useState(false) + const shouldHighlighRef = useRef(false) + + // Determine if the current field is selected + const isSelected = useMemo(() => { + if (selectedPath?.origin === 'field') return false + return selectedPath?.fieldPath === PathUtils.toString(props.path) + }, [props.path, selectedPath]) + // Determine if the field should be highlighted. Since we sometimes need to scroll + // to the thread, we want to wait with highlighting the field until the field + // is in view. + const shouldHighlight = useMemo(() => { + return inView && isSelected + }, [inView, isSelected]) + + // Get the most recent thread ID for the current field. This is used to query the + // DOM for the thread in order to be able to scroll to it. const currentThreadId = useMemo(() => { const pathString = PathUtils.toString(props.path) return currentComments.find((comment) => comment.fieldPath === pathString)?.threadId }, [currentComments, props.path]) + // A function that scrolls to the thread with the given ID const handleScrollToThread = useCallback( (threadId: string) => { - if ( - status === 'open' && - inspector?.name === COMMENTS_INSPECTOR_NAME && - shouldScrollToThread && - threadId - ) { - // Find the node in the DOM + if (inspector?.name === COMMENTS_INSPECTOR_NAME && shouldScrollToThread && threadId) { const node = document.querySelector(`[data-thread-id="${threadId}"]`) - // // Scroll to node with 8px offset top if (node) { - requestAnimationFrame(() => { - node.scrollIntoView({behavior: 'smooth', block: 'start', inline: 'nearest'}) - }) + node.scrollIntoView(SCROLL_INTO_VIEW_OPTIONS) + setShouldScrollToThread(false) } - - // Reset shouldScrollToThread to false after performing the scroll action - setShouldScrollToThread(false) } }, - [inspector, status, shouldScrollToThread], + [inspector, shouldScrollToThread], + ) + + const handleOpenInspector = useCallback( + () => openInspector(COMMENTS_INSPECTOR_NAME), + [openInspector], ) const handleClick = useCallback(() => { + // Since the button in the field only reflects the number of open comments, we + // want to switch to open comments when the user clicks the button so that + // the code below can scroll to the thread. if (hasComments && status === 'resolved') { setStatus('open') } + // If the field has comments, we want to open the inspector, scroll to the comment + // thread and set the path as selected so that the comment is highlighted when the + // user clicks the button. if (currentThreadId) { setShouldScrollToThread(true) handleScrollToThread(currentThreadId) + setSelectedPath({ + fieldPath: PathUtils.toString(props.path), + origin: 'field', + }) } - }, [handleScrollToThread, hasComments, setStatus, status, currentThreadId]) - - useEffect(() => { - if (currentThreadId) { - handleScrollToThread(currentThreadId) - } - }, [currentThreadId, handleScrollToThread]) + }, [ + hasComments, + status, + currentThreadId, + setStatus, + handleScrollToThread, + setSelectedPath, + props.path, + ]) const handleCommentAdd = useCallback(() => { if (value) { // Since this is a new comment, we generate a new thread ID const newThreadId = uuid() + // Construct the comment payload const nextComment = { - fieldPath: pathToString(props.path), + fieldPath: PathUtils.toString(props.path), message: value, parentCommentId: undefined, status: 'open', threadId: newThreadId, } satisfies CommentCreatePayload + // Execute the create mutation create.execute(nextComment) - openInspector(COMMENTS_INSPECTOR_NAME) - // If a comment is added to a field when viewing resolved comments, we switch // to open comments and scroll to the comment that was just added + // Open the inspector when a new comment is added + handleOpenInspector() + + // Set the status to 'open' so that the comment is visible setStatus('open') + + // Reset the value setValue(null) - setShouldScrollToThread(true) - handleScrollToThread(newThreadId) + + // Enable scrolling to the thread and scroll to the thread. + // New comments appear at the top, however, the user may have scrolled down + // to read older comments. Therefore, we scroll up to the thread so that + // the user can see the new comment. + requestAnimationFrame(() => { + // Set the path as selected so that the new comment is highlighted + setSelectedPath({ + fieldPath: PathUtils.toString(props.path), + origin: 'field', + }) + + setShouldScrollToThread(true) + handleScrollToThread(newThreadId) + }) } - }, [create, handleScrollToThread, openInspector, props.path, setStatus, value]) + }, [ + create, + handleOpenInspector, + handleScrollToThread, + props.path, + setSelectedPath, + setStatus, + value, + ]) - const handleDiscard = useCallback(() => { - setValue(null) + const handleEditDiscard = useCallback(() => setValue(null), []) + + useEffect(() => { + if (currentThreadId) { + handleScrollToThread(currentThreadId) + } + }, [currentThreadId, handleScrollToThread]) + + useEffect(() => { + if (isSelected && rootElementRef.current) { + scrollIntoViewIfNeeded(rootElementRef.current, { + ...SCROLL_INTO_VIEW_OPTIONS, + boundary: boundaryElement, + scrollMode: 'if-needed', + block: 'center', + }) + } + }, [boundaryElement, isSelected, props.path, selectedPath]) + + // Give the user a way to deselect the path by clicking outside the field + // to get rid of the highlight. + useClickOutside(() => { + if (isSelected) { + setSelectedPath(null) + } + }, [rootElementRef.current]) + + useEffect(() => { + return () => { + // Clear the selected path when the field is unmounted + setSelectedPath(null) + } + // Intentionally omitting `setSelectedPath` from the deps array + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const internalComments: FieldProps['__internal_comments'] = useMemo( @@ -126,13 +264,12 @@ function CommentFieldInner(props: FieldProps) { ), @@ -143,18 +280,26 @@ function CommentFieldInner(props: FieldProps) { currentUser, count, hasComments, + handleClick, handleCommentAdd, - handleDiscard, - openInspector, + handleEditDiscard, + handleOpenInspector, value, - handleClick, open, ], ) - return props.renderDefault({ - ...props, - // eslint-disable-next-line camelcase - __internal_comments: internalComments, - }) + return ( + + {props.renderDefault({ + ...props, + // eslint-disable-next-line camelcase + __internal_comments: internalComments, + })} + + ) } diff --git a/packages/sanity/src/desk/comments/plugin/field/CommentFieldButton.tsx b/packages/sanity/src/desk/comments/plugin/field/CommentFieldButton.tsx index 54ad6435c7e..00777a31c90 100644 --- a/packages/sanity/src/desk/comments/plugin/field/CommentFieldButton.tsx +++ b/packages/sanity/src/desk/comments/plugin/field/CommentFieldButton.tsx @@ -13,8 +13,6 @@ import { } from '@sanity/ui' import styled, {css} from 'styled-components' import {CommentIcon} from '../common/CommentIcon' -import {DocumentPaneContextValue} from '../../../panes/document/DocumentPaneContext' -import {COMMENTS_INSPECTOR_NAME} from '../../../panes/document/constants' import {CommentMessage, useComments, CommentInput} from '../../src' import {CurrentUser, PortableTextBlock, useDidUpdate} from 'sanity' @@ -47,13 +45,12 @@ const StyledPopover = styled(Popover)(({theme}) => { interface CommentFieldButtonProps { count: number currentUser: CurrentUser - hasComments: boolean onChange: (value: PortableTextBlock[]) => void onClick?: () => void onCommentAdd: () => void onDiscardEdit: () => void onOpenChange: (open: boolean) => void - openInspector: DocumentPaneContextValue['openInspector'] + openInspector: () => void value: CommentMessage } @@ -61,7 +58,6 @@ export function CommentFieldButton(props: CommentFieldButtonProps) { const { count, currentUser, - hasComments, onChange, onClick, onCommentAdd, @@ -76,6 +72,8 @@ export function CommentFieldButton(props: CommentFieldButtonProps) { const {mentionOptions} = useComments() + const hasComments = Boolean(count > 0) + const close = useCallback(() => setOpen(false), []) const conditionalClose = useCallback(() => { @@ -87,7 +85,7 @@ export function CommentFieldButton(props: CommentFieldButtonProps) { onClick?.() if (hasComments) { - openInspector(COMMENTS_INSPECTOR_NAME) + openInspector() setOpen(false) return } diff --git a/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspector.tsx b/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspector.tsx index 4f6831ab867..62fc49484dd 100644 --- a/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspector.tsx +++ b/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspector.tsx @@ -9,6 +9,7 @@ import React, { useState, } from 'react' import {Path} from '@sanity/types' +import * as PathUtils from '@sanity/util/paths' import {usePaneRouter} from '../../../components' import {EMPTY_PARAMS} from '../../../constants' import {useDocumentPane} from '../../../panes/document/useDocumentPane' @@ -36,23 +37,40 @@ export function CommentsInspector(props: DocumentInspectorProps) { const [commentToDelete, setCommentToDelete] = useState(null) const [deleteLoading, setDeleteLoading] = useState(false) const [deleteError, setDeleteError] = useState(null) - - const pushToast = useToast().push - - const {onPathOpen, onFocus} = useDocumentPane() + const commentsListHandleRef = useRef(null) const currentUser = useCurrentUser() const {params, createPathWithParams, setParams} = usePaneRouter() const uniqueParams = useUnique(params) || (EMPTY_PARAMS as Partial<{comment?: string}>) const commentIdParamRef = useRef(uniqueParams?.comment) - const {comments, create, edit, mentionOptions, remove, update, status, setStatus, getComment} = - useComments() + const pushToast = useToast().push + const {onPathOpen, ready} = useDocumentPane() + + const { + comments, + create, + edit, + getComment, + getCommentPath, + mentionOptions, + remove, + selectedPath, + setSelectedPath, + setStatus, + status, + update, + } = useComments() const currentComments = useMemo(() => comments.data[status], [comments, status]) - const commentsListHandleRef = useRef(null) - const didScrollDown = useRef(false) + const loading = useMemo(() => { + // The comments and the document are loaded separately which means that + // the comments might be ready before the document is ready. Since the user should + // be able to interact with the document from the comments inspector, we need to make sure + // that the document is ready before we allow the user to interact with the comments. + return comments.loading || !ready + }, [comments.loading, ready]) const handleCopyLink = useCallback( (id: string) => { @@ -109,9 +127,12 @@ export function CommentsInspector(props: DocumentInspectorProps) { const handlePathFocus = useCallback( (path: Path) => { onPathOpen(path) - onFocus(path) + setSelectedPath({ + fieldPath: PathUtils.toString(path), + origin: 'inspector', + }) }, - [onFocus, onPathOpen], + [onPathOpen, setSelectedPath], ) const handleNewThreadCreate = useCallback( @@ -174,24 +195,34 @@ export function CommentsInspector(props: DocumentInspectorProps) { [closeDeleteDialog, remove], ) - useEffect(() => { - if (commentIdParamRef.current && !didScrollDown.current && !comments.loading) { - commentsListHandleRef.current?.scrollToComment(commentIdParamRef.current) + const handleScrollToComment = useCallback( + (id: string, fieldPath: string) => { + if (fieldPath) { + setSelectedPath({ + fieldPath, + origin: 'inspector', + }) + + requestAnimationFrame(() => { + commentsListHandleRef.current?.scrollToComment(id) + }) - setTimeout(() => { setParams({ ...params, comment: undefined, }) + } + }, + [params, setParams, setSelectedPath], + ) - didScrollDown.current = true - }) - } + useLayoutEffect(() => { + const path = getCommentPath(commentIdParamRef.current || '') - return () => { - didScrollDown.current = false + if (path && !loading && commentIdParamRef.current) { + handleScrollToComment(commentIdParamRef.current, path) } - }, [comments.loading, params, setParams]) + }, [getCommentPath, handleScrollToComment, loading]) return ( @@ -213,7 +244,7 @@ export function CommentsInspector(props: DocumentInspectorProps) { comments={currentComments} currentUser={currentUser} error={comments.error} - loading={comments.loading} + loading={loading} mentionOptions={mentionOptions} onCopyLink={handleCopyLink} onCreateRetry={handleCreateRetry} @@ -224,6 +255,7 @@ export function CommentsInspector(props: DocumentInspectorProps) { onReply={handleReply} onStatusChange={handleStatusChange} ref={commentsListHandleRef} + selectedPath={selectedPath} status={status} /> )} diff --git a/packages/sanity/src/desk/comments/src/__workshop__/CommentsListStory.tsx b/packages/sanity/src/desk/comments/src/__workshop__/CommentsListStory.tsx index 292f8b1407a..afe1a3eb705 100644 --- a/packages/sanity/src/desk/comments/src/__workshop__/CommentsListStory.tsx +++ b/packages/sanity/src/desk/comments/src/__workshop__/CommentsListStory.tsx @@ -207,6 +207,7 @@ export default function CommentsListStory() { onNewThreadCreate={handleNewThreadCreate} onReply={handleReplySubmit} onStatusChange={handleStatusChange} + selectedPath={null} status={status} /> diff --git a/packages/sanity/src/desk/comments/src/__workshop__/CommentsProviderStory.tsx b/packages/sanity/src/desk/comments/src/__workshop__/CommentsProviderStory.tsx index 29d8047302d..7e7535763bd 100644 --- a/packages/sanity/src/desk/comments/src/__workshop__/CommentsProviderStory.tsx +++ b/packages/sanity/src/desk/comments/src/__workshop__/CommentsProviderStory.tsx @@ -6,6 +6,10 @@ import {CommentsProvider, CommentsSetupProvider} from '../context' import {useComments} from '../hooks' import {useCurrentUser} from 'sanity' +const noop = () => { + // ... +} + export default function CommentsProviderStory() { const _type = useString('_type', 'author') || 'author' const _id = useString('_id', 'grrm') || 'grrm' @@ -32,14 +36,13 @@ function Inner() { error={comments.error} loading={comments.loading} mentionOptions={mentionOptions} + onCreateRetry={noop} onDelete={remove.execute} onEdit={edit.execute} onNewThreadCreate={create.execute} onReply={create.execute} + selectedPath={null} status="open" - onCreateRetry={() => { - // ... - }} /> ) } diff --git a/packages/sanity/src/desk/comments/src/components/list/CommentsList.tsx b/packages/sanity/src/desk/comments/src/components/list/CommentsList.tsx index 8e8ee3f0c93..917820fc47b 100644 --- a/packages/sanity/src/desk/comments/src/components/list/CommentsList.tsx +++ b/packages/sanity/src/desk/comments/src/components/list/CommentsList.tsx @@ -11,6 +11,7 @@ import { import {CommentsListItem} from './CommentsListItem' import {CommentThreadLayout} from './CommentThreadLayout' import {CommentsListStatus} from './CommentsListStatus' +import {SelectedPath} from '../../context/comments/types' const SCROLL_INTO_VIEW_OPTIONS: ScrollIntoViewOptions = { behavior: 'smooth', @@ -54,6 +55,7 @@ export interface CommentsListProps { onPathFocus?: (path: Path) => void onReply: (payload: CommentCreatePayload) => void onStatusChange?: (id: string, status: CommentStatus) => void + selectedPath: SelectedPath status: CommentStatus } @@ -81,21 +83,18 @@ const CommentsListInner = forwardRef( onPathFocus, onReply, onStatusChange, + selectedPath, status, } = props const [boundaryElement, setBoundaryElement] = useState(null) - const scrollToComment = useCallback( - (id: string) => { - requestAnimationFrame(() => { - const commentElement = boundaryElement?.querySelector(`[data-comment-id="${id}"]`) - if (commentElement) { - commentElement.scrollIntoView(SCROLL_INTO_VIEW_OPTIONS) - } - }) - }, - [boundaryElement], - ) + const scrollToComment = useCallback((id: string) => { + const commentElement = document?.querySelector(`[data-comment-id="${id}"]`) + + if (commentElement) { + commentElement.scrollIntoView(SCROLL_INTO_VIEW_OPTIONS) + } + }, []) useImperativeHandle( ref, @@ -156,9 +155,10 @@ const CommentsListInner = forwardRef( flex={1} overflow="auto" padding={3} + paddingTop={1} paddingBottom={6} sizing="border" - space={4} + space={1} > {groupedThreads?.map(([fieldPath, group]) => { @@ -172,7 +172,7 @@ const CommentsListInner = forwardRef( const firstThreadId = group[0].threadId return ( - + ( item.parentComment._state?.type !== 'createError' && item.parentComment._state?.type !== 'createRetrying' + // Check if the current field is selected + const isSelected = selectedPath?.fieldPath === item.fieldPath + return ( ( onStatusChange={onStatusChange} parentComment={item.parentComment} replies={replies} + selected={isSelected} /> ) })} diff --git a/packages/sanity/src/desk/comments/src/components/list/CommentsListItem.tsx b/packages/sanity/src/desk/comments/src/components/list/CommentsListItem.tsx index 4a38b2e1e1f..bc024ca75cb 100644 --- a/packages/sanity/src/desk/comments/src/components/list/CommentsListItem.tsx +++ b/packages/sanity/src/desk/comments/src/components/list/CommentsListItem.tsx @@ -3,7 +3,7 @@ import {Button, Flex, Stack} from '@sanity/ui' import styled, {css} from 'styled-components' import {CurrentUser, Path} from '@sanity/types' import {ChevronDownIcon} from '@sanity/icons' -// import * as PathUtils from '@sanity/util/paths' +import * as PathUtils from '@sanity/util/paths' import {CommentInput, CommentInputHandle} from '../pte' import { CommentCreatePayload, @@ -21,18 +21,35 @@ const EMPTY_ARRAY: [] = [] const MAX_COLLAPSED_REPLIES = 5 -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. - @media (hover: hover) { - &:hover { - [data-root-menu='true'] { - opacity: 1; +const StyledThreadCard = styled(ThreadCard)(({theme}) => { + const {hovered} = theme.sanity.color.button.bleed.default + + return css` + position: relative; + + &:has(> [data-ui='GhostButton']:focus:focus-visible) { + box-shadow: + inset 0 0 0 1px var(--card-border-color), + 0 0 0 1px var(--card-bg-color), + 0 0 0 3px var(--card-focus-ring-color); + } + + // 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. + &:not([data-active='true']) { + @media (hover: hover) { + &:hover { + --card-bg-color: ${hovered.bg2}; + + [data-root-menu='true'] { + opacity: 1; + } + } } } - } -` + ` +}) const ExpandButton = styled(Button)(({theme}) => { const {medium} = theme.sanity.fonts.text.weights @@ -42,6 +59,16 @@ const ExpandButton = styled(Button)(({theme}) => { ` }) +const GhostButton = styled(Button)` + /* background: orange; */ + opacity: 0; + position: absolute; + right: 0; + top: 0; + bottom: 0; + left: 0; +` + interface CommentsListItemProps { canReply?: boolean currentUser: CurrentUser @@ -55,6 +82,7 @@ interface CommentsListItemProps { onStatusChange?: (id: string, status: CommentStatus) => void parentComment: CommentDocument replies: CommentDocument[] | undefined + selected?: boolean } export const CommentsListItem = React.memo(function CommentsListItem(props: CommentsListItemProps) { @@ -71,6 +99,7 @@ export const CommentsListItem = React.memo(function CommentsListItem(props: Comm onStatusChange, parentComment, replies = EMPTY_ARRAY, + selected, } = props const [value, setValue] = useState(EMPTY_ARRAY) const [collapsed, setCollapsed] = useState(true) @@ -101,17 +130,17 @@ export const CommentsListItem = React.memo(function CommentsListItem(props: Comm value, ]) - // const handleThreadRootClick = useCallback(() => { - // const path = PathUtils.fromString(parentComment.target.path.field) - - // onPathFocus?.(path) - // }, [onPathFocus, parentComment.target.path.field]) + const handleThreadRootClick = useCallback(() => { + const path = PathUtils.fromString(parentComment.target.path.field) + onPathFocus?.(path) + }, [onPathFocus, parentComment.target.path.field]) const cancelEdit = useCallback(() => { setValue(EMPTY_ARRAY) }, []) - const handleExpand = useCallback(() => { + const handleExpand = useCallback((e: React.MouseEvent) => { + e.stopPropagation() setCollapsed(false) didExpand.current = true }, []) @@ -140,7 +169,13 @@ export const CommentsListItem = React.memo(function CommentsListItem(props: Comm return ( - + + + diff --git a/packages/sanity/src/desk/comments/src/components/list/CommentsListItemContextMenu.tsx b/packages/sanity/src/desk/comments/src/components/list/CommentsListItemContextMenu.tsx new file mode 100644 index 00000000000..8c174f71ec8 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/list/CommentsListItemContextMenu.tsx @@ -0,0 +1,143 @@ +import React from 'react' +import { + CheckmarkCircleIcon, + UndoIcon, + EllipsisVerticalIcon, + EditIcon, + TrashIcon, + LinkIcon, +} from '@sanity/icons' +import { + TooltipDelayGroupProviderProps, + MenuButtonProps, + TooltipDelayGroupProvider, + Button, + MenuButton, + Menu, + MenuItem, + MenuDivider, + Layer, + Card, +} from '@sanity/ui' +import styled, {css} from 'styled-components' +import {CommentStatus} from '../../types' +import {TextTooltip} from '../TextTooltip' + +const TOOLTIP_GROUP_DELAY: TooltipDelayGroupProviderProps['delay'] = {open: 500} +const POPOVER_PROPS: MenuButtonProps['popover'] = {placement: 'bottom-end'} + +const FloatingLayer = styled(Layer)` + display: flex; +` + +const FloatingCard = styled(Card)(({theme}) => { + const {space} = theme.sanity + + return css` + gap: ${space[1] / 2}px; + padding: ${space[1] / 2}px; + + &:empty { + display: none; + } + ` +}) + +interface CommentsListItemContextMenuProps { + canDelete: boolean | undefined + canEdit: boolean | undefined + isParent: boolean | undefined + onCopyLink?: () => void + onDeleteStart?: () => void + onEditStart?: () => void + onMenuClose?: () => void + onMenuOpen?: () => void + onStatusChange?: () => void + status: CommentStatus +} + +export function CommentsListItemContextMenu(props: CommentsListItemContextMenuProps) { + const { + canDelete, + canEdit, + isParent, + onCopyLink, + onDeleteStart, + onEditStart, + onMenuClose, + onMenuOpen, + onStatusChange, + status, + ...rest + } = props + + const showMenuButton = Boolean(onCopyLink || onDeleteStart || onEditStart) + + return ( + + + + {isParent && ( + +