From 08034640c32cabf418260a03d7eeba87335d369c Mon Sep 17 00:00:00 2001 From: Herman Wikner Date: Tue, 31 Oct 2023 15:31:57 +0100 Subject: [PATCH] feat(desk): implement comments (beta) (#4886) * feat: comments wip * add handle to commentslist, scroll down to comment fix * fix(comments): only calculate current caret element on demand (#4935) * feat(comments): minor UI updates (#4929) - Tooltip delays (500 in, 0 out), wraps comment actions - Optically aligned icons (still temporary, changes forthcoming in @sanity/icons) - Display total comment (not thread) count per field - Minor changes to open/resolved selects, comment context menu alignment + field headers - Primary tone on new comment container, also broken out into a separate component - Other super minor tweaks, slightly more condensed comments, PTE container - Bump @sanity/ui to 1.8.2 - Uses current dataset name when querying for metacontent comments * feat: improve comment breadcrumbs * feat: disable replying/creating new comments when resolved * feat: scroll to comment when opening inspector * feat: use correct feature key in `useCommentsEnabled` * refactor: scroll to thread, feature enable check, build breadcrumbs * feat: ui updates * dev(test-studio): add comments debug schema * feat: ui updates, refactor, add array item schema title in breadcrumbs * refactor(comments): inline text filtering for mentions menu * refactor(comments): remove unused 'expanded' context This is no longer in use it seems like. Removing for simplicity. * refactor(comments): introduce focus to end of content as explicit fn Make a explicit function to focus the editor at the end of the edited comment content as focusEditor fn is now used internally in the context provider. Also simplify an effect that focuses the component this way. * fix(comments): fix issue with focusing editor through effect Make sure the editor is mounted before trying to call focus on it. * feat: validate comments against schema and document value * dev(test-studio): update comments debug schema * feat: improve comment breadcrumbs * refactor(comments): remove unused 'expanded' context This is no longer in use it seems like. Removing for simplicity. * fix: resolve conflicts * fix: scroll to comment * refactor: types, add comments, clean up * fix: delete comment dialog message * feat: update pte list config * fix(comments): create better placement for mentionmenu popover * fix: move static props outside the render scope, a11y improvements * feat: add comment creation error handling * dev(test-studio): update comments debug schema * fix: sort order when creating a new thread + scroll to comment issues * refactor: minor improvements * feat: improve perf by memoizing the comments list component * fix: add proper tags to comments list * feat: cache system groups response to prevent unnecessary requests * fix: breadcrumbs build * test: build comment breadcrumbs * fix(core/comments): ensure proper authentication config on comment client Re-use the authentication configured for the default client. Authentication method vary if the browser supports cookies or not. * dev(test-studio): temporarily disable `assist` and `tsdoc` plugins * refactor: move comments from `core` to `desk` * feat: add `createPathWithParams` to pane router * feat: implement comments setup * feat: add `useCommentsSetup` hook * feat: add `client` option to `useCommentsStore` and `useCommentOperations` * refactor: remove unused context * test: update `buildCommentBreadcrumbs` test * feat: add copy link to comment feature, UI tweaks and clean up * refactor: export `useMentionOptions` options interface * refactor: update comment workshop stories * fix: attach workspace title to comments notification context (#4992) * feat: add highlight/scroll logic + general improvements * fix(comments): handle text overflow in editable surfaces, optically align field titles (#5004) * fix(comments): add viewport specific max-height to editable comment areas * fix(comments): optically align thread field titles * fixup! fix(comments): add viewport specific max-height to editable comment areas * fix: field highlight effect array issue by using `AnimatePresence` * feat: implement comment discard logic * feat: disable comments by default * dev(test-studio): enable comments * fix: ensure created comments create URLs with inspect and comment params (#5012) * chore: add code owner for comments * fix: re-focus input when cancelling comment discard when creating new thread * refactor: simplify discard controller * fix: reset comment id ref from params when scroll completed * fix: move group to top when new thread is created + select the path * fix: tweak field highlight overlay * test(comments): add test-ids Add a couple of test-ids we will need when writing tests. * refactor(comments): refactor focus handling in the CommentInput This will fix some bugs we have been seeing related to setting focus in the comment input. Use the PTE Editable selection prop to control selecting end of the content. Call focus on the editor without side-effects. * test(playwright-ct): add tests for CommentInput Add some basic tests for the comment input. More to come. * fix(comments): use current editor value over snapshot to update comment * test(playwright-ct): update props after refactor * fix: store document references as plain objects (#5021) * feat: implement breadcrumbs button * feat: reset select path when changing status view * fix: reset select path when closing inspector * feat: improve selected path logic, update ui when creating new thread * fix: revert the storing of cross dataset references in comment documents * feat: add to notification context (#5041) * dev(test-studio): remove temporary workspace * dev(test-studio): re-enable `tsdoc` and `assist` plugins * chore: update `yarn.lock` * chore: bump sanity ui * refactor: remove `useFieldCommentsCount` hook * feat: handle network connection reconnect in `useCommentsStore` * feat: disable comments (input and actions) while running setup * fix: retry comment create issue * fix: `focusOnMount` issue * chore: update yarn.lock * feat: add comments onboarding * refactor: setup when creating the first comment * fix: update comments onboarding local storage key * fix: scroll to comment * refactor(comments): refactor how input and focus is handled * Enter will submit the comment * Input field is reset after submission or discard * Setting focus is handled through the same function everywhere. * test(playwrigh-ct): add test for comment submission * refactor(comments): Performance opt: Don't re-render replies every time. * refactor(comments): refactor key event handling in the comment input and consumers This will let comment input key events propagate to the parent components for handling spesific functionality for that parent. This also reduces the need for additional key handlers in the parent components, and makes it easier to compose functionality as there is only one originating source of those events. * fix: comments onboarding copy * refactor: make code more readable in `FormFieldBaseHeader` * fix: incorrect interface name in `CommentsSetupProvider` * fix: remove unused CSS in `CommentFieldButton` * fix: wrap local storage functions in try/catch in `CommentsOnboardingProvider` * refactor: get rid of `satisfies` where possible * fix(core): invalid tsdoc in `useDidUpdate` * fix: add request tag in `CommentsSetupProvider` * fix(studio-e2e-testing): remove `commentAction` field action * Update packages/sanity/src/desk/comments/src/context/setup/CommentsSetupProvider.tsx Co-authored-by: Robin Pyon --------- Co-authored-by: Robin Pyon Co-authored-by: Per-Kristian Nordnes --- .github/CODEOWNERS | 2 + dev/studio-e2e-testing/sanity.config.ts | 3 +- .../fieldActions/commentFieldAction.tsx | 22 -- dev/test-studio/sanity.config.ts | 8 +- dev/test-studio/schema/debug/comments.ts | 115 ++++++ dev/test-studio/schema/index.ts | 2 + dev/test-studio/structure/constants.ts | 25 +- packages/sanity/package.json | 1 + .../tests/comments/CommentInput.spec.tsx | 49 +++ .../tests/comments/CommentInputStory.tsx | 47 +++ .../core/components/userAvatar/UserAvatar.tsx | 3 +- .../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 | 93 +++-- .../components/formField/FormFieldSet.tsx | 7 +- .../src/core/form/hooks/useDidUpdate.ts | 3 + packages/sanity/src/core/form/index.ts | 1 + .../CrossDatasetReferenceInput.tsx | 2 +- .../inputs/ReferenceInput/ReferenceField.tsx | 101 ++--- .../resolveConditionalProperty.ts | 6 + packages/sanity/src/core/form/store/index.ts | 2 + .../studio/inputResolver/fieldResolver.tsx | 3 + .../sanity/src/core/form/types/fieldProps.ts | 9 + packages/sanity/src/core/hooks/index.ts | 1 + .../useFeatureEnabled.ts | 5 +- .../src/core/store/_legacy/project/types.ts | 2 + packages/sanity/src/desk/comments/index.ts | 2 + .../comments/plugin/field/CommentField.tsx | 340 ++++++++++++++++ .../plugin/field/CommentFieldButton.tsx | 206 ++++++++++ .../src/desk/comments/plugin/field/index.ts | 1 + .../sanity/src/desk/comments/plugin/index.ts | 21 + .../plugin/inspector/CommentsInspector.tsx | 296 ++++++++++++++ .../inspector/CommentsInspectorHeader.tsx | 95 +++++ .../desk/comments/plugin/inspector/index.ts | 31 ++ .../comments/plugin/layout/CommentsLayout.tsx | 11 + .../src/desk/comments/plugin/layout/index.ts | 1 + .../__tests__/buildCommentBreadcrumbs.test.ts | 262 +++++++++++++ .../__workshop__/CommentBreadcrumbsStory.tsx | 17 + .../__workshop__/CommentDeleteDialogStory.tsx | 20 + .../src/__workshop__/CommentInputStory.tsx | 48 +++ .../src/__workshop__/CommentsListStory.tsx | 218 +++++++++++ .../__workshop__/CommentsProviderStory.tsx | 48 +++ .../__workshop__/MentionOptionsHookStory.tsx | 28 ++ .../src/__workshop__/MentionsMenuStory.tsx | 32 ++ .../desk/comments/src/__workshop__/index.ts | 44 +++ .../comments/src/components/BetaBadge.tsx | 21 + .../src/components/CommentBreadcrumbs.tsx | 91 +++++ .../src/components/CommentDeleteDialog.tsx | 75 ++++ .../comments/src/components/TextTooltip.tsx | 32 ++ .../src/components/avatars/CommentsAvatar.tsx | 44 +++ .../src/components/avatars/SpacerAvatar.tsx | 15 + .../comments/src/components/avatars/index.ts | 2 + .../desk/comments/src/components/constants.ts | 3 + .../src/components/icons/AddCommentIcon.tsx | 32 ++ .../src/components/icons/CommentIcon.tsx | 28 ++ .../src/components/icons/MentionIcon.tsx | 27 ++ .../src/components/icons/SendIcon.tsx | 27 ++ .../comments/src/components/icons/index.ts | 4 + .../src/desk/comments/src/components/index.ts | 8 + .../components/list/CommentThreadLayout.tsx | 108 ++++++ .../src/components/list/CommentsList.tsx | 228 +++++++++++ .../src/components/list/CommentsListItem.tsx | 320 +++++++++++++++ .../list/CommentsListItemContextMenu.tsx | 147 +++++++ .../list/CommentsListItemLayout.tsx | 364 ++++++++++++++++++ .../components/list/CommentsListStatus.tsx | 86 +++++ .../components/list/CreateNewThreadInput.tsx | 100 +++++ .../comments/src/components/list/index.ts | 2 + .../comments/src/components/list/styles.ts | 11 + .../src/components/mentions/MentionsMenu.tsx | 118 ++++++ .../components/mentions/MentionsMenuItem.tsx | 53 +++ .../comments/src/components/mentions/index.ts | 1 + .../onboarding/CommentsOnboardingPopover.tsx | 42 ++ .../src/components/onboarding/index.ts | 1 + .../pte/CommentMessageSerializer.tsx | 78 ++++ .../pte/blocks/MentionInlineBlock.tsx | 60 +++ .../src/components/pte/blocks/NormalBlock.tsx | 22 ++ .../src/components/pte/blocks/index.ts | 2 + .../pte/comment-input/CommentInput.tsx | 218 +++++++++++ .../CommentInputDiscardDialog.tsx | 80 ++++ .../pte/comment-input/CommentInputInner.tsx | 154 ++++++++ .../comment-input/CommentInputProvider.tsx | 247 ++++++++++++ .../components/pte/comment-input/Editable.tsx | 235 +++++++++++ .../src/components/pte/comment-input/index.ts | 3 + .../pte/comment-input/useCommentInput.ts | 12 + .../pte/comment-input/useCursorElement.ts | 57 +++ .../comments/src/components/pte/config.ts | 34 ++ .../desk/comments/src/components/pte/index.ts | 2 + .../src/components/pte/render/index.ts | 2 + .../src/components/pte/render/renderBlock.tsx | 9 + .../src/components/pte/render/renderChild.tsx | 15 + .../src/context/comments/CommentsContext.ts | 4 + .../src/context/comments/CommentsProvider.tsx | 356 +++++++++++++++++ .../comments/src/context/comments/index.ts | 2 + .../comments/src/context/comments/types.ts | 69 ++++ .../src/desk/comments/src/context/index.ts | 3 + .../onboarding/CommentsOnboardingContext.ts | 4 + .../onboarding/CommentsOnboardingProvider.tsx | 51 +++ .../comments/src/context/onboarding/index.ts | 2 + .../comments/src/context/onboarding/types.ts | 4 + .../src/context/setup/CommentsSetupContext.ts | 8 + .../context/setup/CommentsSetupProvider.tsx | 128 ++++++ .../desk/comments/src/context/setup/index.ts | 3 + .../desk/comments/src/context/setup/types.ts | 12 + .../sanity/src/desk/comments/src/helpers.ts | 20 + .../src/desk/comments/src/hooks/index.ts | 6 + .../src/hooks/use-mention-options/helpers.ts | 72 ++++ .../src/hooks/use-mention-options/index.ts | 1 + .../use-mention-options/useMentionOptions.ts | 118 ++++++ .../src/hooks/useCommentOperations.ts | 242 ++++++++++++ .../desk/comments/src/hooks/useComments.ts | 17 + .../comments/src/hooks/useCommentsEnabled.ts | 68 ++++ .../src/hooks/useCommentsOnboarding.ts | 13 + .../comments/src/hooks/useCommentsSetup.ts | 12 + .../src/hooks/useNotificationTarget.ts | 67 ++++ .../sanity/src/desk/comments/src/index.ts | 7 + .../src/desk/comments/src/store/index.ts | 1 + .../src/desk/comments/src/store/reducer.ts | 160 ++++++++ .../comments/src/store/useCommentsStore.ts | 153 ++++++++ .../sanity/src/desk/comments/src/types.ts | 187 +++++++++ .../src/utils/buildCommentBreadcrumbs.ts | 203 ++++++++++ .../src/utils/buildCommentThreadItems.ts | 52 +++ .../src/desk/comments/src/utils/index.ts | 1 + .../paneRouter/PaneRouterContext.tsx | 1 + .../paneRouter/PaneRouterProvider.tsx | 32 +- .../src/desk/components/paneRouter/types.ts | 6 + packages/sanity/src/desk/deskTool.ts | 6 +- .../panes/document/DocumentPaneProvider.tsx | 33 +- .../src/desk/panes/document/constants.ts | 1 + packages/sanity/src/desk/router.ts | 2 +- 131 files changed, 7496 insertions(+), 148 deletions(-) delete mode 100644 dev/test-studio/fieldActions/commentFieldAction.tsx create mode 100644 dev/test-studio/schema/debug/comments.ts create mode 100644 packages/sanity/playwright-ct/tests/comments/CommentInput.spec.tsx create mode 100644 packages/sanity/playwright-ct/tests/comments/CommentInputStory.tsx rename packages/sanity/src/core/{form/inputs/CrossDatasetReferenceInput => hooks}/useFeatureEnabled.ts (92%) create mode 100644 packages/sanity/src/desk/comments/index.ts create mode 100644 packages/sanity/src/desk/comments/plugin/field/CommentField.tsx create mode 100644 packages/sanity/src/desk/comments/plugin/field/CommentFieldButton.tsx create mode 100644 packages/sanity/src/desk/comments/plugin/field/index.ts create mode 100644 packages/sanity/src/desk/comments/plugin/index.ts create mode 100644 packages/sanity/src/desk/comments/plugin/inspector/CommentsInspector.tsx create mode 100644 packages/sanity/src/desk/comments/plugin/inspector/CommentsInspectorHeader.tsx create mode 100644 packages/sanity/src/desk/comments/plugin/inspector/index.ts create mode 100644 packages/sanity/src/desk/comments/plugin/layout/CommentsLayout.tsx create mode 100644 packages/sanity/src/desk/comments/plugin/layout/index.ts create mode 100644 packages/sanity/src/desk/comments/src/__tests__/buildCommentBreadcrumbs.test.ts create mode 100644 packages/sanity/src/desk/comments/src/__workshop__/CommentBreadcrumbsStory.tsx create mode 100644 packages/sanity/src/desk/comments/src/__workshop__/CommentDeleteDialogStory.tsx create mode 100644 packages/sanity/src/desk/comments/src/__workshop__/CommentInputStory.tsx create mode 100644 packages/sanity/src/desk/comments/src/__workshop__/CommentsListStory.tsx create mode 100644 packages/sanity/src/desk/comments/src/__workshop__/CommentsProviderStory.tsx create mode 100644 packages/sanity/src/desk/comments/src/__workshop__/MentionOptionsHookStory.tsx create mode 100644 packages/sanity/src/desk/comments/src/__workshop__/MentionsMenuStory.tsx create mode 100644 packages/sanity/src/desk/comments/src/__workshop__/index.ts create mode 100644 packages/sanity/src/desk/comments/src/components/BetaBadge.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/CommentBreadcrumbs.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/CommentDeleteDialog.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/TextTooltip.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/avatars/CommentsAvatar.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/avatars/SpacerAvatar.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/avatars/index.ts create mode 100644 packages/sanity/src/desk/comments/src/components/constants.ts create mode 100644 packages/sanity/src/desk/comments/src/components/icons/AddCommentIcon.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/icons/CommentIcon.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/icons/MentionIcon.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/icons/SendIcon.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/icons/index.ts create mode 100644 packages/sanity/src/desk/comments/src/components/index.ts create mode 100644 packages/sanity/src/desk/comments/src/components/list/CommentThreadLayout.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/list/CommentsList.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/list/CommentsListItem.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/list/CommentsListItemContextMenu.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/list/CommentsListItemLayout.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/list/CommentsListStatus.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/list/CreateNewThreadInput.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/list/index.ts create mode 100644 packages/sanity/src/desk/comments/src/components/list/styles.ts create mode 100644 packages/sanity/src/desk/comments/src/components/mentions/MentionsMenu.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/mentions/MentionsMenuItem.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/mentions/index.ts create mode 100644 packages/sanity/src/desk/comments/src/components/onboarding/CommentsOnboardingPopover.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/onboarding/index.ts create mode 100644 packages/sanity/src/desk/comments/src/components/pte/CommentMessageSerializer.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/pte/blocks/MentionInlineBlock.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/pte/blocks/NormalBlock.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/pte/blocks/index.ts create mode 100644 packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInput.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputDiscardDialog.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputInner.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputProvider.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/pte/comment-input/Editable.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/pte/comment-input/index.ts create mode 100644 packages/sanity/src/desk/comments/src/components/pte/comment-input/useCommentInput.ts create mode 100644 packages/sanity/src/desk/comments/src/components/pte/comment-input/useCursorElement.ts create mode 100644 packages/sanity/src/desk/comments/src/components/pte/config.ts create mode 100644 packages/sanity/src/desk/comments/src/components/pte/index.ts create mode 100644 packages/sanity/src/desk/comments/src/components/pte/render/index.ts create mode 100644 packages/sanity/src/desk/comments/src/components/pte/render/renderBlock.tsx create mode 100644 packages/sanity/src/desk/comments/src/components/pte/render/renderChild.tsx create mode 100644 packages/sanity/src/desk/comments/src/context/comments/CommentsContext.ts create mode 100644 packages/sanity/src/desk/comments/src/context/comments/CommentsProvider.tsx create mode 100644 packages/sanity/src/desk/comments/src/context/comments/index.ts create mode 100644 packages/sanity/src/desk/comments/src/context/comments/types.ts create mode 100644 packages/sanity/src/desk/comments/src/context/index.ts create mode 100644 packages/sanity/src/desk/comments/src/context/onboarding/CommentsOnboardingContext.ts create mode 100644 packages/sanity/src/desk/comments/src/context/onboarding/CommentsOnboardingProvider.tsx create mode 100644 packages/sanity/src/desk/comments/src/context/onboarding/index.ts create mode 100644 packages/sanity/src/desk/comments/src/context/onboarding/types.ts create mode 100644 packages/sanity/src/desk/comments/src/context/setup/CommentsSetupContext.ts create mode 100644 packages/sanity/src/desk/comments/src/context/setup/CommentsSetupProvider.tsx create mode 100644 packages/sanity/src/desk/comments/src/context/setup/index.ts create mode 100644 packages/sanity/src/desk/comments/src/context/setup/types.ts create mode 100644 packages/sanity/src/desk/comments/src/helpers.ts create mode 100644 packages/sanity/src/desk/comments/src/hooks/index.ts create mode 100644 packages/sanity/src/desk/comments/src/hooks/use-mention-options/helpers.ts create mode 100644 packages/sanity/src/desk/comments/src/hooks/use-mention-options/index.ts create mode 100644 packages/sanity/src/desk/comments/src/hooks/use-mention-options/useMentionOptions.ts create mode 100644 packages/sanity/src/desk/comments/src/hooks/useCommentOperations.ts create mode 100644 packages/sanity/src/desk/comments/src/hooks/useComments.ts create mode 100644 packages/sanity/src/desk/comments/src/hooks/useCommentsEnabled.ts create mode 100644 packages/sanity/src/desk/comments/src/hooks/useCommentsOnboarding.ts create mode 100644 packages/sanity/src/desk/comments/src/hooks/useCommentsSetup.ts create mode 100644 packages/sanity/src/desk/comments/src/hooks/useNotificationTarget.ts create mode 100644 packages/sanity/src/desk/comments/src/index.ts create mode 100644 packages/sanity/src/desk/comments/src/store/index.ts create mode 100644 packages/sanity/src/desk/comments/src/store/reducer.ts create mode 100644 packages/sanity/src/desk/comments/src/store/useCommentsStore.ts create mode 100644 packages/sanity/src/desk/comments/src/types.ts create mode 100644 packages/sanity/src/desk/comments/src/utils/buildCommentBreadcrumbs.ts create mode 100644 packages/sanity/src/desk/comments/src/utils/buildCommentThreadItems.ts create mode 100644 packages/sanity/src/desk/comments/src/utils/index.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 167264d9c47..abd89246fa7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -25,3 +25,5 @@ # Tom owns the Shopify templates /packages/@sanity/cli/src/actions/init-project/templates/shopify* @thebiggianthead /packages/@sanity/cli/templates/shopify* @thebiggianthead + +/packages/sanity/src/desk/comments/ @hermanwikner diff --git a/dev/studio-e2e-testing/sanity.config.ts b/dev/studio-e2e-testing/sanity.config.ts index c47d8012965..78a58de96c8 100644 --- a/dev/studio-e2e-testing/sanity.config.ts +++ b/dev/studio-e2e-testing/sanity.config.ts @@ -12,7 +12,6 @@ import {defaultDocumentNode, newDocumentOptions, structure} from 'sanity-test-st import {presenceTool} from 'sanity-test-studio/plugins/presence' import {copyAction} from 'sanity-test-studio/fieldActions/copyAction' import {assistFieldActionGroup} from 'sanity-test-studio/fieldActions/assistFieldActionGroup' -import {commentAction} from 'sanity-test-studio/fieldActions/commentFieldAction' import {customInspector} from 'sanity-test-studio/inspectors/custom' import {pasteAction} from 'sanity-test-studio/fieldActions/pasteAction' import {Branding} from './components/Branding' @@ -45,7 +44,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 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..2cc25ef766f 100644 --- a/dev/test-studio/sanity.config.ts +++ b/dev/test-studio/sanity.config.ts @@ -5,7 +5,6 @@ import {deskTool} from 'sanity/desk' import {muxInput} from 'sanity-plugin-mux-input' import {assist} from '@sanity/assist' import {googleMapsInput} from '@sanity/google-maps-input' -// eslint-disable-next-line import/no-extraneous-dependencies import {tsdoc} from '@sanity/tsdoc/studio' import {theme as tailwindTheme} from './sanity.theme.mjs' import {imageAssetSource} from './assetSources' @@ -39,7 +38,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,12 +68,16 @@ 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 }, newDocumentOptions, + + unstable_comments: { + enabled: true, + }, }, plugins: [ deskTool({ diff --git a/dev/test-studio/schema/debug/comments.ts b/dev/test-studio/schema/debug/comments.ts new file mode 100644 index 00000000000..3846857aa40 --- /dev/null +++ b/dev/test-studio/schema/debug/comments.ts @@ -0,0 +1,115 @@ +import {defineType} from 'sanity' + +const DESCRIPTION = 'Comments added to this field should be hidden when the toggle above is checked' + +export const commentsDebug = defineType({ + name: 'commentsDebug', + type: 'document', + title: 'Comments debug', + fields: [ + { + name: 'string', + type: 'string', + title: 'String title', + }, + { + name: 'hideFields', + type: 'boolean', + title: 'Hide fields', + }, + { + type: 'object', + name: 'object', + title: 'Object title', + fields: [ + { + type: 'string', + name: 'string', + title: 'String title', + hidden: ({document}) => Boolean(document?.hideFields), + description: DESCRIPTION, + }, + { + type: 'number', + name: 'number', + title: 'Number title', + }, + ], + }, + { + type: 'array', + name: 'arrayOfObjects', + title: 'Array 1', + of: [ + { + name: 'arrayObject', + type: 'object', + title: 'Array object 1', + fields: [ + { + name: 'string', + type: 'string', + title: 'String 1', + }, + { + name: 'image', + type: 'image', + title: 'Image 1', + hidden: ({document}) => { + return Boolean(document?.hideFields) + }, + description: DESCRIPTION, + }, + { + name: 'nestedArray', + type: 'array', + title: 'Array 2', + of: [ + { + name: 'nestedArrayObject1', + type: 'object', + title: 'Nested array object 1', + fields: [ + { + name: 'string', + type: 'string', + title: 'String 2.1', + }, + { + name: 'image', + type: 'image', + title: 'Image 2.1', + hidden: ({document}) => { + return Boolean(document?.hideFields) + }, + description: DESCRIPTION, + }, + ], + }, + { + type: 'object', + name: 'nestedArrayObject2', + title: 'Nested array object 2', + fields: [ + { + name: 'string', + type: 'string', + title: 'String 2.2', + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + name: 'image', + type: 'image', + title: 'Image title', + hidden: ({document}) => Boolean(document?.hideFields), + description: DESCRIPTION, + }, + ], +}) diff --git a/dev/test-studio/schema/index.ts b/dev/test-studio/schema/index.ts index 3a15300cc8f..fdd3ba5e896 100644 --- a/dev/test-studio/schema/index.ts +++ b/dev/test-studio/schema/index.ts @@ -113,6 +113,7 @@ import {circularCrossDatasetReferenceTest} from './debug/circularCrossDatasetRef import {allNativeInputComponents} from './debug/allNativeInputComponents' import fieldGroupsWithFieldsets from './debug/fieldGroupsWithFieldsets' import ptReference from './debug/ptReference' +import {commentsDebug} from './debug/comments' // @todo temporary, until code input is v3 compatible const codeInputType = { @@ -159,6 +160,7 @@ export const schemaTypes = [ code, codeInputType, // @todo temporary, until code input is v3 compatible color, + commentsDebug, conditionalFields, conditionalFieldset, customBlock, diff --git a/dev/test-studio/structure/constants.ts b/dev/test-studio/structure/constants.ts index 264ae339150..9dd987bfb62 100644 --- a/dev/test-studio/structure/constants.ts +++ b/dev/test-studio/structure/constants.ts @@ -34,27 +34,26 @@ export const PLUGIN_INPUT_TYPES = [ ] export const DEBUG_INPUT_TYPES = [ - 'languageFilterDebug', 'actionsTest', - 'simpleArrayOfObjects', - 'simpleReferences', - 'formInputDebug', + 'allNativeInputComponents', + 'collapsibleObjects', + 'commentsDebug', 'conditionalFieldsTest', 'customInputsTest', 'customInputsWithPatches', 'documentActionsTest', - 'collapsibleObjects', + 'documentWithHoistedPt', 'empty', 'fieldActionsTest', 'fieldComponentsTest', 'fieldsetsTest', 'fieldValidationInferReproDoc', 'focusTest', - 'documentWithHoistedPt', + 'formInputDebug', 'initialValuesTest', 'inspectorsTest', 'invalidPreviews', - 'thesis', + 'languageFilterDebug', 'manyFieldsTest', 'noTitleField', 'poppers', @@ -62,22 +61,24 @@ export const DEBUG_INPUT_TYPES = [ 'previewImageUrlTest', 'previewMediaTest', 'previewSelectBugRepro', + 'ptReference', 'radio', 'readOnlyTest', - 'recursiveDocument', 'recursiveArraysTest', + 'recursiveDocument', 'recursiveObjectTest', 'recursivePopoverTest', 'reservedKeywordsTest', + 'scrollBug', 'select', + 'simpleArrayOfObjects', + 'simpleReferences', + 'thesis', 'typeWithNoToplevelStrings', 'uploadsTest', 'validationTest', - 'allNativeInputComponents', - 'scrollBug', - 'ptReference', - 'virtualizationInObject', 'virtualizationDebug', + 'virtualizationInObject', ] export const CI_INPUT_TYPES = ['conditionalFieldset', 'validationCI', 'textsTest'] 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/playwright-ct/tests/comments/CommentInput.spec.tsx b/packages/sanity/playwright-ct/tests/comments/CommentInput.spec.tsx new file mode 100644 index 00000000000..1b34489d901 --- /dev/null +++ b/packages/sanity/playwright-ct/tests/comments/CommentInput.spec.tsx @@ -0,0 +1,49 @@ +import {expect, test} from '@playwright/experimental-ct-react' +import React from 'react' +import {testHelpers} from '../utils/testHelpers' +import {CommentsInputStory} from './CommentInputStory' + +test.describe('Comments', () => { + test.describe('CommentInput', () => { + test('Should render', async ({mount, page}) => { + await mount() + const $editable = page.getByTestId('comment-input-editable') + await expect($editable).toBeVisible() + }) + + test('Should be able to type into', async ({mount, page}) => { + const {insertPortableText} = testHelpers({page}) + await mount() + const $editable = page.getByTestId('comment-input-editable') + await expect($editable).toBeEditable() + await insertPortableText('My first comment!', $editable) + await expect($editable).toHaveText('My first comment!') + }) + + test('Should bring up mentions menu when typing @', async ({mount, page}) => { + await mount() + const $editable = page.getByTestId('comment-input-editable') + await $editable.waitFor({state: 'visible'}) + await page.keyboard.type('@') + await expect(page.getByTestId('comments-mentions-menu')).toBeVisible() + }) + + test('Should be able to submit', async ({mount, page}) => { + const {insertPortableText} = testHelpers({page}) + let submitted = false + const onSubmit = () => { + submitted = true + } + await mount() + const $editable = page.getByTestId('comment-input-editable') + await expect($editable).toBeEditable() + // Test that blank comments can't be submitted + await page.keyboard.press('Enter') + expect(submitted).toBe(false) + await insertPortableText('This is a comment!', $editable) + await expect($editable).toHaveText('This is a comment!') + await page.keyboard.press('Enter') + expect(submitted).toBe(true) + }) + }) +}) diff --git a/packages/sanity/playwright-ct/tests/comments/CommentInputStory.tsx b/packages/sanity/playwright-ct/tests/comments/CommentInputStory.tsx new file mode 100644 index 00000000000..a7ff572789c --- /dev/null +++ b/packages/sanity/playwright-ct/tests/comments/CommentInputStory.tsx @@ -0,0 +1,47 @@ +import React, {useState} from 'react' +import {CurrentUser, PortableTextBlock} from '@sanity/types' +import {noop} from 'lodash' +import {CommentInput} from '../../../src/desk/comments/src/components/pte/comment-input/CommentInput' +import {TestWrapper} from '../formBuilder/utils/TestWrapper' + +const currentUser: CurrentUser = { + email: '', + id: '', + name: '', + role: '', + roles: [], + profileImage: '', + provider: '', +} + +const SCHEMA_TYPES: [] = [] + +export function CommentsInputStory({ + onDiscardCancel = noop, + onDiscardConfirm = noop, + onSubmit = noop, + value = null, +}: { + onDiscardCancel?: () => void + onDiscardConfirm?: () => void + onSubmit?: () => void + value?: PortableTextBlock[] | null +}) { + const [valueState, setValueState] = useState(value) + return ( + + + + ) +} diff --git a/packages/sanity/src/core/components/userAvatar/UserAvatar.tsx b/packages/sanity/src/core/components/userAvatar/UserAvatar.tsx index ce22b510931..1ed0aefd150 100644 --- a/packages/sanity/src/core/components/userAvatar/UserAvatar.tsx +++ b/packages/sanity/src/core/components/userAvatar/UserAvatar.tsx @@ -80,7 +80,7 @@ const StaticUserAvatar = forwardRef(function StaticUserAvatar( props: Omit & {user: User}, ref: React.ForwardedRef, ) { - const {user, animateArrowFrom, position, size, status, tone} = props + const {user, animateArrowFrom, position, size, status, tone, ...restProps} = props const [imageLoadError, setImageLoadError] = useState(null) const userColor = useUserColor(user.id) const imageUrl = imageLoadError ? undefined : user?.imageUrl @@ -98,6 +98,7 @@ const StaticUserAvatar = forwardRef(function StaticUserAvatar( size={typeof size === 'string' ? LEGACY_TO_UI_AVATAR_SIZES[size] : size} status={status} title={user?.displayName} + {...restProps} /> ) }) diff --git a/packages/sanity/src/core/config/configPropertyReducers.ts b/packages/sanity/src/core/config/configPropertyReducers.ts index b842b6f0c98..13cffdbeaa9 100644 --- a/packages/sanity/src/core/config/configPropertyReducers.ts +++ b/packages/sanity/src/core/config/configPropertyReducers.ts @@ -14,7 +14,10 @@ import type { NewDocumentOptionsContext, ResolveProductionUrlContext, Tool, + DocumentCommentsEnabledContext, + PluginOptions, } from './types' +import {flattenConfig} from './flattenConfig' export const initialDocumentBadges: DocumentBadgeComponent[] = [] @@ -239,3 +242,31 @@ export const documentInspectorsReducer: ConfigPropertyReducer< )}`, ) } + +export const documentCommentsEnabledReducer = (opts: { + config: PluginOptions + context: DocumentCommentsEnabledContext + initialValue: boolean +}): boolean => { + const {config, context, initialValue} = opts + const flattenedConfig = flattenConfig(config, []) + + // There is no concept of 'previous value' in this API. We only care about the final value. + // That is, if a plugin returns true, but the next plugin returns false, the result will be false. + // The last plugin 'wins'. + const result = flattenedConfig.reduce((acc, {config: innerConfig}) => { + const resolver = innerConfig.document?.unstable_comments?.enabled + + if (!resolver && typeof resolver !== 'boolean') return acc + if (typeof resolver === 'function') return resolver(context) + if (typeof resolver === 'boolean') return resolver + + throw new Error( + `Expected \`document.unstable_comments.enabled\` to be a boolean or a function, but received ${getPrintableType( + resolver, + )}`, + ) + }, initialValue) + + return result +} diff --git a/packages/sanity/src/core/config/prepareConfig.ts b/packages/sanity/src/core/config/prepareConfig.ts index a2074f32099..db2f1ebb565 100644 --- a/packages/sanity/src/core/config/prepareConfig.ts +++ b/packages/sanity/src/core/config/prepareConfig.ts @@ -28,6 +28,7 @@ import type { import { documentActionsReducer, documentBadgesReducer, + documentCommentsEnabledReducer, documentInspectorsReducer, documentLanguageFilterReducer, fileAssetSourceResolver, @@ -506,6 +507,16 @@ function resolveSource({ propertyName: 'document.unstable_languageFilter', reducer: documentLanguageFilterReducer, }), + + unstable_comments: { + enabled: (partialContext) => { + return documentCommentsEnabledReducer({ + context: partialContext, + config, + initialValue: false, + }) + }, + }, }, form: { file: { diff --git a/packages/sanity/src/core/config/types.ts b/packages/sanity/src/core/config/types.ts index 9d2c6b64f35..756b4fc0ef0 100644 --- a/packages/sanity/src/core/config/types.ts +++ b/packages/sanity/src/core/config/types.ts @@ -276,6 +276,11 @@ export interface DocumentPluginOptions { * @beta */ newDocumentOptions?: NewDocumentOptionsResolver + + /** @internal */ + unstable_comments?: { + enabled: boolean | ((context: DocumentCommentsEnabledContext) => boolean) + } } /** @@ -461,6 +466,12 @@ export interface DocumentInspectorContext extends ConfigContext { documentType: string } +/** @hidden @beta */ +export interface DocumentCommentsEnabledContext { + documentId?: string + documentType: string +} + /** * @hidden * @beta @@ -564,6 +575,11 @@ export interface Source { * @beta */ inspectors: (props: PartialContext) => DocumentInspector[] + + /** @internal */ + unstable_comments: { + enabled: (props: DocumentCommentsEnabledContext) => boolean + } } /** diff --git a/packages/sanity/src/core/form/components/formField/FormField.tsx b/packages/sanity/src/core/form/components/formField/FormField.tsx index 344bdd00bc2..6639318339b 100644 --- a/packages/sanity/src/core/form/components/formField/FormField.tsx +++ b/packages/sanity/src/core/form/components/formField/FormField.tsx @@ -4,6 +4,7 @@ import React, {memo} from 'react' import {FormNodePresence} from '../../../presence' import {DocumentFieldActionNode} from '../../../config' import {useFieldActions} from '../../field' +import {FieldCommentsProps} from '../../types' import {FormFieldBaseHeader} from './FormFieldBaseHeader' import {FormFieldHeaderText} from './FormFieldHeaderText' @@ -21,6 +22,8 @@ export interface FormFieldProps { * @beta */ __unstable_presence?: FormNodePresence[] + /** @internal @deprecated DO NOT USE */ + __internal_comments?: FieldCommentsProps /** @internal @deprecated ONLY USED BY AI ASSIST PLUGIN */ __internal_slot?: React.ReactNode children: React.ReactNode @@ -48,6 +51,7 @@ export const FormField = memo(function FormField( __unstable_headerActions: actions = EMPTY_ARRAY, __unstable_presence: presence = EMPTY_ARRAY, __internal_slot: slot = null, + __internal_comments: comments, children, description, inputId, @@ -72,7 +76,7 @@ export const FormField = memo(function FormField( */} {title && ( (({theme, $right}) => { const {space} = theme.sanity - return css` position: absolute; - bottom: 0px; + bottom: 0; right: ${$right + space[1]}px; ` }) @@ -29,9 +27,7 @@ const ContentBox = styled(Box)<{ $presenceMaxWidth: number }>(({theme, $presenceMaxWidth}) => { const {space} = theme.sanity - return css` - // Limit the width to preserve space for presence avatars max-width: calc(100% - ${$presenceMaxWidth + space[1]}px); min-width: 75%; ` @@ -43,7 +39,6 @@ const SlotBox = styled(Box)<{ }>(({theme, $right, $fieldActionsVisible}) => { const {space} = theme.sanity const right = $fieldActionsVisible ? $right + space[1] : $right - return css` position: absolute; bottom: 0; @@ -51,22 +46,22 @@ const SlotBox = styled(Box)<{ ` }) -const FieldActionsFloatingCard = styled(Card)(({theme}) => { +const FieldActionsFloatingCard = styled(Card)(({theme}: {theme: Theme}) => { const {space} = theme.sanity - return css` - position: absolute; bottom: 0; - right: 0; + gap: ${space[1] / 2}px; padding: ${space[1] / 2}px; + position: absolute; + right: 0; ` }) const MAX_AVATARS = 4 interface FormFieldBaseHeaderProps { - /** @internal @deprecated ONLY USED BY AI ASSIST PLUGIN */ - __internal_slot?: React.ReactNode + __internal_comments?: FieldCommentsProps // DO NOT USE + __internal_slot?: React.ReactNode // ONLY USED BY AI ASSIST PLUGIN actions?: DocumentFieldActionNode[] content: React.ReactNode fieldFocused: boolean @@ -74,27 +69,51 @@ interface FormFieldBaseHeaderProps { presence?: FormNodePresence[] } -/** @internal */ export function FormFieldBaseHeader(props: FormFieldBaseHeaderProps) { - const {__internal_slot: slot, actions, content, presence, fieldFocused, fieldHovered} = props - - // The state refers to if a group field action menu is open + const { + __internal_comments: comments, + __internal_slot: slot, + actions, + content, + fieldFocused, + fieldHovered, + presence, + } = props + + // State for if an actions menu is open const [menuOpen, setMenuOpen] = useState(false) + // States for floating card element and its width const [floatingCardElement, setFloatingCardElement] = useState(null) + const [floatingCardWidth, setFloatingCardWidth] = useState(0) + // States for slot element and its width const [slotElement, setSlotElement] = useState(null) - - // The amount the presence box should be offset to the right - const [floatingCardWidth, setFloatingCardWidth] = useState(0) const [slotWidth, setSlotWidth] = useState(0) + // Extract comment related data with default values + const { + hasComments = false, + button: commentButton = null, + isAddingComment = false, + } = comments || {} + + // Determine if actions exist and if field actions should be shown const hasActions = actions && actions.length > 0 - const showFieldActions = hasActions && (fieldFocused || fieldHovered || menuOpen) + const showFieldActions = fieldFocused || fieldHovered || menuOpen || isAddingComment + + // Determine the shadow level for the card + const shadow = (showFieldActions && hasActions) || !hasComments ? 3 : undefined - const presenceMaxWidth = calcAvatarStackWidth(MAX_AVATARS) + // Determine if there's a comment button or actions to show. + // We check for `comments.button` since that's the visual element that should be + // used for comments. If no button is provided, we don't have anything to show for comments. + const hasCommentsButtonOrActions = comments?.button || hasActions - // Use the width of the floating card to offset the presence box + // Determine if floating card with actions should be shown + const shouldShowFloatingCard = showFieldActions || hasComments + + // Calculate floating card's width useEffect(() => { if (floatingCardElement) { const {width} = floatingCardElement.getBoundingClientRect() @@ -102,6 +121,7 @@ export function FormFieldBaseHeader(props: FormFieldBaseHeaderProps) { } }, [floatingCardElement, showFieldActions]) + // Calculate slot element's width useEffect(() => { if (slotElement) { const {width} = slotElement.getBoundingClientRect() @@ -109,6 +129,7 @@ export function FormFieldBaseHeader(props: FormFieldBaseHeaderProps) { } }, [slotElement]) + // Construct the slot element if slot is provided const slotEl = useMemo(() => { if (!slot) return null @@ -125,7 +146,7 @@ export function FormFieldBaseHeader(props: FormFieldBaseHeaderProps) { return ( - + {content} @@ -137,15 +158,19 @@ export function FormFieldBaseHeader(props: FormFieldBaseHeaderProps) { {slotEl} - {showFieldActions && ( + {shouldShowFloatingCard && hasCommentsButtonOrActions && ( - + {showFieldActions && hasActions && ( + + )} + + {commentButton} )} diff --git a/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx b/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx index 81b98067e6e..1ab5372ce6d 100644 --- a/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx +++ b/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx @@ -7,6 +7,7 @@ import {FormNodePresence} from '../../../presence' import {DocumentFieldActionNode} from '../../../config' import {useFieldActions} from '../../field' import {createDescriptionId} from '../../members/common/createDescriptionId' +import {FieldCommentsProps} from '../../types' import {FormFieldValidationStatus} from './FormFieldValidationStatus' import {FormFieldSetLegend} from './FormFieldSetLegend' import {focusRingStyle} from './styles' @@ -24,6 +25,8 @@ export interface FormFieldSetProps { * @beta */ __unstable_presence?: FormNodePresence[] + /** @internal @deprecated DO NOT USE */ + __internal_comments?: FieldCommentsProps /** @internal @deprecated ONLY USED BY AI ASSIST PLUGIN */ __internal_slot?: React.ReactNode children: React.ReactNode | (() => React.ReactNode) @@ -96,9 +99,10 @@ export const FormFieldSet = forwardRef(function FormFieldSet( ref: React.ForwardedRef, ) { const { + __internal_comments: comments, + __internal_slot: slot = null, __unstable_headerActions: actions = EMPTY_ARRAY, __unstable_presence: presence = EMPTY_ARRAY, - __internal_slot: slot = null, children, collapsed, collapsible, @@ -150,6 +154,7 @@ export const FormFieldSet = forwardRef(function FormFieldSet( return ( ( /** The value you want to respond to changes in. */ diff --git a/packages/sanity/src/core/form/index.ts b/packages/sanity/src/core/form/index.ts index d86ac84796c..2682899361d 100644 --- a/packages/sanity/src/core/form/index.ts +++ b/packages/sanity/src/core/form/index.ts @@ -16,3 +16,4 @@ export * from './useFormValue' export * from './utils/mutationPatch' export * from './utils/path' export * from './utils/TransformPatches' +export * from './hooks/useDidUpdate' diff --git a/packages/sanity/src/core/form/inputs/CrossDatasetReferenceInput/CrossDatasetReferenceInput.tsx b/packages/sanity/src/core/form/inputs/CrossDatasetReferenceInput/CrossDatasetReferenceInput.tsx index 17ad23d5931..c638c64f1f6 100644 --- a/packages/sanity/src/core/form/inputs/CrossDatasetReferenceInput/CrossDatasetReferenceInput.tsx +++ b/packages/sanity/src/core/form/inputs/CrossDatasetReferenceInput/CrossDatasetReferenceInput.tsx @@ -29,13 +29,13 @@ import {FIXME} from '../../../FIXME' import {ChangeIndicator} from '../../../changeIndicators' import {PreviewCard} from '../../../components' import {useDidUpdate} from '../../hooks/useDidUpdate' +import {useFeatureEnabled} from '../../../hooks' import {CrossDatasetReferenceInfo, CrossDatasetSearchHit, SearchState} from './types' import {OptionPreview} from './OptionPreview' import {GetReferenceInfoFn, useReferenceInfo} from './useReferenceInfo' import {PreviewReferenceValue} from './PreviewReferenceValue' import {ReferenceAutocomplete} from './ReferenceAutocomplete' import {DisabledFeatureWarning} from './DisabledFeatureWarning' -import {useFeatureEnabled} from './useFeatureEnabled' import {useProjectId} from './utils/useProjectId' const INITIAL_SEARCH_STATE: SearchState = { diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx index 2eeebc6f66b..b8d48facae4 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx @@ -33,7 +33,7 @@ import {useScrollIntoViewOnFocusWithin} from '../../hooks/useScrollIntoViewOnFoc import {useDidUpdate} from '../../hooks/useDidUpdate' import {set, unset} from '../../patch' import {AlertStrip} from '../../components/AlertStrip' -import {FieldActionsResolver} from '../../field' +import {FieldActionsProvider, FieldActionsResolver} from '../../field' import {DocumentFieldActionNode} from '../../../config' import {useFormPublishedId} from '../../useFormPublishedId' import {useReferenceInput} from './useReferenceInput' @@ -68,7 +68,7 @@ const MENU_POPOVER_PROPS = {portal: true, tone: 'default'} as const export function ReferenceField(props: ReferenceFieldProps) { const elementRef = useRef(null) - const {schemaType, path, open, inputId, children, inputProps, __internal_slot: slot} = props + const {schemaType, path, open, inputId, children, inputProps} = props const {readOnly, focused, renderPreview, onChange} = props.inputProps const [fieldActionsNodes, setFieldActionNodes] = useState([]) @@ -292,53 +292,58 @@ export function ReferenceField(props: ReferenceFieldProps) { /> )} - - {isEditing ? ( - {children} - ) : ( - - - - - - - {menu} - - {footer} - - - )} - + + {isEditing ? ( + {children} + ) : ( + + + + + + + {menu} + + {footer} + + + )} + + ) } diff --git a/packages/sanity/src/core/form/store/conditional-property/resolveConditionalProperty.ts b/packages/sanity/src/core/form/store/conditional-property/resolveConditionalProperty.ts index b0498a114a0..e5121793da6 100644 --- a/packages/sanity/src/core/form/store/conditional-property/resolveConditionalProperty.ts +++ b/packages/sanity/src/core/form/store/conditional-property/resolveConditionalProperty.ts @@ -1,6 +1,9 @@ /* eslint-disable no-nested-ternary */ import {ConditionalProperty, CurrentUser} from '@sanity/types' +/** + * @internal + */ export interface ConditionalPropertyCallbackContext { parent?: unknown document?: Record @@ -8,6 +11,9 @@ export interface ConditionalPropertyCallbackContext { value: unknown } +/** + * @internal + */ export function resolveConditionalProperty( property: ConditionalProperty, context: ConditionalPropertyCallbackContext, diff --git a/packages/sanity/src/core/form/store/index.ts b/packages/sanity/src/core/form/store/index.ts index 6b3a98a41e4..57a9a4a57c5 100644 --- a/packages/sanity/src/core/form/store/index.ts +++ b/packages/sanity/src/core/form/store/index.ts @@ -3,3 +3,5 @@ export * from './types' export * from './utils/getExpandOperations' export * from './useFormState' export type {FIXME_SanityDocument} from './formState' // eslint-disable-line camelcase + +export {resolveConditionalProperty} from './conditional-property' diff --git a/packages/sanity/src/core/form/studio/inputResolver/fieldResolver.tsx b/packages/sanity/src/core/form/studio/inputResolver/fieldResolver.tsx index caf70a01612..e6bca94dbf0 100644 --- a/packages/sanity/src/core/form/studio/inputResolver/fieldResolver.tsx +++ b/packages/sanity/src/core/form/studio/inputResolver/fieldResolver.tsx @@ -51,6 +51,7 @@ function PrimitiveField(field: FieldProps) { +/** @internal */ export function useFeatureEnabled(featureKey: string): Features { const versionedClient = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) diff --git a/packages/sanity/src/core/store/_legacy/project/types.ts b/packages/sanity/src/core/store/_legacy/project/types.ts index 15bf9709af0..760a45aacfa 100644 --- a/packages/sanity/src/core/store/_legacy/project/types.ts +++ b/packages/sanity/src/core/store/_legacy/project/types.ts @@ -1,3 +1,4 @@ +import {Role} from '@sanity/types' import {Observable} from 'rxjs' /** @@ -26,6 +27,7 @@ export interface ProjectData { isCurrentUser: boolean isRobot: boolean role: string + roles: Role[] }[] features: string[] pendingInvites: number diff --git a/packages/sanity/src/desk/comments/index.ts b/packages/sanity/src/desk/comments/index.ts new file mode 100644 index 00000000000..5490ada2b73 --- /dev/null +++ b/packages/sanity/src/desk/comments/index.ts @@ -0,0 +1,2 @@ +export * from './plugin' +export * from './src' diff --git a/packages/sanity/src/desk/comments/plugin/field/CommentField.tsx b/packages/sanity/src/desk/comments/plugin/field/CommentField.tsx new file mode 100644 index 00000000000..0815509d5b0 --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/field/CommentField.tsx @@ -0,0 +1,340 @@ +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} from '@sanity/ui' +import styled, {css} from 'styled-components' +import scrollIntoViewIfNeeded, {Options} from 'scroll-into-view-if-needed' +import {useInView, motion, AnimatePresence, Variants} from 'framer-motion' +import {hues} from '@sanity/color' +import {COMMENTS_INSPECTOR_NAME} from '../../../panes/document/constants' +import {useDocumentPane} from '../../../panes/document/useDocumentPane' +import {useCommentsEnabled, useComments, CommentCreatePayload} from '../../src' +import {CommentFieldButton} from './CommentFieldButton' +import {FieldProps, getSchemaTypeTitle, useCurrentUser} from 'sanity' + +const HIGHLIGHT_BLOCK_VARIANTS: Variants = { + initial: { + opacity: 0, + }, + animate: { + opacity: 1, + }, + exit: { + opacity: 0, + }, +} + +export function CommentField(props: FieldProps) { + const {documentId, documentType} = useDocumentPane() + + const {isEnabled} = useCommentsEnabled({ + documentId, + documentType, + }) + + if (!isEnabled) { + return props.renderDefault(props) + } + + return +} + +const SCROLL_INTO_VIEW_OPTIONS: ScrollIntoViewOptions = { + behavior: 'smooth', + block: 'start', + inline: 'nearest', +} + +const HighlightDiv = styled(motion.div)(({theme}) => { + const {radius, space, color} = theme.sanity + const bg = hues.blue[color.dark ? 900 : 50].hex + + return css` + mix-blend-mode: ${color.dark ? 'screen' : 'multiply'}; + border-radius: ${radius[3]}px; + top: -${space[2]}px; + left: -${space[2]}px; + bottom: -${space[2]}px; + right: -${space[2]}px; + pointer-events: none; + position: absolute; + z-index: 1; + width: calc(100% + ${space[2] * 2}px); + height: calc(100% + ${space[2] * 2}px); + background-color: ${bg}; + ` +}) + +const FieldStack = styled(Stack)` + position: relative; +` + +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 { + comments, + create, + isRunningSetup, + mentionOptions, + selectedPath, + setSelectedPath, + setStatus, + status, + } = useComments() + + const inView = useInView(rootElementRef) + + const fieldTitle = useMemo(() => getSchemaTypeTitle(props.schemaType), [props.schemaType]) + const currentComments = useMemo(() => comments.data[status], [comments.data, status]) + + const [shouldHighlight, setShouldHighlight] = useState(false) + + const commentsInspectorOpen = useMemo(() => { + return inspector?.name === COMMENTS_INSPECTOR_NAME + }, [inspector?.name]) + + // Determine if the current field is selected + const isSelected = useMemo(() => { + if (!commentsInspectorOpen) return false + if (selectedPath?.origin === 'field') return false + return selectedPath?.fieldPath === PathUtils.toString(props.path) + }, [commentsInspectorOpen, props.path, selectedPath?.fieldPath, selectedPath?.origin]) + + // 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]) + + // Total number of comments for the current field + const count = useMemo(() => { + const stringPath = PathUtils.toString(props.path) + + const commentsCount = comments.data.open + .map((c) => (c.fieldPath === stringPath ? c.commentsCount : 0)) + .reduce((acc, val) => acc + val, 0) + + return commentsCount || 0 + }, [comments.data.open, props.path]) + + const hasComments = Boolean(count > 0) + + // A function that scrolls to the thread group with the given ID + const handleScrollToThread = useCallback( + (threadId: string) => { + if (commentsInspectorOpen && shouldScrollToThread && threadId) { + const node = document.querySelector(`[data-group-id="${threadId}"]`) + + if (node) { + node.scrollIntoView(SCROLL_INTO_VIEW_OPTIONS) + setShouldScrollToThread(false) + } + } + }, + [shouldScrollToThread, commentsInspectorOpen], + ) + + 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 (hasComments) { + setOpen(false) + openInspector(COMMENTS_INSPECTOR_NAME) + } else { + setOpen((v) => !v) + } + + // 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', + threadId: null, + }) + } + }, [ + hasComments, + status, + currentThreadId, + setStatus, + openInspector, + 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: CommentCreatePayload = { + fieldPath: PathUtils.toString(props.path), + message: value, + parentCommentId: undefined, + status: 'open', + threadId: newThreadId, + } + + // Execute the create mutation + create.execute(nextComment) + + // 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) + + // 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', + threadId: newThreadId, + }) + + setShouldScrollToThread(true) + handleScrollToThread(newThreadId) + }) + } + }, [ + create, + handleOpenInspector, + handleScrollToThread, + props.path, + setSelectedPath, + setStatus, + value, + ]) + + const handleDiscard = useCallback(() => setValue(null), []) + + useEffect(() => { + if (currentThreadId) { + handleScrollToThread(currentThreadId) + } + }, [currentThreadId, handleScrollToThread]) + + const scrollIntoViewIfNeededOpts = useMemo( + () => + ({ + ...SCROLL_INTO_VIEW_OPTIONS, + boundary: boundaryElement, + scrollMode: 'if-needed', + block: 'start', + }) satisfies Options, + [boundaryElement], + ) + + useEffect(() => { + // When the field is selected, we want to scroll it into + // view (if needed) and highlight it. + if (isSelected && rootElementRef.current) { + scrollIntoViewIfNeeded(rootElementRef.current, scrollIntoViewIfNeededOpts) + } + }, [boundaryElement, isSelected, props.path, scrollIntoViewIfNeededOpts, selectedPath]) + + useEffect(() => { + const showHighlight = inView && isSelected + + setShouldHighlight(showHighlight) + + const timer = setTimeout(() => { + setShouldHighlight(false) + }, 1200) + + return () => clearTimeout(timer) + }, [currentComments, inView, isSelected, props.path, selectedPath]) + + const internalComments: FieldProps['__internal_comments'] = useMemo( + () => ({ + button: currentUser && ( + + ), + hasComments, + isAddingComment: open, + }), + [ + currentUser, + count, + fieldTitle, + mentionOptions, + handleClick, + handleCommentAdd, + handleDiscard, + open, + value, + isRunningSetup, + hasComments, + ], + ) + + return ( + + + {shouldHighlight && ( + + )} + + + {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 new file mode 100644 index 00000000000..b29e6231649 --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/field/CommentFieldButton.tsx @@ -0,0 +1,206 @@ +import React, {useCallback, useMemo, useRef, useState} from 'react' +import { + Box, + Button, + Flex, + Popover, + Stack, + Text, + Tooltip, + TooltipProps, + useClickOutside, +} from '@sanity/ui' +import styled from 'styled-components' +import { + CommentMessage, + CommentInput, + CommentInputHandle, + hasCommentMessageValue, + AddCommentIcon, + CommentIcon, + MentionOptionsHookValue, +} from '../../src' +import {CurrentUser, PortableTextBlock} from 'sanity' + +const TOOLTIP_DELAY: TooltipProps['delay'] = {open: 500} + +const TooltipText = styled(Text)` + width: max-content; +` + +const ContentStack = styled(Stack)` + width: 320px; +` + +interface CommentFieldButtonProps { + count: number + currentUser: CurrentUser + fieldTitle: string + isRunningSetup: boolean + mentionOptions: MentionOptionsHookValue + onChange: (value: PortableTextBlock[]) => void + onClick?: () => void + onCommentAdd: () => void + onDiscard: () => void + onInputKeyDown?: (event: React.KeyboardEvent) => void + open: boolean + setOpen: (open: boolean) => void + value: CommentMessage +} + +export function CommentFieldButton(props: CommentFieldButtonProps) { + const { + count, + currentUser, + fieldTitle, + isRunningSetup, + mentionOptions, + onChange, + onClick, + onCommentAdd, + onDiscard, + onInputKeyDown, + open, + setOpen, + value, + } = props + const [popoverElement, setPopoverElement] = useState(null) + const commentInputHandle = useRef(null) + const hasComments = Boolean(count > 0) + + const closePopover = useCallback(() => setOpen(false), [setOpen]) + + const handleSubmit = useCallback(() => { + onCommentAdd() + closePopover() + }, [closePopover, onCommentAdd]) + + const hasValue = useMemo(() => hasCommentMessageValue(value), [value]) + + const startDiscard = useCallback(() => { + if (!hasValue) { + closePopover() + return + } + + commentInputHandle.current?.discardDialogController.open() + }, [closePopover, hasValue]) + + const handleInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // Don't act if the input already prevented this event + if (event.isDefaultPrevented()) { + return + } + // Discard the input text + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + startDiscard() + } + // Call parent handler + if (onInputKeyDown) onInputKeyDown(event) + }, + [onInputKeyDown, startDiscard], + ) + + const handleDiscardCancel = useCallback(() => { + commentInputHandle.current?.discardDialogController.close() + }, []) + + const handleDiscardConfirm = useCallback(() => { + commentInputHandle.current?.discardDialogController.close() + closePopover() + onDiscard() + }, [closePopover, onDiscard]) + + useClickOutside(startDiscard, [popoverElement]) + + const placeholder = ( + <> + Add comment to {fieldTitle} + + ) + + if (!hasComments) { + const content = ( + + + + ) + + return ( + +
+ + Add comment + + } + > +
+
+ ) + } + + return ( + + View comment{count > 1 ? 's' : ''} + + } + delay={TOOLTIP_DELAY} + fallbackPlacements={['bottom']} + > + + + ) +} diff --git a/packages/sanity/src/desk/comments/plugin/field/index.ts b/packages/sanity/src/desk/comments/plugin/field/index.ts new file mode 100644 index 00000000000..b2013088000 --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/field/index.ts @@ -0,0 +1 @@ +export * from './CommentField' diff --git a/packages/sanity/src/desk/comments/plugin/index.ts b/packages/sanity/src/desk/comments/plugin/index.ts new file mode 100644 index 00000000000..ac3853c1f37 --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/index.ts @@ -0,0 +1,21 @@ +import {commentsInspector} from './inspector' +import {CommentField} from './field' +import {CommentsLayout} from './layout' +import {definePlugin} from 'sanity' + +export const comments = definePlugin({ + name: 'sanity/desk/comments', + document: { + inspectors: [commentsInspector], + }, + form: { + components: { + field: CommentField, + }, + }, + studio: { + components: { + layout: CommentsLayout, + }, + }, +}) diff --git a/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspector.tsx b/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspector.tsx new file mode 100644 index 00000000000..2b697ff50a7 --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspector.tsx @@ -0,0 +1,296 @@ +import {Flex, useToast} from '@sanity/ui' +import React, {Fragment, useCallback, useEffect, useMemo, useRef, 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' +import { + useComments, + CommentsListHandle, + CommentCreatePayload, + CommentEditPayload, + CommentStatus, + CommentDeleteDialog, + CommentsList, + CommentsOnboardingPopover, + useCommentsOnboarding, +} from '../../src' +import {CommentsInspectorHeader} from './CommentsInspectorHeader' +import {DocumentInspectorProps, useCurrentUser, useUnique} from 'sanity' + +interface CommentToDelete { + commentId: string + isParent: boolean +} + +export function CommentsInspector(props: DocumentInspectorProps) { + const {onClose} = props + + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [commentToDelete, setCommentToDelete] = useState(null) + const [deleteLoading, setDeleteLoading] = useState(false) + const [deleteError, setDeleteError] = useState(null) + 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 pushToast = useToast().push + const {onPathOpen, ready} = useDocumentPane() + + const {isDismissed, setDismissed} = useCommentsOnboarding() + + const { + comments, + create, + edit, + getComment, + getCommentPath, + isRunningSetup, + mentionOptions, + remove, + selectedPath, + setSelectedPath, + setStatus, + status, + update, + } = useComments() + + const currentComments = useMemo(() => comments.data[status], [comments, status]) + + 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 handleChangeView = useCallback( + (nextView: CommentStatus) => { + setStatus(nextView) + setSelectedPath(null) + }, + [setSelectedPath, setStatus], + ) + + const handleCloseInspector = useCallback(() => { + onClose() + setSelectedPath(null) + }, [onClose, setSelectedPath]) + + const handleCopyLink = useCallback( + (id: string) => { + const path = createPathWithParams({ + ...params, + comment: id, + }) + + const url = `${window.location.origin}${path}` + + navigator.clipboard + .writeText(url) + .then(() => { + pushToast({ + closable: true, + status: 'info', + title: 'Copied link to clipboard', + }) + }) + .catch(() => { + pushToast({ + closable: true, + status: 'error', + title: 'Unable to copy link to clipboard', + }) + }) + }, + [createPathWithParams, params, pushToast], + ) + + const handleCreateRetry = useCallback( + (id: string) => { + const comment = getComment(id) + if (!comment) return + + create.execute({ + fieldPath: comment.target.path.field, + id: comment._id, + message: comment.message, + parentCommentId: comment.parentCommentId, + status: comment.status, + threadId: comment.threadId, + }) + }, + [create, getComment], + ) + + const closeDeleteDialog = useCallback(() => { + if (deleteLoading) return + setShowDeleteDialog(false) + setCommentToDelete(null) + }, [deleteLoading]) + + const handlePathSelect = useCallback( + (path: Path, threadId?: string) => { + onPathOpen(path) + setSelectedPath({ + fieldPath: PathUtils.toString(path), + origin: 'inspector', + threadId: threadId || null, + }) + }, + [onPathOpen, setSelectedPath], + ) + + const handleNewThreadCreate = useCallback( + (payload: CommentCreatePayload) => { + create.execute(payload) + + setSelectedPath({ + fieldPath: payload.fieldPath, + origin: 'inspector', + threadId: payload.threadId, + }) + }, + [create, setSelectedPath], + ) + + const handleReply = useCallback( + (payload: CommentCreatePayload) => { + create.execute(payload) + }, + [create], + ) + + const handleEdit = useCallback( + (id: string, payload: CommentEditPayload) => { + edit.execute(id, payload) + }, + [edit], + ) + + const handleStatusChange = useCallback( + (id: string, nextStatus: CommentStatus) => { + update.execute(id, { + status: nextStatus, + }) + }, + [update], + ) + + const onDeleteStart = useCallback( + (id: string) => { + const parent = currentComments.find((c) => c.parentComment?._id === id) + const isParent = Boolean(parent && parent?.replies?.length > 0) + + setShowDeleteDialog(true) + setCommentToDelete({ + commentId: id, + isParent, + }) + }, + + [currentComments], + ) + + const handleDeleteConfirm = useCallback( + async (id: string) => { + try { + setDeleteLoading(true) + await remove.execute(id) + closeDeleteDialog() + } catch (err) { + setDeleteError(err) + } finally { + setDeleteLoading(false) + } + }, + [closeDeleteDialog, remove], + ) + + const handleScrollToComment = useCallback( + (id: string, fieldPath: string) => { + if (fieldPath) { + requestAnimationFrame(() => { + setSelectedPath({ + fieldPath, + origin: 'inspector', + threadId: null, + }) + + commentsListHandleRef.current?.scrollToComment(id) + + setParams({ + ...params, + comment: undefined, + }) + + commentIdParamRef.current = undefined + }) + } + }, + [params, setParams, setSelectedPath], + ) + + useEffect(() => { + const path = getCommentPath(commentIdParamRef.current || '') + + if (path && !loading && commentIdParamRef.current) { + handleScrollToComment(commentIdParamRef.current, path) + } + }, [getCommentPath, handleScrollToComment, loading]) + + return ( + + {commentToDelete && showDeleteDialog && ( + + )} + + + + + + + {currentUser && ( + + )} + + + ) +} diff --git a/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspectorHeader.tsx b/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspectorHeader.tsx new file mode 100644 index 00000000000..0e77925525b --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspectorHeader.tsx @@ -0,0 +1,95 @@ +import {CheckmarkIcon, ChevronDownIcon, DoubleChevronRightIcon} from '@sanity/icons' +import {Button, Card, Flex, Menu, MenuButton, MenuItem, Text} from '@sanity/ui' +import {startCase} from 'lodash' +import React, {forwardRef, useCallback} from 'react' +import styled from 'styled-components' +import {BetaBadge, CommentStatus} from '../../src' + +const Root = styled(Card)({ + position: 'relative', + zIndex: 1, + lineHeight: 0, + + '&:after': { + content: '""', + display: 'block', + position: 'absolute', + left: 0, + bottom: -1, + right: 0, + borderBottom: '1px solid var(--card-border-color)', + opacity: 0.5, + }, +}) + +interface CommentsInspectorHeaderProps { + onClose: () => void + onViewChange: (view: CommentStatus) => void + view: CommentStatus +} + +export const CommentsInspectorHeader = forwardRef(function CommentsInspectorHeader( + props: CommentsInspectorHeaderProps, + ref: React.ForwardedRef, +) { + const {onClose, onViewChange, view} = props + + const handleSetOpenView = useCallback(() => onViewChange('open'), [onViewChange]) + const handleSetResolvedView = useCallback(() => onViewChange('resolved'), [onViewChange]) + + return ( + + + + + Comments + + + + + + + + } + menu={ + + + + + } + popover={{placement: 'bottom-end'}} + /> + +