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']} + > + + + + + + {count > 9 ? '9+' : count} + + + + ) +} 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'}} + /> + + + + + + ) +}) diff --git a/packages/sanity/src/desk/comments/plugin/inspector/index.ts b/packages/sanity/src/desk/comments/plugin/inspector/index.ts new file mode 100644 index 00000000000..84ac03e046b --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/inspector/index.ts @@ -0,0 +1,31 @@ +import {CommentIcon} from '@sanity/icons' +import {COMMENTS_INSPECTOR_NAME} from '../../../panes/document/constants' +import {useCommentsEnabled} from '../../src' +import {CommentsInspector} from './CommentsInspector' +import { + DocumentInspectorMenuItem, + DocumentInspectorUseMenuItemProps, + defineDocumentInspector, +} from 'sanity' + +function useMenuItem(props: DocumentInspectorUseMenuItemProps): DocumentInspectorMenuItem { + const {documentId, documentType} = props + + const {isEnabled} = useCommentsEnabled({ + documentId, + documentType, + }) + + return { + hidden: !isEnabled, + icon: CommentIcon, + showAsAction: true, + title: 'Comments', + } +} + +export const commentsInspector = defineDocumentInspector({ + name: COMMENTS_INSPECTOR_NAME, + component: CommentsInspector, + useMenuItem, +}) diff --git a/packages/sanity/src/desk/comments/plugin/layout/CommentsLayout.tsx b/packages/sanity/src/desk/comments/plugin/layout/CommentsLayout.tsx new file mode 100644 index 00000000000..b95bfb26e15 --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/layout/CommentsLayout.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import {CommentsOnboardingProvider, CommentsSetupProvider} from '../../src' +import {LayoutProps} from 'sanity' + +export function CommentsLayout(props: LayoutProps) { + return ( + + {props.renderDefault(props)} + + ) +} diff --git a/packages/sanity/src/desk/comments/plugin/layout/index.ts b/packages/sanity/src/desk/comments/plugin/layout/index.ts new file mode 100644 index 00000000000..dc487569ade --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/layout/index.ts @@ -0,0 +1 @@ +export * from './CommentsLayout' diff --git a/packages/sanity/src/desk/comments/src/__tests__/buildCommentBreadcrumbs.test.ts b/packages/sanity/src/desk/comments/src/__tests__/buildCommentBreadcrumbs.test.ts new file mode 100644 index 00000000000..bca0e9648d1 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/__tests__/buildCommentBreadcrumbs.test.ts @@ -0,0 +1,262 @@ +import {Schema} from '@sanity/schema' +import {CurrentUser, defineField} from '@sanity/types' +import {buildCommentBreadcrumbs} from '../utils' + +const CURRENT_USER: CurrentUser = { + email: '', + id: '', + name: '', + role: '', + roles: [], + profileImage: '', + provider: '', +} + +const stringWithTitleField = defineField({ + name: 'stringWithTitle', + title: 'My string title', + type: 'string', +}) + +const stringWithoutTitleField = defineField({name: 'stringWithoutTitle', type: 'string'}) + +const stringWithHiddenCallback = defineField({ + name: 'stringWithHiddenCallback', + type: 'string', + hidden: () => true, +}) + +const objectField = defineField({ + name: 'myObject', + title: 'My object title', + type: 'object', + fields: [stringWithTitleField, stringWithHiddenCallback], +}) + +const arrayOfObjectsField = defineField({ + name: 'myArray', + title: 'My array title', + type: 'array', + of: [objectField], +}) + +const nestedArrayOfObjectsField = defineField({ + name: 'myNestedArray', + title: 'My nested array title', + type: 'array', + of: [ + { + type: 'object', + name: 'myNestedObject', + fields: [arrayOfObjectsField], + }, + ], +}) + +const schema = Schema.compile({ + name: 'default', + types: [ + { + name: 'testDocument', + title: 'Document', + type: 'document', + fields: [ + stringWithTitleField, + stringWithoutTitleField, + objectField, + stringWithHiddenCallback, + arrayOfObjectsField, + nestedArrayOfObjectsField, + ], + }, + ], +}) + +describe('comments: buildCommentBreadcrumbs', () => { + it('should use the title in the schema field if it exists', () => { + const crumbs = buildCommentBreadcrumbs({ + currentUser: CURRENT_USER, + documentValue: {}, + fieldPath: 'stringWithTitle', + schemaType: schema.get('testDocument'), + }) + + expect(crumbs).toEqual([{invalid: false, isArrayItem: false, title: 'My string title'}]) + }) + + it('should use the field name as title if no title exists', () => { + const crumbs = buildCommentBreadcrumbs({ + currentUser: CURRENT_USER, + documentValue: {}, + fieldPath: 'stringWithoutTitle', + schemaType: schema.get('testDocument'), + }) + + expect(crumbs).toEqual([{invalid: false, isArrayItem: false, title: 'String Without Title'}]) + }) + + it('should build breadcrumbs for object with nested fields', () => { + const crumbs = buildCommentBreadcrumbs({ + currentUser: CURRENT_USER, + documentValue: {}, + fieldPath: 'myObject.stringWithTitle', + schemaType: schema.get('testDocument'), + }) + + expect(crumbs).toEqual([ + {invalid: false, isArrayItem: false, title: 'My object title'}, + {invalid: false, isArrayItem: false, title: 'My string title'}, + ]) + }) + + it('should invalidate the breadcrumb if the field is hidden', () => { + const crumbs = buildCommentBreadcrumbs({ + currentUser: CURRENT_USER, + documentValue: {}, + fieldPath: 'stringWithHiddenCallback', + schemaType: schema.get('testDocument'), + }) + + expect(crumbs).toEqual([ + {invalid: true, isArrayItem: false, title: 'String With Hidden Callback'}, + ]) + }) + + it('should build breadcrumbs for array of objects', () => { + const crumbs = buildCommentBreadcrumbs({ + currentUser: CURRENT_USER, + documentValue: { + myArray: [ + { + _key: 'key1', + _type: 'myObject', + stringWithTitle: 'Hello world', + }, + { + _key: 'key2', + _type: 'myObject', + stringWithTitle: 'Hello world', + }, + ], + }, + fieldPath: 'myArray[_key=="key2"].stringWithTitle', + schemaType: schema.get('testDocument'), + }) + + expect(crumbs).toEqual([ + {invalid: false, isArrayItem: false, title: 'My array title'}, + {invalid: false, isArrayItem: true, title: '#2'}, + {invalid: false, isArrayItem: false, title: 'My string title'}, + ]) + }) + + it('should invalidate the breadcrumb if the array item is not found', () => { + const crumbs = buildCommentBreadcrumbs({ + currentUser: CURRENT_USER, + documentValue: { + myArray: [ + { + _key: 'key1', + _type: 'myObject', + stringWithTitle: 'Hello world', + }, + { + _key: 'key2', + _type: 'myObject', + stringWithTitle: 'Hello world', + }, + ], + }, + fieldPath: 'myArray[_key=="key3"].stringWithTitle', + schemaType: schema.get('testDocument'), + }) + + expect(crumbs).toEqual([ + {invalid: false, isArrayItem: false, title: 'My array title'}, + {invalid: true, isArrayItem: true, title: 'Unknown array item'}, + {invalid: true, isArrayItem: false, title: 'Unknown field'}, + ]) + }) + + it('should build breadcrumbs for nested array of objects', () => { + const crumbs = buildCommentBreadcrumbs({ + currentUser: CURRENT_USER, + documentValue: { + myNestedArray: [ + { + _key: 'key1', + _type: 'myNestedObject', + myArray: [ + { + _key: 'key2', + _type: 'myObject', + stringWithTitle: 'Hello world', + }, + { + _key: 'key3', + _type: 'myObject', + stringWithTitle: 'Hello world', + }, + { + _key: 'key4', + _type: 'myObject', + stringWithTitle: 'Hello world', + }, + ], + }, + ], + }, + fieldPath: 'myNestedArray[_key=="key1"].myArray[_key=="key3"].stringWithTitle', + schemaType: schema.get('testDocument'), + }) + + expect(crumbs).toEqual([ + {invalid: false, isArrayItem: false, title: 'My nested array title'}, + {invalid: false, isArrayItem: true, title: '#1'}, + {invalid: false, isArrayItem: false, title: 'My array title'}, + {invalid: false, isArrayItem: true, title: '#2'}, + {invalid: false, isArrayItem: false, title: 'My string title'}, + ]) + }) + + it('should invalidate the breadcrumb if a nested array item is hidden', () => { + const crumbs = buildCommentBreadcrumbs({ + currentUser: CURRENT_USER, + documentValue: { + myNestedArray: [ + { + _key: 'key1', + _type: 'myNestedObject', + myArray: [ + { + _key: 'key2', + _type: 'myObject', + stringWithTitle: 'Hello world', + }, + { + _key: 'key3', + _type: 'myObject', + stringWithHiddenCallback: 'Hello world', + }, + { + _key: 'key4', + _type: 'myObject', + stringWithTitle: 'Hello world', + }, + ], + }, + ], + }, + fieldPath: 'myNestedArray[_key=="key1"].myArray[_key=="key3"].stringWithHiddenCallback', + schemaType: schema.get('testDocument'), + }) + + expect(crumbs).toEqual([ + {invalid: false, isArrayItem: false, title: 'My nested array title'}, + {invalid: false, isArrayItem: true, title: '#1'}, + {invalid: false, isArrayItem: false, title: 'My array title'}, + {invalid: false, isArrayItem: true, title: '#2'}, + {invalid: true, isArrayItem: false, title: 'String With Hidden Callback'}, + ]) + }) +}) diff --git a/packages/sanity/src/desk/comments/src/__workshop__/CommentBreadcrumbsStory.tsx b/packages/sanity/src/desk/comments/src/__workshop__/CommentBreadcrumbsStory.tsx new file mode 100644 index 00000000000..f83be90f501 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/__workshop__/CommentBreadcrumbsStory.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import {Flex} from '@sanity/ui' +import {useNumber} from '@sanity/ui-workshop' +import {CommentBreadcrumbs} from '../components' + +export default function CommentBreadcrumbsStory() { + const maxLength = useNumber('Max length', 3, 'Props') || 3 + + return ( + + + + ) +} diff --git a/packages/sanity/src/desk/comments/src/__workshop__/CommentDeleteDialogStory.tsx b/packages/sanity/src/desk/comments/src/__workshop__/CommentDeleteDialogStory.tsx new file mode 100644 index 00000000000..b1eed81b61c --- /dev/null +++ b/packages/sanity/src/desk/comments/src/__workshop__/CommentDeleteDialogStory.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import {useAction, useBoolean} from '@sanity/ui-workshop' +import {CommentDeleteDialog} from '../components' + +export default function CommentDeleteDialogStory() { + const isParent = useBoolean('Is parent', false, 'Props') || false + const error = useBoolean('Error', false, 'Props') || false + const loading = useBoolean('Loading', false, 'Props') || false + + return ( + + ) +} diff --git a/packages/sanity/src/desk/comments/src/__workshop__/CommentInputStory.tsx b/packages/sanity/src/desk/comments/src/__workshop__/CommentInputStory.tsx new file mode 100644 index 00000000000..04e98068657 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/__workshop__/CommentInputStory.tsx @@ -0,0 +1,48 @@ +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 'sanity' + +const noop = () => { + // ... +} + +export default function CommentsInputStory() { + const [value, setValue] = useState(null) + const currentUser = useCurrentUser() + const expandOnFocus = useBoolean('Expand on focus', false, 'Props') + const readOnly = useBoolean('Read only', false, 'Props') + + if (!currentUser) return null + + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/packages/sanity/src/desk/comments/src/__workshop__/CommentsListStory.tsx b/packages/sanity/src/desk/comments/src/__workshop__/CommentsListStory.tsx new file mode 100644 index 00000000000..66297d11861 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/__workshop__/CommentsListStory.tsx @@ -0,0 +1,218 @@ +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} 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', + _createdAt: new Date().toISOString(), + _updatedAt: '2021-05-04T14:54:37Z', + authorId: 'p8U8TipFc', + status: 'open', + _rev: '1', + + threadId: '1', + + target: { + documentType: 'article', + path: { + field: 'title', + }, + 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 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([BASE]) + + 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 readOnly = useBoolean('Read only', false, 'Props') || false + + const currentUser = useCurrentUser() + + const mentionOptions = useMentionOptions(MENTION_HOOK_OPTIONS) + + const handleReplySubmit = useCallback( + (payload: CommentCreatePayload) => { + const reply: CommentDocument = { + ...BASE, + ...payload, + _createdAt: new Date().toISOString(), + _id: uuid(), + authorId: currentUser?.id || 'pP5s3g90N', + parentCommentId: payload.parentCommentId, + } + + setState((prev) => [reply, ...prev]) + }, + [currentUser?.id], + ) + + const handleEdit = useCallback((id: string, payload: CommentEditPayload) => { + setState((prev) => { + return prev.map((item) => { + if (item._id === id) { + return { + ...item, + ...payload, + _updatedAt: new Date().toISOString(), + } + } + + return item + }) + }) + }, []) + + 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.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 ( + + + + + + ) +} diff --git a/packages/sanity/src/desk/comments/src/__workshop__/CommentsProviderStory.tsx b/packages/sanity/src/desk/comments/src/__workshop__/CommentsProviderStory.tsx new file mode 100644 index 00000000000..7e7535763bd --- /dev/null +++ b/packages/sanity/src/desk/comments/src/__workshop__/CommentsProviderStory.tsx @@ -0,0 +1,48 @@ +/* eslint-disable react/jsx-handler-names */ +import React from 'react' +import {useString} from '@sanity/ui-workshop' +import {CommentsList} from '../components' +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' + + return ( + + + + + + ) +} + +function Inner() { + const {comments, create, edit, mentionOptions, remove} = useComments() + const currentUser = useCurrentUser() + + if (!currentUser) return null + + return ( + + ) +} diff --git a/packages/sanity/src/desk/comments/src/__workshop__/MentionOptionsHookStory.tsx b/packages/sanity/src/desk/comments/src/__workshop__/MentionOptionsHookStory.tsx new file mode 100644 index 00000000000..fa1a32751d0 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/__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/desk/comments/src/__workshop__/MentionsMenuStory.tsx b/packages/sanity/src/desk/comments/src/__workshop__/MentionsMenuStory.tsx new file mode 100644 index 00000000000..948b09c9578 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/__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/desk/comments/src/__workshop__/index.ts b/packages/sanity/src/desk/comments/src/__workshop__/index.ts new file mode 100644 index 00000000000..5a4fb1db998 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/__workshop__/index.ts @@ -0,0 +1,44 @@ +import {defineScope} from '@sanity/ui-workshop' +import {lazy} from 'react' + +export default defineScope({ + name: 'desk/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')), + }, + { + name: 'comment-delete-dialog', + title: 'CommentDeleteDialog', + component: lazy(() => import('./CommentDeleteDialogStory')), + }, + { + name: 'comment-breadcrumbs', + title: 'CommentBreadcrumbs', + component: lazy(() => import('./CommentBreadcrumbsStory')), + }, + ], +}) diff --git a/packages/sanity/src/desk/comments/src/components/BetaBadge.tsx b/packages/sanity/src/desk/comments/src/components/BetaBadge.tsx new file mode 100644 index 00000000000..a14d2bcc098 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/BetaBadge.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import {hues} from '@sanity/color' +import {Badge} from '@sanity/ui' +import styled, {css} from 'styled-components' + +export const StyledBadge = styled(Badge)(({theme}) => { + const fgTint = theme.sanity.color.dark ? 50 : 700 + const bgTint = theme.sanity.color.dark ? 700 : 100 + const bg = hues.purple[bgTint].hex + const fg = hues.purple[fgTint].hex + + return css` + background-color: ${bg}; + box-shadow: inset 0 0 0 1px ${bg}; + color: ${fg}; + ` +}) + +export function BetaBadge() { + return Beta +} diff --git a/packages/sanity/src/desk/comments/src/components/CommentBreadcrumbs.tsx b/packages/sanity/src/desk/comments/src/components/CommentBreadcrumbs.tsx new file mode 100644 index 00000000000..aa02014770d --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/CommentBreadcrumbs.tsx @@ -0,0 +1,91 @@ +import {ChevronRightIcon} from '@sanity/icons' +import {Box, Flex, Stack, Text, Tooltip, TooltipProps} from '@sanity/ui' +import React, {Fragment, useMemo} from 'react' + +export interface CommentBreadcrumbsProps { + titlePath: string[] + maxLength: number +} + +type Item = string | string[] + +const TOOLTIP_DELAY: TooltipProps['delay'] = { + close: 0, + open: 500, +} + +const separator = ( + + + +) + +const renderItem = (item: string, index: number) => { + return ( + + + {item} + + + ) +} + +export function CommentBreadcrumbs(props: CommentBreadcrumbsProps) { + const {titlePath, maxLength} = props + + const items: Item[] = useMemo(() => { + const len = titlePath.length + const beforeLength = Math.ceil(maxLength / 2) + const afterLength = Math.floor(maxLength / 2) + + if (maxLength && len > maxLength) { + return [ + ...titlePath.slice(0, beforeLength - 1), + titlePath.slice(beforeLength - 1, len - afterLength), + ...titlePath.slice(len - afterLength), + ] + } + + return titlePath + }, [maxLength, titlePath]) + + const nodes = useMemo(() => { + return items.map((item, index) => { + const key = `${item}-${index}` + const showSeparator = index < items.length - 1 + + if (Array.isArray(item)) { + return ( + + + {item.map(renderItem)} + + } + > + {renderItem('...', index)} + + + {showSeparator && separator} + + ) + } + + return ( + + {renderItem(item, index)} + + {showSeparator && separator} + + ) + }) + }, [items]) + + return ( + + {nodes} + + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/CommentDeleteDialog.tsx b/packages/sanity/src/desk/comments/src/components/CommentDeleteDialog.tsx new file mode 100644 index 00000000000..f83ca70c31a --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/CommentDeleteDialog.tsx @@ -0,0 +1,75 @@ +import {Dialog, Grid, Button, Stack, Text} from '@sanity/ui' +import React, {useCallback} from 'react' +import {TextWithTone} from 'sanity' + +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 ( + + + + + } + > + + {body} + + {error && ( + + An error occurred while deleting the comment. Please try again. + + )} + + + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/TextTooltip.tsx b/packages/sanity/src/desk/comments/src/components/TextTooltip.tsx new file mode 100644 index 00000000000..d91ffbd3cf1 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/TextTooltip.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import {Flex, Text, Tooltip, TooltipProps} from '@sanity/ui' +import styled from 'styled-components' + +const TOOLTIP_DELAY: TooltipProps['delay'] = {open: 500} + +const ContextText = styled(Text)` + min-width: max-content; +` + +interface TextTooltipProps { + children: React.ReactNode + text?: string +} + +export function TextTooltip(props: TextTooltipProps) { + const {children, text} = props + + return ( + {text}} + padding={2} + > + {children} + + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/avatars/CommentsAvatar.tsx b/packages/sanity/src/desk/comments/src/components/avatars/CommentsAvatar.tsx new file mode 100644 index 00000000000..75263dbecd9 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/avatars/CommentsAvatar.tsx @@ -0,0 +1,44 @@ +import React, {useMemo} from 'react' +import styled from 'styled-components' +import {Avatar, AvatarProps} from '@sanity/ui' +import {User} from '@sanity/types' + +const StyledAvatar = styled(Avatar)` + svg > ellipse { + stroke: transparent; + } +` + +const SYMBOLS = /[^\p{Alpha}\p{White_Space}]/gu +const WHITESPACE = /\p{White_Space}+/u + +function nameToInitials(fullName: string) { + const namesArray = fullName.replace(SYMBOLS, '').split(WHITESPACE) + + if (namesArray.length === 1) { + return `${namesArray[0].charAt(0)}`.toUpperCase() + } + + return `${namesArray[0].charAt(0)}${namesArray[namesArray.length - 1].charAt(0)}` +} + +interface CommentsAvatarProps extends AvatarProps { + user: User | undefined | null +} + +export function CommentsAvatar(props: CommentsAvatarProps) { + const {user: userProp, ...restProps} = props + const user = userProp as User + const initials = useMemo(() => nameToInitials(user?.displayName || ''), [user?.displayName]) + + if (!user) return + + return ( + + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/avatars/SpacerAvatar.tsx b/packages/sanity/src/desk/comments/src/components/avatars/SpacerAvatar.tsx new file mode 100644 index 00000000000..232080e8dd9 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/avatars/SpacerAvatar.tsx @@ -0,0 +1,15 @@ +import React from 'react' + +export const AVATAR_HEIGHT = 25 + +const INLINE_STYLE: React.CSSProperties = { + minWidth: AVATAR_HEIGHT, +} + +/** + * This component is used to as a spacer in situations where we want to align + * components without avatars with components that have avatars. + */ +export function SpacerAvatar() { + return +} diff --git a/packages/sanity/src/desk/comments/src/components/avatars/index.ts b/packages/sanity/src/desk/comments/src/components/avatars/index.ts new file mode 100644 index 00000000000..90ba25005e0 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/avatars/index.ts @@ -0,0 +1,2 @@ +export * from './CommentsAvatar' +export * from './SpacerAvatar' diff --git a/packages/sanity/src/desk/comments/src/components/constants.ts b/packages/sanity/src/desk/comments/src/components/constants.ts new file mode 100644 index 00000000000..50d00d1193b --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/constants.ts @@ -0,0 +1,3 @@ +import {FlexProps} from '@sanity/ui' + +export const FLEX_GAP: FlexProps['gap'] = 3 diff --git a/packages/sanity/src/desk/comments/src/components/icons/AddCommentIcon.tsx b/packages/sanity/src/desk/comments/src/components/icons/AddCommentIcon.tsx new file mode 100644 index 00000000000..e1db11f8a01 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/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/desk/comments/src/components/icons/CommentIcon.tsx b/packages/sanity/src/desk/comments/src/components/icons/CommentIcon.tsx new file mode 100644 index 00000000000..c45cff706c2 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/icons/CommentIcon.tsx @@ -0,0 +1,28 @@ +import React, {forwardRef} from 'react' + +// A slightly (arguably) more optically centered version of the current provided by @sanity/icons +// @todo: remove this and replace with an updated version from @sanity/icons +export const CommentIcon = forwardRef(function Icon( + props: React.SVGProps, + ref: React.Ref, +) { + return ( + + + + ) +}) diff --git a/packages/sanity/src/desk/comments/src/components/icons/MentionIcon.tsx b/packages/sanity/src/desk/comments/src/components/icons/MentionIcon.tsx new file mode 100644 index 00000000000..60c1e0a6173 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/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/desk/comments/src/components/icons/SendIcon.tsx b/packages/sanity/src/desk/comments/src/components/icons/SendIcon.tsx new file mode 100644 index 00000000000..ad0535013e5 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/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/desk/comments/src/components/icons/index.ts b/packages/sanity/src/desk/comments/src/components/icons/index.ts new file mode 100644 index 00000000000..91936babe87 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/icons/index.ts @@ -0,0 +1,4 @@ +export * from './SendIcon' +export * from './MentionIcon' +export * from './AddCommentIcon' +export * from './CommentIcon' diff --git a/packages/sanity/src/desk/comments/src/components/index.ts b/packages/sanity/src/desk/comments/src/components/index.ts new file mode 100644 index 00000000000..40ad8c191fc --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/index.ts @@ -0,0 +1,8 @@ +export * from './pte' +export * from './list' +export * from './CommentDeleteDialog' +export * from './TextTooltip' +export * from './CommentBreadcrumbs' +export * from './icons' +export * from './BetaBadge' +export * from './onboarding' diff --git a/packages/sanity/src/desk/comments/src/components/list/CommentThreadLayout.tsx b/packages/sanity/src/desk/comments/src/components/list/CommentThreadLayout.tsx new file mode 100644 index 00000000000..dfb59399ff2 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/list/CommentThreadLayout.tsx @@ -0,0 +1,108 @@ +import {CurrentUser, Path} from '@sanity/types' +import {Button, Flex, Stack} from '@sanity/ui' +import React, {useCallback, useMemo} from 'react' +import {uuid} from '@sanity/uuid' +import styled, {css} from 'styled-components' +import * as PathUtils from '@sanity/util/paths' +import { + CommentMessage, + CommentCreatePayload, + MentionOptionsHookValue, + CommentListBreadcrumbs, +} from '../../types' +import {CommentBreadcrumbs} from '../CommentBreadcrumbs' +import {CreateNewThreadInput} from './CreateNewThreadInput' +import {ThreadCard} from './styles' + +const HeaderFlex = styled(Flex)` + min-height: 25px; +` + +const BreadcrumbsButton = styled(Button)(({theme}) => { + const fg = theme.sanity.color.base.fg + return css` + --card-fg-color: ${fg}; + ` +}) + +interface CommentThreadLayoutProps { + breadcrumbs?: CommentListBreadcrumbs + canCreateNewThread: boolean + children: React.ReactNode + currentUser: CurrentUser + fieldPath: string + mentionOptions: MentionOptionsHookValue + onNewThreadCreate: (payload: CommentCreatePayload) => void + onPathSelect?: (path: Path) => void + readOnly?: boolean +} + +export function CommentThreadLayout(props: CommentThreadLayoutProps) { + const { + breadcrumbs, + canCreateNewThread, + children, + currentUser, + fieldPath, + mentionOptions, + onNewThreadCreate, + onPathSelect, + readOnly, + } = props + + const selectPath = useCallback(() => { + onPathSelect?.(PathUtils.fromString(fieldPath)) + }, [fieldPath, onPathSelect]) + + const handleNewThreadCreate = useCallback( + (payload: CommentMessage) => { + const nextComment: CommentCreatePayload = { + fieldPath, + message: payload, + parentCommentId: undefined, + status: 'open', + // Since this is a new comment, we generate a new thread ID + threadId: uuid(), + } + + onNewThreadCreate?.(nextComment) + }, + [onNewThreadCreate, fieldPath], + ) + + const crumbsTitlePath = useMemo(() => breadcrumbs?.map((p) => p.title) || [], [breadcrumbs]) + const lastCrumb = crumbsTitlePath[crumbsTitlePath.length - 1] + + return ( + + + + + + + + + + + + {canCreateNewThread && ( + + + + )} + + {children} + + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/list/CommentsList.tsx b/packages/sanity/src/desk/comments/src/components/list/CommentsList.tsx new file mode 100644 index 00000000000..1d9b0d63026 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/list/CommentsList.tsx @@ -0,0 +1,228 @@ +import React, {forwardRef, useCallback, useImperativeHandle, useMemo, useState} from 'react' +import {BoundaryElementProvider, Flex, Stack} from '@sanity/ui' +import {CurrentUser, Path} from '@sanity/types' +import { + CommentCreatePayload, + CommentEditPayload, + CommentStatus, + CommentThreadItem, + MentionOptionsHookValue, +} from '../../types' +import {SelectedPath} from '../../context/comments/types' +import {CommentsListItem} from './CommentsListItem' +import {CommentThreadLayout} from './CommentThreadLayout' +import {CommentsListStatus} from './CommentsListStatus' + +const SCROLL_INTO_VIEW_OPTIONS: ScrollIntoViewOptions = { + behavior: 'smooth', + block: 'start', + inline: 'nearest', +} + +interface GroupedComments { + [field: string]: CommentThreadItem[] +} + +function groupThreads(comments: CommentThreadItem[]) { + return comments.reduce((acc, comment) => { + const field = comment.fieldPath + + if (!acc[field]) { + acc[field] = [] + } + + acc[field].push(comment) + + return acc + }, {} as GroupedComments) +} + +/** + * @beta + * @hidden + */ +export interface CommentsListProps { + comments: CommentThreadItem[] + currentUser: CurrentUser + error: Error | null + loading: boolean + mentionOptions: MentionOptionsHookValue + onCopyLink?: (id: string) => void + onCreateRetry: (id: string) => void + onDelete: (id: string) => void + onEdit: (id: string, payload: CommentEditPayload) => void + onNewThreadCreate: (payload: CommentCreatePayload) => void + onPathSelect?: (path: Path, threadId?: string) => void + onReply: (payload: CommentCreatePayload) => void + onStatusChange?: (id: string, status: CommentStatus) => void + readOnly?: boolean + selectedPath: SelectedPath + status: CommentStatus +} + +/** + * @beta + * @hidden + */ +export interface CommentsListHandle { + scrollToComment: (id: string) => void +} + +const CommentsListInner = forwardRef( + function CommentsListInner(props: CommentsListProps, ref) { + const { + comments, + currentUser, + error, + loading, + mentionOptions, + onCopyLink, + onCreateRetry, + onDelete, + onEdit, + onNewThreadCreate, + onPathSelect, + onReply, + onStatusChange, + readOnly, + selectedPath, + status, + } = props + const [boundaryElement, setBoundaryElement] = useState(null) + + const scrollToComment = useCallback((id: string) => { + const commentElement = document?.querySelector(`[data-comment-id="${id}"]`) + + if (commentElement) { + commentElement.scrollIntoView(SCROLL_INTO_VIEW_OPTIONS) + } + }, []) + + const handleListItemPathSelect = useCallback( + (path: Path, threadId?: string) => { + onPathSelect?.(path, threadId) + }, + [onPathSelect], + ) + + useImperativeHandle( + ref, + () => { + return { + scrollToComment, + } + }, + [scrollToComment], + ) + + const groupedThreads = useMemo(() => Object.entries(groupThreads(comments)), [comments]) + + const showComments = !loading && !error && groupedThreads.length > 0 + + return ( + + + + {showComments && ( + + + {groupedThreads?.map(([fieldPath, group]) => { + // Since all threads in the group point to the same field, the breadcrumbs will be + // the same for all of them. Therefore, we can just pick the first one. + const breadcrumbs = group[0].breadcrumbs + + // The thread ID is used to scroll to the thread. + // We pick the first thread id in the group so that we scroll to the first thread + // in the group. + const firstThreadId = group[0].threadId + + return ( + + + {group.map((item) => { + // The default sort order is by date, descending (newest first). + // However, inside a thread, we want the order to be ascending (oldest first). + // So we reverse the array here. + // We use slice() to avoid mutating the original array. + const replies = item.replies.slice().reverse() + + const canReply = + status === 'open' && + item.parentComment._state?.type !== 'createError' && + item.parentComment._state?.type !== 'createRetrying' + + // Check if the current field is selected + const hasSelectedThread = selectedPath?.threadId + const threadIsSelected = selectedPath?.threadId === item.threadId + const fieldIsSelect = selectedPath?.fieldPath === item.fieldPath + const isSelected = threadIsSelected || (fieldIsSelect && !hasSelectedThread) + + return ( + handleListItemPathSelect(path, item.threadId)} + onReply={onReply} + onStatusChange={onStatusChange} + parentComment={item.parentComment} + readOnly={readOnly} + replies={replies} + selected={isSelected} + /> + ) + })} + + + ) + })} + + + )} + + ) + }, +) + +/** + * @beta + * @hidden + */ +export const CommentsList = React.memo(CommentsListInner) diff --git a/packages/sanity/src/desk/comments/src/components/list/CommentsListItem.tsx b/packages/sanity/src/desk/comments/src/components/list/CommentsListItem.tsx new file mode 100644 index 00000000000..5a40d7f9416 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/list/CommentsListItem.tsx @@ -0,0 +1,320 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' +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 {CommentInput, CommentInputHandle} from '../pte' +import { + CommentCreatePayload, + CommentDocument, + CommentEditPayload, + CommentMessage, + CommentStatus, + MentionOptionsHookValue, +} from '../../types' +import {SpacerAvatar} from '../avatars' +import {hasCommentMessageValue} from '../../helpers' +import {CommentsListItemLayout} from './CommentsListItemLayout' +import {ThreadCard} from './styles' + +const EMPTY_ARRAY: [] = [] + +const MAX_COLLAPSED_REPLIES = 5 + +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 + + return css` + font-weight: ${medium}; + ` +}) + +const GhostButton = styled(Button)` + opacity: 0; + position: absolute; + right: 0; + top: 0; + bottom: 0; + left: 0; +` + +interface CommentsListItemProps { + canReply?: boolean + currentUser: CurrentUser + mentionOptions: MentionOptionsHookValue + onCopyLink?: (id: string) => void + onCreateRetry: (id: string) => void + onDelete: (id: string) => void + onEdit: (id: string, payload: CommentEditPayload) => void + onKeyDown?: (event: React.KeyboardEvent) => void + onPathSelect?: (path: Path) => void + onReply: (payload: CommentCreatePayload) => void + onStatusChange?: (id: string, status: CommentStatus) => void + parentComment: CommentDocument + readOnly?: boolean + replies: CommentDocument[] | undefined + selected?: boolean +} + +export const CommentsListItem = React.memo(function CommentsListItem(props: CommentsListItemProps) { + const { + canReply, + currentUser, + mentionOptions, + onCopyLink, + onCreateRetry, + onDelete, + onEdit, + onKeyDown, + onPathSelect, + onReply, + onStatusChange, + parentComment, + readOnly, + replies = EMPTY_ARRAY, + selected, + } = props + const [value, setValue] = useState(EMPTY_ARRAY) + const [collapsed, setCollapsed] = useState(true) + const didExpand = useRef(false) + const rootRef = useRef(null) + const replyInputRef = useRef(null) + + const hasValue = useMemo(() => hasCommentMessageValue(value), [value]) + + const handleReplySubmit = useCallback(() => { + const nextComment: CommentCreatePayload = { + fieldPath: parentComment.target.path.field, + message: value, + parentCommentId: parentComment._id, + status: parentComment?.status || 'open', + // Since this is a reply to an existing comment, we use the same thread ID as the parent + threadId: parentComment.threadId, + } + + onReply?.(nextComment) + setValue(EMPTY_ARRAY) + }, [ + onReply, + parentComment._id, + parentComment?.status, + parentComment.target.path.field, + parentComment.threadId, + value, + ]) + + const startDiscard = useCallback(() => { + if (!hasValue) { + setValue(EMPTY_ARRAY) + return + } + + replyInputRef.current?.discardDialogController.open() + }, [hasValue]) + + const handleInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // Don't act if the input already prevented this event + if (event.isDefaultPrevented()) { + return + } + // Discard input text with Escape + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + startDiscard() + } + // TODO: this would be cool + // Edit last comment if current user is the owner and pressing arrowUp + // if (event.key === 'ArrowUp') { + // const lastReply = replies.splice(-1)[0] + // if (lastReply?.authorId === currentUser.id && !hasValue) { + // + // } + // } + }, + [startDiscard], + ) + + const cancelDiscard = useCallback(() => { + replyInputRef.current?.discardDialogController.close() + }, []) + + const confirmDiscard = useCallback(() => { + setValue(EMPTY_ARRAY) + replyInputRef.current?.discardDialogController.close() + replyInputRef.current?.focus() + }, []) + + const handleThreadRootClick = useCallback(() => { + const path = PathUtils.fromString(parentComment.target.path.field) + onPathSelect?.(path) + }, [onPathSelect, parentComment.target.path.field]) + + const handleExpand = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setCollapsed(false) + didExpand.current = true + }, []) + + const splicedReplies = useMemo(() => { + if (collapsed) return replies?.slice(-MAX_COLLAPSED_REPLIES) + return replies + }, [replies, collapsed]) + + const showCollapseButton = useMemo(() => { + if (!replies) return false + return replies.length > MAX_COLLAPSED_REPLIES + }, [replies]) + + const expandButtonText = useMemo(() => { + return `${replies?.length - MAX_COLLAPSED_REPLIES} more ${ + replies?.length - MAX_COLLAPSED_REPLIES === 1 ? 'comment' : 'comments' + }` + }, [replies?.length]) + + useEffect(() => { + if (replies.length > MAX_COLLAPSED_REPLIES && !didExpand.current) { + setCollapsed(true) + } + }, [replies]) + + const renderedReplies = useMemo( + () => + splicedReplies.map((reply) => ( + + + + )), + [ + currentUser, + handleInputKeyDown, + mentionOptions, + onCopyLink, + onCreateRetry, + onDelete, + onEdit, + readOnly, + splicedReplies, + ], + ) + + return ( + + + + + + + + + + {showCollapseButton && !didExpand.current && ( + + + + + + )} + + {renderedReplies} + {canReply && ( + + )} + + + + ) +}) 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..f026891a805 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/list/CommentsListItemContextMenu.tsx @@ -0,0 +1,147 @@ +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 + readOnly?: boolean + status: CommentStatus +} + +export function CommentsListItemContextMenu(props: CommentsListItemContextMenuProps) { + const { + canDelete, + canEdit, + isParent, + onCopyLink, + onDeleteStart, + onEditStart, + onMenuClose, + onMenuOpen, + onStatusChange, + readOnly, + status, + ...rest + } = props + + const showMenuButton = Boolean(onCopyLink || onDeleteStart || onEditStart) + + return ( + + + + {isParent && ( + + + + )} + + + } + onOpen={onMenuOpen} + onClose={onMenuClose} + menu={ + + + + + + + + + + } + popover={POPOVER_PROPS} + /> + + + + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/list/CommentsListItemLayout.tsx b/packages/sanity/src/desk/comments/src/components/list/CommentsListItemLayout.tsx new file mode 100644 index 00000000000..553c74e3236 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/list/CommentsListItemLayout.tsx @@ -0,0 +1,364 @@ +import { + TextSkeleton, + Flex, + Stack, + Text, + Card, + useGlobalKeyDown, + useClickOutside, + Box, +} from '@sanity/ui' +import React, {useCallback, useMemo, useRef, useState} from 'react' +import {CurrentUser} from '@sanity/types' +import styled, {css} from 'styled-components' +import {format} from 'date-fns' +import {CommentMessageSerializer} from '../pte' +import {CommentInput, CommentInputHandle} from '../pte/comment-input' +import { + CommentDocument, + CommentEditPayload, + CommentMessage, + CommentStatus, + MentionOptionsHookValue, +} from '../../types' +import {FLEX_GAP} from '../constants' +import {hasCommentMessageValue, useCommentHasChanged} from '../../helpers' +import {AVATAR_HEIGHT, CommentsAvatar, SpacerAvatar} from '../avatars' +import {CommentsListItemContextMenu} from './CommentsListItemContextMenu' +import {TimeAgoOpts, useTimeAgo, useUser, useDidUpdate} from 'sanity' + +export function StopPropagation(props: React.PropsWithChildren) { + const {children} = props + + const handleClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation() + }, []) + + return {children} +} + +const SKELETON_INLINE_STYLE: React.CSSProperties = {width: '50%'} + +const TimeText = styled(Text)` + min-width: max-content; +` + +const InnerStack = styled(Stack)` + transition: opacity 200ms ease; + + &[data-muted='true'] { + transition: unset; + opacity: 0.5; + } +` + +const ErrorFlex = styled(Flex)` + min-height: ${AVATAR_HEIGHT}px; +` + +const RetryCardButton = styled(Card)` + // Add not on hover + &:not(:hover) { + background-color: transparent; + } +` + +const StyledCommentsListItemContextMenu = styled(CommentsListItemContextMenu)`` + +const RootStack = styled(Stack)(({theme}) => { + const {space} = theme.sanity + + return css` + position: relative; + + // Only show the floating layer on hover when hover is supported. + // Else, the layer is always visible. + @media (hover: hover) { + ${StyledCommentsListItemContextMenu} { + opacity: 0; + position: absolute; + right: 0; + top: 0; + + transform: translate(${space[1]}px, -${space[1]}px); + } + + ${StyledCommentsListItemContextMenu} { + &:focus-within { + opacity: 1; + } + } + + &:hover { + ${StyledCommentsListItemContextMenu} { + opacity: 1; + } + } + } + + &[data-menu-open='true'] { + ${StyledCommentsListItemContextMenu} { + opacity: 1; + } + } + ` +}) + +interface CommentsListItemLayoutProps { + canDelete?: boolean + canEdit?: boolean + comment: CommentDocument + currentUser: CurrentUser + hasError?: boolean + isParent?: boolean + isRetrying?: boolean + mentionOptions: MentionOptionsHookValue + onCopyLink?: (id: string) => void + onCreateRetry?: (id: string) => void + onDelete: (id: string) => void + onEdit: (id: string, message: CommentEditPayload) => void + onInputKeyDown?: (event: React.KeyboardEvent) => void + onStatusChange?: (id: string, status: CommentStatus) => void + readOnly?: boolean +} + +const TIME_AGO_OPTS: TimeAgoOpts = {agoSuffix: true} + +export function CommentsListItemLayout(props: CommentsListItemLayoutProps) { + const { + canDelete, + canEdit, + comment, + currentUser, + hasError, + isParent, + isRetrying, + mentionOptions, + onCopyLink, + onCreateRetry, + onDelete, + onEdit, + onInputKeyDown, + onStatusChange, + readOnly, + } = props + const {_createdAt, authorId, message, _id, lastEditedAt} = comment + const [user] = useUser(authorId) + + const [value, setValue] = useState(message) + const [isEditing, setIsEditing] = useState(false) + const [rootElement, setRootElement] = useState(null) + const startMessage = useRef(message) + const [menuOpen, setMenuOpen] = useState(false) + + const hasChanges = useCommentHasChanged(value) + const hasValue = useMemo(() => hasCommentMessageValue(value), [value]) + + const commentInputRef = useRef(null) + + const createdDate = _createdAt ? new Date(_createdAt) : new Date() + const createdTimeAgo = useTimeAgo(createdDate, TIME_AGO_OPTS) + const formattedCreatedAt = format(createdDate, 'PPPPp') + + const formattedLastEditAt = lastEditedAt ? format(new Date(lastEditedAt), 'PPPPp') : null + const displayError = hasError || isRetrying + + const handleMenuOpen = useCallback(() => setMenuOpen(true), []) + const handleMenuClose = useCallback(() => setMenuOpen(false), []) + const handleCopyLink = useCallback(() => onCopyLink?.(_id), [_id, onCopyLink]) + const handleCreateRetry = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onCreateRetry?.(_id) + }, + [_id, onCreateRetry], + ) + const handleDelete = useCallback(() => onDelete(_id), [_id, onDelete]) + + const cancelEdit = useCallback(() => { + setIsEditing(false) + setValue(startMessage.current) + }, []) + + const startDiscard = useCallback(() => { + if (!hasValue || !hasChanges) { + cancelEdit() + return + } + commentInputRef.current?.discardDialogController.open() + }, [cancelEdit, hasChanges, 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 cancelDiscard = useCallback(() => { + commentInputRef.current?.discardDialogController.close() + }, []) + + const confirmDiscard = useCallback(() => { + commentInputRef.current?.discardDialogController.close() + cancelEdit() + }, [cancelEdit]) + + const handleEditSubmit = useCallback(() => { + onEdit(_id, {message: value}) + setIsEditing(false) + }, [_id, onEdit, value]) + + const handleOpenStatusChange = useCallback(() => { + onStatusChange?.(_id, comment.status === 'open' ? 'resolved' : 'open') + }, [_id, comment.status, onStatusChange]) + + const toggleEdit = useCallback(() => { + setIsEditing((v) => !v) + }, []) + + useDidUpdate(isEditing, () => { + setMenuOpen(false) + }) + + useGlobalKeyDown((event) => { + if (event.key === 'Escape' && !hasChanges) { + cancelEdit() + } + }) + + useClickOutside(() => { + if (!hasChanges) { + cancelEdit() + } + }, [rootElement]) + + const avatar = + + const name = user?.displayName ? ( + + {user.displayName} + + ) : ( + + ) + + return ( + + + + {avatar} + + + + {name} + + {!displayError && ( + + + {createdTimeAgo} + + + {formattedLastEditAt && ( + + (edited) + + )} + + )} + + + + {!isEditing && !displayError && ( + + + + )} + + + {isEditing && ( + + + + + + + + )} + + {!isEditing && ( + + + + + + )} + + + {displayError && ( + + + + + + {hasError && 'Failed to send.'} + {isRetrying && 'Posting...'} + + + + + + Retry + + + + + + )} + + ) +} 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/CreateNewThreadInput.tsx b/packages/sanity/src/desk/comments/src/components/list/CreateNewThreadInput.tsx new file mode 100644 index 00000000000..44cb74133e0 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/list/CreateNewThreadInput.tsx @@ -0,0 +1,100 @@ +import {CurrentUser} from '@sanity/types' +import {EMPTY_ARRAY} from '@sanity/ui-workshop' +import React, {useState, useCallback, useRef, useMemo} from 'react' +import {CommentMessage, MentionOptionsHookValue} from '../../types' +import {CommentInput, CommentInputHandle, CommentInputProps} from '../pte' +import {hasCommentMessageValue} from '../../helpers' + +interface CreateNewThreadInputProps { + currentUser: CurrentUser + fieldName: string + mentionOptions: MentionOptionsHookValue + onBlur?: CommentInputProps['onBlur'] + onFocus?: CommentInputProps['onFocus'] + onKeyDown?: (event: React.KeyboardEvent) => void + onNewThreadCreate: (payload: CommentMessage) => void + readOnly?: boolean +} + +export function CreateNewThreadInput(props: CreateNewThreadInputProps) { + const { + currentUser, + fieldName, + mentionOptions, + onBlur, + onFocus, + onKeyDown, + onNewThreadCreate, + readOnly, + } = props + + const [value, setValue] = useState(EMPTY_ARRAY) + const commentInputHandle = useRef(null) + + const handleSubmit = useCallback(() => { + onNewThreadCreate?.(value) + setValue(EMPTY_ARRAY) + }, [onNewThreadCreate, value]) + + const hasValue = useMemo(() => hasCommentMessageValue(value), [value]) + + const startDiscard = useCallback(() => { + if (!hasValue) { + return + } + commentInputHandle.current?.discardDialogController.open() + }, [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 (onKeyDown) onKeyDown(event) + }, + [onKeyDown, startDiscard], + ) + + const confirmDiscard = useCallback(() => { + setValue(EMPTY_ARRAY) + commentInputHandle.current?.discardDialogController.close() + commentInputHandle.current?.focus() + }, []) + + const cancelDiscard = useCallback(() => { + commentInputHandle.current?.discardDialogController.close() + }, []) + + const placeholder = ( + <> + Add comment to {fieldName} + > + ) + + return ( + + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/list/index.ts b/packages/sanity/src/desk/comments/src/components/list/index.ts new file mode 100644 index 00000000000..b249329e251 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/list/index.ts @@ -0,0 +1,2 @@ +export * from './CommentsList' +export * from './CommentsListItem' diff --git a/packages/sanity/src/desk/comments/src/components/list/styles.ts b/packages/sanity/src/desk/comments/src/components/list/styles.ts new file mode 100644 index 00000000000..743ffec53af --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/list/styles.ts @@ -0,0 +1,11 @@ +import {Card, CardProps} from '@sanity/ui' +import styled from 'styled-components' + +export const ThreadCard = styled(Card).attrs(({tone}) => ({ + padding: 3, + radius: 3, + sizing: 'border', + tone: tone || 'transparent', +}))` + // ... +` diff --git a/packages/sanity/src/desk/comments/src/components/mentions/MentionsMenu.tsx b/packages/sanity/src/desk/comments/src/components/mentions/MentionsMenu.tsx new file mode 100644 index 00000000000..915d9309506 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/mentions/MentionsMenu.tsx @@ -0,0 +1,118 @@ +import {Box, Flex, Spinner, Stack, Text} from '@sanity/ui' +import styled from 'styled-components' +import React, {useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react' +import {MentionOptionUser} from '../../types' +import {MentionsMenuItem} from './MentionsMenuItem' +import {CommandList, CommandListHandle} from 'sanity' + +const EMPTY_ARRAY: MentionOptionUser[] = [] + +const Root = styled(Stack)({ + maxWidth: '220px', // todo: improve +}) + +const ITEM_HEIGHT = 41 +const LIST_PADDING = 4 +const MAX_ITEMS = 7 + +const FlexWrap = styled(Flex)({ + maxHeight: ITEM_HEIGHT * MAX_ITEMS + LIST_PADDING * 2 + ITEM_HEIGHT / 2, +}) + +export interface MentionsMenuHandle { + setSearchTerm: (term: string) => void +} +interface MentionsMenuProps { + loading: boolean + inputElement?: HTMLDivElement | null + onSelect: (userId: string) => void + options: MentionOptionUser[] | null +} + +export const MentionsMenu = React.forwardRef(function MentionsMenu( + props: MentionsMenuProps, + ref: React.Ref, +) { + const {loading, onSelect, options = [], inputElement} = props + const [searchTerm, setSearchTerm] = useState('') + const commandListRef = useRef(null) + + useImperativeHandle( + ref, + () => { + return { + setSearchTerm(term: string) { + setSearchTerm(term) + }, + } + }, + [], + ) + + const renderItem = useCallback( + (itemProps: MentionOptionUser) => { + return + }, + [onSelect], + ) + + const getItemDisabled = useCallback( + (index: number) => { + return !options?.[index]?.canBeMentioned + }, + [options], + ) + + const filteredOptions = useMemo(() => { + if (!searchTerm) return options || EMPTY_ARRAY + + const filtered = options?.filter((option) => { + return option?.displayName?.toLowerCase().includes(searchTerm.toLowerCase()) + }) + + return filtered || EMPTY_ARRAY + }, [options, searchTerm]) + + if (loading) { + return ( + + + + + + ) + } + + // In this case the input element is the actual content editable HTMLDivElement from the PTE. + // Typecast it to an input element to make the CommandList component happy. + const _inputElement = inputElement ? (inputElement as HTMLInputElement) : undefined + + return ( + + {filteredOptions.length === 0 && ( + + + No users found + + + )} + + {filteredOptions.length > 0 && ( + + + + )} + + ) +}) diff --git a/packages/sanity/src/desk/comments/src/components/mentions/MentionsMenuItem.tsx b/packages/sanity/src/desk/comments/src/components/mentions/MentionsMenuItem.tsx new file mode 100644 index 00000000000..7cc97ec1377 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/mentions/MentionsMenuItem.tsx @@ -0,0 +1,53 @@ +import {TextSkeleton, Flex, Text, Card, Box, Badge} from '@sanity/ui' +import React, {useCallback} from 'react' +import styled from 'styled-components' +import {MentionOptionUser} from '../../types' +import {CommentsAvatar} from '../avatars' +import {useUser} from 'sanity' + +const InnerFlex = styled(Flex)`` + +const SKELETON_INLINE_STYLE: React.CSSProperties = {width: '50%'} + +interface MentionsItemProps { + user: MentionOptionUser + onSelect: (userId: string) => void +} + +export function MentionsMenuItem(props: MentionsItemProps) { + const {user, onSelect} = props + const [loadedUser] = useUser(user.id) + + const avatar = ( + + ) + + const text = loadedUser ? ( + + {loadedUser.displayName} + + ) : ( + + ) + + const handleSelect = useCallback(() => { + onSelect(user.id) + }, [onSelect, user.id]) + + return ( + + + + {avatar} + {text} + + + {!user.canBeMentioned && ( + + Unauthorized + + )} + + + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/mentions/index.ts b/packages/sanity/src/desk/comments/src/components/mentions/index.ts new file mode 100644 index 00000000000..ef357c6638f --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/mentions/index.ts @@ -0,0 +1 @@ +export * from './MentionsMenu' diff --git a/packages/sanity/src/desk/comments/src/components/onboarding/CommentsOnboardingPopover.tsx b/packages/sanity/src/desk/comments/src/components/onboarding/CommentsOnboardingPopover.tsx new file mode 100644 index 00000000000..19a61914c75 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/onboarding/CommentsOnboardingPopover.tsx @@ -0,0 +1,42 @@ +import {Box, Button, Flex, Popover, PopoverProps, Stack, Text} from '@sanity/ui' +import React from 'react' +import styled from 'styled-components' + +const Root = styled(Box)` + max-width: 280px; +` + +interface CommentsOnboardingPopoverProps extends Omit { + // ... + onDismiss: () => void +} + +export function CommentsOnboardingPopover(props: CommentsOnboardingPopoverProps) { + const {onDismiss} = props + + return ( + + + + Collaborate in One Place + + + + Add a comment on any field. All comments for this document will be here, grouped by + field. + + + + + + + + } + open + portal + {...props} + /> + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/onboarding/index.ts b/packages/sanity/src/desk/comments/src/components/onboarding/index.ts new file mode 100644 index 00000000000..1045515d23a --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/onboarding/index.ts @@ -0,0 +1 @@ +export * from './CommentsOnboardingPopover' diff --git a/packages/sanity/src/desk/comments/src/components/pte/CommentMessageSerializer.tsx b/packages/sanity/src/desk/comments/src/components/pte/CommentMessageSerializer.tsx new file mode 100644 index 00000000000..964ae752900 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/CommentMessageSerializer.tsx @@ -0,0 +1,78 @@ +import {Stack} from '@sanity/ui' +import styled, {css} from 'styled-components' +import React from 'react' +import {PortableText, PortableTextComponents} from '@portabletext/react' +import {CommentMessage} from '../../types' +import {MentionInlineBlock, NormalBlock} from './blocks' + +const PortableTextWrap = styled(Stack)(() => { + return css` + & > [data-ui='Text']:not(:first-child) { + margin-top: 1em; // todo: improve + } + + & > [data-ui='Text']:has(> span:empty) { + display: none; + } + ` +}) + +const EMPTY_ARRAY: [] = [] + +const components: PortableTextComponents = { + block: { + normal: ({children}) => {children}, + + // Since we do not offer any formatting options, we can just use the normal block for all of these. + h1: ({children}) => {children}, + h2: ({children}) => {children}, + h3: ({children}) => {children}, + h4: ({children}) => {children}, + h5: ({children}) => {children}, + h6: ({children}) => {children}, + blockquote: ({children}) => {children}, + code: ({children}) => {children}, + }, + list: { + bullet: ({children}) => children, + number: ({children}) => <>{children}>, + checkmarks: ({children}) => <>{children}>, + }, + listItem: { + bullet: ({children}) => {children}, + number: ({children}) => {children}, + checkmarks: ({children}) => {children}, + }, + marks: { + // Since we do not offer any formatting options, we can just use the normal block for all of these. + strong: ({children}) => <>{children}>, + em: ({children}) => <>{children}>, + code: ({children}) => <>{children}>, + underline: ({children}) => <>{children}>, + strikeThrough: ({children}) => <>{children}>, + link: ({children}) => <>{children}>, + }, + types: { + mention: (props) => { + return + }, + }, +} + +interface CommentMessageSerializerProps { + blocks: CommentMessage +} + +/** + * @beta + * @hidden + */ +export function CommentMessageSerializer(props: CommentMessageSerializerProps) { + const {blocks} = props + + return ( + + + + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/pte/blocks/MentionInlineBlock.tsx b/packages/sanity/src/desk/comments/src/components/pte/blocks/MentionInlineBlock.tsx new file mode 100644 index 00000000000..b926e1af8e9 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/blocks/MentionInlineBlock.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import {Tooltip, Flex, Text, TooltipProps} from '@sanity/ui' +import styled, {css} from 'styled-components' +import {CommentsAvatar} from '../../avatars' +import {useCurrentUser, useUser} from 'sanity' + +const TOOLTIP_DELAY: TooltipProps['delay'] = {open: 500, close: 0} + +const Span = styled.span(({theme}) => { + const {regular} = theme.sanity.fonts?.text.weights + const {hovered} = theme.sanity.color?.card + const {bg} = theme.sanity.color.selectable?.caution.pressed || {} + + return css` + font-weight: ${regular}; + color: var(--card-link-fg-color); + border-radius: 2px; + background-color: ${hovered.bg}; + padding: 1px; + box-sizing: border-box; + + &[data-active='true'] { + background-color: ${bg}; + } + ` +}) + +interface MentionInlineBlockProps { + userId: string + selected: boolean +} + +export function MentionInlineBlock(props: MentionInlineBlockProps) { + const {selected, userId} = props + const [user, loading] = useUser(userId) + const currentUser = useCurrentUser() + + if (!user || loading) return @Loading // todo: improve + + return ( + + + + + + {user.displayName} + + } + > + + @{user.displayName} + + + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/pte/blocks/NormalBlock.tsx b/packages/sanity/src/desk/comments/src/components/pte/blocks/NormalBlock.tsx new file mode 100644 index 00000000000..6cabe631bf9 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/blocks/NormalBlock.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import {Flex, Text} from '@sanity/ui' +import styled from 'styled-components' + +const NormalText = styled(Text)` + word-break: break-word; +` + +interface NormalBlockProps { + children: React.ReactNode +} + +export function NormalBlock(props: NormalBlockProps) { + const {children} = props + + return {children} + return ( + + {children} + + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/pte/blocks/index.ts b/packages/sanity/src/desk/comments/src/components/pte/blocks/index.ts new file mode 100644 index 00000000000..d8eb8b84f11 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/blocks/index.ts @@ -0,0 +1,2 @@ +export * from './MentionInlineBlock' +export * from './NormalBlock' diff --git a/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInput.tsx b/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInput.tsx new file mode 100644 index 00000000000..b15a9a1cc49 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInput.tsx @@ -0,0 +1,218 @@ +import React, {forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react' +import {EditorChange, PortableTextEditor, keyGenerator} from '@sanity/portable-text-editor' +import {CurrentUser, PortableTextBlock} from '@sanity/types' +import FocusLock from 'react-focus-lock' +import {Stack} from '@sanity/ui' +import {editorSchemaType} from '../config' +import {MentionOptionsHookValue} from '../../../types' +import {CommentInputInner} from './CommentInputInner' +import {CommentInputProvider} from './CommentInputProvider' +import {CommentInputDiscardDialog} from './CommentInputDiscardDialog' + +const EMPTY_ARRAY: [] = [] + +const SCROLL_INTO_VIEW_OPTIONS: ScrollIntoViewOptions = { + behavior: 'smooth', + block: 'center', + inline: 'center', +} + +export interface CommentInputProps { + currentUser: CurrentUser + expandOnFocus?: boolean + focusLock?: boolean + focusOnMount?: boolean + mentionOptions: MentionOptionsHookValue + onBlur?: (e: React.FormEvent) => void + onChange: (value: PortableTextBlock[]) => void + onDiscardCancel: () => void + onDiscardConfirm: () => void + onFocus?: (e: React.FormEvent) => void + onKeyDown?: (e: React.KeyboardEvent) => void + onMentionMenuOpenChange?: (open: boolean) => void + onSubmit: () => void + placeholder?: React.ReactNode + readOnly?: boolean + value: PortableTextBlock[] | null + withAvatar?: boolean +} + +interface CommentDiscardDialogController { + open: () => void + close: () => void +} + +export interface CommentInputHandle { + blur: () => void + discardDialogController: CommentDiscardDialogController + focus: () => void + scrollTo: () => void + reset: () => void +} + +/** + * @beta + * @hidden + */ +export const CommentInput = forwardRef( + function CommentInput(props, ref) { + const { + currentUser, + expandOnFocus, + focusLock = false, + focusOnMount, + mentionOptions, + onBlur, + onChange, + onDiscardCancel, + onDiscardConfirm, + onFocus, + onKeyDown, + onMentionMenuOpenChange, + onSubmit, + placeholder, + readOnly, + value = EMPTY_ARRAY, + withAvatar = true, + } = props + const [focused, setFocused] = useState(false) + const editorRef = useRef(null) + const editorContainerRef = useRef(null) + const [showDiscardDialog, setShowDiscardDialog] = useState(false) + + // A unique (React) key for the editor instance. + const [editorInstanceKey, setEditorInstanceKey] = useState(keyGenerator()) + + const requestFocus = useCallback(() => { + requestAnimationFrame(() => { + if (!editorRef.current) return + PortableTextEditor.focus(editorRef.current) + }) + }, []) + + const resetEditorInstance = useCallback(() => { + setEditorInstanceKey(keyGenerator()) + }, []) + + const handleChange = useCallback( + (change: EditorChange) => { + // Focus the editor when ready if focusOnMount is true + if (change.type === 'ready') { + if (focusOnMount) { + requestFocus() + } + } + if (change.type === 'focus') { + setFocused(true) + } + + if (change.type === 'blur') { + setFocused(false) + } + + // Update the comment value whenever the comment is edited by the user. + if (change.type === 'patch' && editorRef.current) { + const editorStateValue = PortableTextEditor.getValue(editorRef.current) + onChange(editorStateValue || EMPTY_ARRAY) + } + }, + [focusOnMount, onChange, requestFocus], + ) + + const scrollToEditor = useCallback(() => { + editorContainerRef.current?.scrollIntoView(SCROLL_INTO_VIEW_OPTIONS) + }, []) + + const handleSubmit = useCallback(() => { + onSubmit() + resetEditorInstance() + requestFocus() + scrollToEditor() + }, [onSubmit, requestFocus, resetEditorInstance, scrollToEditor]) + + const handleDiscardConfirm = useCallback(() => { + onDiscardConfirm() + resetEditorInstance() + }, [onDiscardConfirm, resetEditorInstance]) + + // The way a user a comment can be discarded varies from the context it is used in. + // This controller is used to take care of the main logic of the discard process, while + // specific behavior is handled by the consumer. + const discardDialogController = useMemo((): CommentDiscardDialogController => { + return { + open: () => { + setShowDiscardDialog(true) + }, + close: () => { + setShowDiscardDialog(false) + requestFocus() + }, + } + }, [requestFocus]) + + useImperativeHandle( + ref, + () => { + return { + focus: requestFocus, + blur() { + if (editorRef.current) { + PortableTextEditor.blur(editorRef.current) + } + }, + scrollTo: scrollToEditor, + reset: resetEditorInstance, + + discardDialogController, + } + }, + [discardDialogController, requestFocus, resetEditorInstance, scrollToEditor], + ) + + return ( + <> + {showDiscardDialog && ( + + )} + + + + + + + + + + + > + ) + }, +) diff --git a/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputDiscardDialog.tsx b/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputDiscardDialog.tsx new file mode 100644 index 00000000000..263d3bb9ec8 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputDiscardDialog.tsx @@ -0,0 +1,80 @@ +import { + Dialog, + Grid, + Button, + Stack, + Text, + ThemeColorProvider, + useBoundaryElement, + PortalProvider, + DialogProvider, +} from '@sanity/ui' +import React, {useCallback} from 'react' + +const Z_OFFSET = 9999999 // Change to appropriate z-offset + +/** + * @beta + * @hidden + */ +export interface CommentInputDiscardDialogProps { + onClose: () => void + onConfirm: () => void +} + +/** + * @beta + * @hidden + */ +export function CommentInputDiscardDialog(props: CommentInputDiscardDialogProps) { + const {onClose, onConfirm} = props + + const portal = useBoundaryElement() + + const handleCancelClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onClose() + }, + [onClose], + ) + + const handleConfirmClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onConfirm() + }, + [onConfirm], + ) + + // The ThemeColorProvider is needed to make sure that the backdrop of the dialog not + // inherits the tone of parent color providers. + // The PortalProvider and DialogProvider is needed to make sure that the dialog is + // rendered fullscreen and not scoped to the form view. + return ( + + + + + + + + } + > + + Do you want to discard the comment? + + + + + + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputInner.tsx b/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputInner.tsx new file mode 100644 index 00000000000..04fcd29288b --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputInner.tsx @@ -0,0 +1,154 @@ +import React, {useCallback} from 'react' +import {Flex, Button, MenuDivider, Box, Card, Stack} from '@sanity/ui' +import styled, {css} from 'styled-components' +import {CurrentUser} from '@sanity/types' +import {MentionIcon, SendIcon} from '../../icons' +import {CommentsAvatar} from '../../avatars/CommentsAvatar' +import {useCommentInput} from './useCommentInput' +import {Editable} from './Editable' +import {useUser} from 'sanity' + +const EditableWrap = styled(Box)` + max-height: 20vh; + overflow-y: auto; +` + +const ButtonDivider = styled(MenuDivider)({ + height: 20, + width: 1, +}) + +const ActionButton = styled(Button).attrs({ + fontSize: 1, + padding: 2, +})` + /* border-radius: 50%; */ +` + +const RootCard = styled(Card)(({theme}) => { + const radii = theme.sanity.radius[2] + + return css` + border-radius: ${radii}px; + + &:not([data-expand-on-focus='false'], :focus-within) { + background: transparent; + box-shadow: unset; + } + + &[data-focused='true']:focus-within { + ${EditableWrap} { + min-height: 1em; + } + + 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); + } + + &:focus-within { + ${EditableWrap} { + min-height: 1em; + } + } + + &[data-expand-on-focus='false'] { + ${EditableWrap} { + min-height: 1em; + } + } + + &[data-expand-on-focus='true'] { + [data-ui='CommentInputActions']:not([hidden]) { + display: none; + } + + &:focus-within { + [data-ui='CommentInputActions'] { + display: flex; + } + } + } + ` +}) + +interface CommentInputInnerProps { + currentUser: CurrentUser + focusLock?: boolean + onBlur?: (e: React.FormEvent) => void + onFocus?: (e: React.FormEvent) => void + onKeyDown?: (e: React.KeyboardEvent) => void + onSubmit: () => void + placeholder?: React.ReactNode + withAvatar?: boolean +} + +export function CommentInputInner(props: CommentInputInnerProps) { + const {currentUser, focusLock, onBlur, onFocus, onKeyDown, onSubmit, placeholder, withAvatar} = + props + + const [user] = useUser(currentUser.id) + const {canSubmit, expandOnFocus, focused, hasChanges, insertAtChar, openMentions, readOnly} = + useCommentInput() + + const avatar = withAvatar ? : null + + const handleMentionButtonClicked = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + insertAtChar() + openMentions() + }, + [insertAtChar, openMentions], + ) + + return ( + + {avatar} + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputProvider.tsx b/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputProvider.tsx new file mode 100644 index 00000000000..453fea555c7 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/comment-input/CommentInputProvider.tsx @@ -0,0 +1,247 @@ +import { + EditorSelection, + PortableTextEditor, + usePortableTextEditor, +} from '@sanity/portable-text-editor' +import React, {useCallback, useMemo, useState} from 'react' +import {Path, isKeySegment, isPortableTextSpan, isPortableTextTextBlock} from '@sanity/types' +import {CommentMessage, MentionOptionsHookValue} from '../../../types' +import {hasCommentMessageValue, useCommentHasChanged} from '../../../helpers' +import {useDidUpdate} from 'sanity' + +export interface CommentInputContextValue { + canSubmit?: boolean + closeMentions: () => void + editor: PortableTextEditor + expandOnFocus?: boolean + focused: boolean + focusEditor: () => void + focusOnMount?: boolean + hasChanges: boolean + insertAtChar: () => void + insertMention: (userId: string) => void + mentionOptions: MentionOptionsHookValue + mentionsMenuOpen: boolean + mentionsSearchTerm: string + onBeforeInput: (event: InputEvent) => void + openMentions: () => void + readOnly: boolean + value: CommentMessage +} + +export const CommentInputContext = React.createContext(null) + +interface CommentInputProviderProps { + children: React.ReactNode + expandOnFocus?: boolean + focused: boolean + focusOnMount?: boolean + mentionOptions: MentionOptionsHookValue + onMentionMenuOpenChange?: (open: boolean) => void + readOnly?: boolean + value: CommentMessage +} + +export function CommentInputProvider(props: CommentInputProviderProps) { + const { + children, + expandOnFocus = false, + focused, + focusOnMount = false, + mentionOptions, + onMentionMenuOpenChange, + value, + readOnly, + } = props + + const editor = usePortableTextEditor() + + const [mentionsMenuOpen, setMentionsMenuOpen] = useState(false) + const [mentionsSearchTerm, setMentionsSearchTerm] = useState('') + const [selectionAtMentionInsert, setSelectionAtMentionInsert] = useState(null) + + const canSubmit = useMemo(() => hasCommentMessageValue(value), [value]) + + const hasChanges = useCommentHasChanged(value) + + const focusEditor = useCallback(() => { + if (readOnly) return + PortableTextEditor.focus(editor) + }, [editor, readOnly]) + + const closeMentions = useCallback(() => { + setMentionsMenuOpen(false) + setMentionsSearchTerm('') + setSelectionAtMentionInsert(null) + }, []) + + const openMentions = useCallback(() => { + setMentionsMenuOpen(true) + setMentionsSearchTerm('') + setMentionsMenuOpen(true) + setSelectionAtMentionInsert(PortableTextEditor.getSelection(editor)) + }, [editor]) + + // This function activates or deactivates the mentions menu and updates + // the mention search term when the user types into the Portable Text Editor. + const onBeforeInput = useCallback( + (event: InputEvent): void => { + const selection = PortableTextEditor.getSelection(editor) + const cursorOffset = selection ? selection.focus.offset : 0 + const focusChild = PortableTextEditor.focusChild(editor) + const focusSpan = (isPortableTextSpan(focusChild) && focusChild) || undefined + + const isInsertText = event.inputType === 'insertText' + const isDeleteText = event.inputType === 'deleteContentBackward' + const isInsertingAtChar = isInsertText && event.data === '@' + + const lastIndexOfAt = focusSpan?.text.substring(0, cursorOffset).lastIndexOf('@') || 0 + + const isWhitespaceCharBeforeCursorPosition = + focusSpan?.text.substring(cursorOffset - 1, cursorOffset) === ' ' + + const filterStartsWithSpaceChar = isInsertText && event.data === ' ' && !mentionsSearchTerm + + // If we are inserting a '@' character - open the mentions menu and reset the search term. + // Only do this if it is in the start of the text, or if '@' is inserted when following a whitespace char. + if (isInsertingAtChar && (cursorOffset < 1 || isWhitespaceCharBeforeCursorPosition)) { + openMentions() + return + } + + // If the user begins typing their filter with a space, or if they are deleting + // characters after activation and the '@' is no longer there, + // clear the search term and close the mentions menu. + if ( + filterStartsWithSpaceChar || + (isDeleteText && + (focusSpan?.text.length === 1 || lastIndexOfAt === (focusSpan?.text.length || 0) - 1)) + ) { + closeMentions() + return + } + + // Update the search term + if (isPortableTextSpan(focusChild)) { + // Term starts with the @ char in the value until the cursor offset + let term = focusChild.text.substring(lastIndexOfAt + 1, cursorOffset) + // Add the char to the mentions search term + if (isInsertText) { + term += event.data + } + // Exclude the char from the mentions search term + if (isDeleteText) { + term = term.substring(0, term.length - 1) + } + // Set the updated mentions search term + setMentionsSearchTerm(term) + } + }, + [closeMentions, editor, mentionsSearchTerm, openMentions], + ) + + const insertAtChar = useCallback(() => { + setMentionsMenuOpen(true) + PortableTextEditor.insertChild(editor, editor.schemaTypes.span, {text: '@'}) + setSelectionAtMentionInsert(PortableTextEditor.getSelection(editor)) + }, [editor]) + + useDidUpdate(mentionsMenuOpen, () => onMentionMenuOpenChange?.(mentionsMenuOpen)) + + const insertMention = useCallback( + (userId: string) => { + const mentionSchemaType = editor.schemaTypes.inlineObjects.find((t) => t.name === 'mention') + let mentionPath: Path | undefined + + const [span, spanPath] = + (selectionAtMentionInsert && + PortableTextEditor.findByPath(editor, selectionAtMentionInsert.focus.path)) || + [] + if (span && isPortableTextSpan(span) && spanPath && mentionSchemaType) { + PortableTextEditor.focus(editor) + const offset = PortableTextEditor.getSelection(editor)?.focus.offset + if (typeof offset !== 'undefined') { + PortableTextEditor.delete( + editor, + { + anchor: {path: spanPath, offset: span.text.lastIndexOf('@')}, + focus: {path: spanPath, offset}, + }, + {mode: 'selected'}, + ) + mentionPath = PortableTextEditor.insertChild(editor, mentionSchemaType, { + _type: 'mention', + userId: userId, + }) + } + + const focusBlock = PortableTextEditor.focusBlock(editor) + + // Set the focus on the next text node after the mention object + if (focusBlock && isPortableTextTextBlock(focusBlock) && mentionPath) { + const mentionKeyPathSegment = mentionPath?.slice(-1)[0] + const nextChildKey = + focusBlock.children[ + focusBlock.children.findIndex( + (c) => isKeySegment(mentionKeyPathSegment) && c._key === mentionKeyPathSegment._key, + ) + 1 + ]?._key + + if (nextChildKey) { + const path: Path = [{_key: focusBlock._key}, 'children', {_key: nextChildKey}] + const sel: EditorSelection = { + anchor: {path, offset: 0}, + focus: {path, offset: 0}, + } + PortableTextEditor.select(editor, sel) + PortableTextEditor.focus(editor) + } + } + } + }, + [editor, selectionAtMentionInsert], + ) + + const ctxValue = useMemo( + (): CommentInputContextValue => ({ + canSubmit, + closeMentions, + editor, + expandOnFocus, + focused, + focusEditor, + focusOnMount, + hasChanges, + insertAtChar, + insertMention, + mentionOptions, + mentionsMenuOpen, + mentionsSearchTerm, + onBeforeInput, + openMentions, + readOnly: Boolean(readOnly), + value, + }), + [ + canSubmit, + closeMentions, + editor, + expandOnFocus, + focused, + focusEditor, + focusOnMount, + hasChanges, + insertAtChar, + insertMention, + mentionOptions, + mentionsMenuOpen, + mentionsSearchTerm, + onBeforeInput, + openMentions, + readOnly, + value, + ], + ) + + return {children} +} diff --git a/packages/sanity/src/desk/comments/src/components/pte/comment-input/Editable.tsx b/packages/sanity/src/desk/comments/src/components/pte/comment-input/Editable.tsx new file mode 100644 index 00000000000..0688f4c4f44 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/comment-input/Editable.tsx @@ -0,0 +1,235 @@ +import React, {KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {Popover, PortalProvider, Stack, useClickOutside, useGlobalKeyDown} from '@sanity/ui' +import { + EditorSelection, + PortableTextEditable, + usePortableTextEditorSelection, +} from '@sanity/portable-text-editor' +import styled, {css} from 'styled-components' +import {isEqual} from 'lodash' +import {isPortableTextSpan, isPortableTextTextBlock} from '@sanity/types' +import {MentionsMenu, MentionsMenuHandle} from '../../mentions' +import {renderBlock, renderChild} from '../render' +import {useCommentInput} from './useCommentInput' +import {useCursorElement} from './useCursorElement' + +const INLINE_STYLE: React.CSSProperties = {outline: 'none'} + +const EditableWrapStack = styled(Stack)(() => { + return css` + & > div:first-child { + [data-slate-node='element']:not(:last-child) { + margin-bottom: 1em; // todo: improve + } + } + ` +}) + +export const StyledPopover = styled(Popover)(({theme}) => { + const {space, radius} = theme.sanity + + return css` + &[data-placement='bottom'] { + transform: translateY(${space[1]}px); + } + + &[data-placement='top'] { + transform: translateY(-${space[1]}px); + } + + [data-ui='Popover__wrapper'] { + border-radius: ${radius[3]}px; + display: flex; + flex-direction: column; + overflow: clip; + overflow: hidden; + position: relative; + width: 300px; // todo: improve + } + ` +}) + +interface EditableProps { + focusLock?: boolean + onBlur?: (e: React.FormEvent) => void + onFocus?: (e: React.FormEvent) => void + onKeyDown?: (e: React.KeyboardEvent) => void + onSubmit?: () => void + placeholder?: React.ReactNode +} + +export interface EditableHandle { + setShowMentionOptions: (show: boolean) => void +} + +export function Editable(props: EditableProps) { + const { + focusLock, + placeholder = 'Create a new comment', + onFocus, + onBlur, + onKeyDown, + onSubmit, + } = props + const [popoverElement, setPopoverElement] = useState(null) + const rootElementRef = useRef(null) + const editableRef = useRef(null) + const mentionsMenuRef = useRef(null) + const selection = usePortableTextEditorSelection() + + const { + canSubmit, + closeMentions, + insertMention, + mentionOptions, + mentionsMenuOpen, + mentionsSearchTerm, + onBeforeInput, + value, + } = useCommentInput() + + const renderPlaceholder = useCallback(() => {placeholder}, [placeholder]) + + useClickOutside( + useCallback(() => { + if (mentionsMenuOpen) { + closeMentions() + } + }, [closeMentions, mentionsMenuOpen]), + [popoverElement], + ) + + // Update the mentions search term in the mentions menu + useEffect(() => { + mentionsMenuRef.current?.setSearchTerm(mentionsSearchTerm) + }, [mentionsSearchTerm]) + + // Close mentions if the user selects text + useEffect(() => { + if (mentionsMenuOpen && selection && !isEqual(selection.anchor, selection.focus)) { + closeMentions() + } + }, [mentionsMenuOpen, closeMentions, selection]) + + // Close mentions if the menu itself has focus and user press Escape + useGlobalKeyDown((event) => { + if (event.code === 'Escape' && mentionsMenuOpen) { + closeMentions() + } + }) + + const cursorElement = useCursorElement({ + disabled: !mentionsMenuOpen, + rootElement: rootElementRef.current, + }) + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + switch (event.key) { + case 'Enter': + // Shift enter is used to insert a new line, + // keep the default behavior + if (event.shiftKey) { + break + } + // Enter is being used both to select something from the mentionsMenu + // or to submit the comment. Prevent the default behavior. + event.preventDefault() + event.stopPropagation() + + // If the mention menu is open close it, but don't submit. + if (mentionsMenuOpen) { + closeMentions() + break + } + + // Submit the comment if eligible for submission + if (onSubmit && canSubmit) { + onSubmit() + } + break + case 'Escape': + case 'ArrowLeft': + case 'ArrowRight': + if (mentionsMenuOpen) { + // stop these events if the menu is open + event.preventDefault() + event.stopPropagation() + closeMentions() + } + break + default: + } + // Call parent key handler + if (onKeyDown) onKeyDown(event) + }, + [canSubmit, closeMentions, mentionsMenuOpen, onKeyDown, onSubmit], + ) + + const initialSelectionAtEndOfContent: EditorSelection | undefined = useMemo(() => { + if (selection) { + return undefined + } + const lastBlock = (value || []).slice(-1)[0] + const lastChild = isPortableTextTextBlock(lastBlock) + ? lastBlock.children.slice(-1)[0] + : undefined + if (!lastChild) { + return undefined + } + const point = { + path: [{_key: lastBlock._key}, 'children', {_key: lastChild._key}], + offset: isPortableTextSpan(lastChild) ? lastChild.text.length : 0, + } + return { + focus: point, + anchor: point, + } + }, [value, selection]) + + return ( + <> + + + } + disabled={!mentionsMenuOpen} + fallbackPlacements={['bottom', 'top']} + open={mentionsMenuOpen} + placement="bottom" + portal + ref={setPopoverElement} + referenceElement={cursorElement} + /> + + + + + + > + ) +} diff --git a/packages/sanity/src/desk/comments/src/components/pte/comment-input/index.ts b/packages/sanity/src/desk/comments/src/components/pte/comment-input/index.ts new file mode 100644 index 00000000000..f2d91840608 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/comment-input/index.ts @@ -0,0 +1,3 @@ +export * from './CommentInput' +export * from './CommentInputProvider' +export * from './Editable' diff --git a/packages/sanity/src/desk/comments/src/components/pte/comment-input/useCommentInput.ts b/packages/sanity/src/desk/comments/src/components/pte/comment-input/useCommentInput.ts new file mode 100644 index 00000000000..bcdcdc4df04 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/comment-input/useCommentInput.ts @@ -0,0 +1,12 @@ +import {useContext} from 'react' +import {CommentInputContext, CommentInputContextValue} from './CommentInputProvider' + +export function useCommentInput(): CommentInputContextValue { + const ctx = useContext(CommentInputContext) + + if (!ctx) { + throw new Error('useCommentInputContext must be used within a CommentInputProvider') + } + + return ctx +} diff --git a/packages/sanity/src/desk/comments/src/components/pte/comment-input/useCursorElement.ts b/packages/sanity/src/desk/comments/src/components/pte/comment-input/useCursorElement.ts new file mode 100644 index 00000000000..5970a8fdef0 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/comment-input/useCursorElement.ts @@ -0,0 +1,57 @@ +import {useState, useMemo, useEffect, useCallback} from 'react' + +const EVENT_LISTENER_OPTIONS: AddEventListenerOptions = {passive: true} + +interface CursorElementHookOptions { + disabled: boolean + rootElement: HTMLElement | null +} + +export function useCursorElement(opts: CursorElementHookOptions): HTMLElement | null { + const {disabled, rootElement} = opts + const [cursorRect, setCursorRect] = useState(null) + + const cursorElement = useMemo(() => { + if (!cursorRect) { + return null + } + return { + getBoundingClientRect: () => { + return cursorRect + }, + } as HTMLElement + }, [cursorRect]) + + const handleSelectionChange = useCallback(() => { + if (disabled) { + setCursorRect(null) + return + } + + const sel = window.getSelection() + + if (!sel || !sel.isCollapsed || sel.rangeCount === 0) return + + const range = sel.getRangeAt(0) + const isWithinRoot = rootElement?.contains(range.commonAncestorContainer) + + if (!isWithinRoot) { + setCursorRect(null) + return + } + const rect = range?.getBoundingClientRect() + if (rect) { + setCursorRect(rect) + } + }, [disabled, rootElement]) + + useEffect(() => { + document.addEventListener('selectionchange', handleSelectionChange, EVENT_LISTENER_OPTIONS) + + return () => { + document.removeEventListener('selectionchange', handleSelectionChange) + } + }, [handleSelectionChange]) + + return cursorElement +} diff --git a/packages/sanity/src/desk/comments/src/components/pte/config.ts b/packages/sanity/src/desk/comments/src/components/pte/config.ts new file mode 100644 index 00000000000..2af06e94fe2 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/config.ts @@ -0,0 +1,34 @@ +import {Schema} from '@sanity/schema' +import {defineArrayMember, defineField} from '@sanity/types' + +export const mentionObject = defineField({ + name: 'mention', + type: 'object', + fields: [ + { + name: 'userId', + type: 'string', + }, + ], +}) + +const blockType = defineField({ + type: 'block', + name: 'block', + of: [mentionObject], + styles: [{title: 'Normal', value: 'normal'}], + lists: [], +}) + +const portableTextType = defineArrayMember({ + type: 'array', + name: 'body', + of: [blockType], +}) + +const schema = Schema.compile({ + name: 'comments', + types: [portableTextType], +}) + +export const editorSchemaType = schema.get('body') diff --git a/packages/sanity/src/desk/comments/src/components/pte/index.ts b/packages/sanity/src/desk/comments/src/components/pte/index.ts new file mode 100644 index 00000000000..73baef26055 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/index.ts @@ -0,0 +1,2 @@ +export * from './CommentMessageSerializer' +export * from './comment-input' diff --git a/packages/sanity/src/desk/comments/src/components/pte/render/index.ts b/packages/sanity/src/desk/comments/src/components/pte/render/index.ts new file mode 100644 index 00000000000..09eea7c6975 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/render/index.ts @@ -0,0 +1,2 @@ +export * from './renderBlock' +export * from './renderChild' diff --git a/packages/sanity/src/desk/comments/src/components/pte/render/renderBlock.tsx b/packages/sanity/src/desk/comments/src/components/pte/render/renderBlock.tsx new file mode 100644 index 00000000000..a1757aec5f2 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/render/renderBlock.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import {RenderBlockFunction} from '@sanity/portable-text-editor' +import {NormalBlock} from '../blocks' + +export const renderBlock: RenderBlockFunction = (blockProps) => { + const {children} = blockProps + + return {children} +} diff --git a/packages/sanity/src/desk/comments/src/components/pte/render/renderChild.tsx b/packages/sanity/src/desk/comments/src/components/pte/render/renderChild.tsx new file mode 100644 index 00000000000..7698b33ef51 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/components/pte/render/renderChild.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import {RenderChildFunction, BlockChildRenderProps} from '@sanity/portable-text-editor' +import {MentionInlineBlock} from '../blocks' + +export const renderChild: RenderChildFunction = (childProps: BlockChildRenderProps) => { + const {children, value, selected} = childProps + + const isMention = value._type === 'mention' && value.userId + + if (isMention) { + return + } + + return children +} diff --git a/packages/sanity/src/desk/comments/src/context/comments/CommentsContext.ts b/packages/sanity/src/desk/comments/src/context/comments/CommentsContext.ts new file mode 100644 index 00000000000..39c8995c1fb --- /dev/null +++ b/packages/sanity/src/desk/comments/src/context/comments/CommentsContext.ts @@ -0,0 +1,4 @@ +import {createContext} from 'react' +import {CommentsContextValue} from './types' + +export const CommentsContext = createContext(null) diff --git a/packages/sanity/src/desk/comments/src/context/comments/CommentsProvider.tsx b/packages/sanity/src/desk/comments/src/context/comments/CommentsProvider.tsx new file mode 100644 index 00000000000..17a6f3c5710 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/context/comments/CommentsProvider.tsx @@ -0,0 +1,356 @@ +import React, {memo, useCallback, useMemo, useState} from 'react' +import {orderBy} from 'lodash' +import { + CommentCreatePayload, + CommentEditPayload, + CommentPostPayload, + CommentStatus, + CommentThreadItem, +} from '../../types' +import { + CommentOperationsHookOptions, + MentionHookOptions, + useCommentOperations, + useCommentsEnabled, + useCommentsSetup, + useMentionOptions, +} from '../../hooks' +import {useCommentsStore} from '../../store' +import {buildCommentThreadItems} from '../../utils/buildCommentThreadItems' +import {CommentsContext} from './CommentsContext' +import {CommentsContextValue, SelectedPath} from './types' +import {getPublishedId, useEditState, useSchema, useCurrentUser, useWorkspace} from 'sanity' + +const EMPTY_ARRAY: [] = [] + +const EMPTY_COMMENTS_DATA = { + open: EMPTY_ARRAY, + resolved: EMPTY_ARRAY, +} + +interface ThreadItemsByStatus { + open: CommentThreadItem[] + resolved: CommentThreadItem[] +} + +/** + * @beta + * @hidden + */ +export interface CommentsProviderProps { + children: React.ReactNode + documentId: string + documentType: string +} + +const EMPTY_COMMENTS = { + data: EMPTY_COMMENTS_DATA, + error: null, + loading: false, +} + +const EMPTY_MENTION_OPTIONS = { + data: [], + error: null, + loading: false, +} + +const noop = async () => { + await Promise.resolve() +} + +const noopOperation = { + execute: noop, +} + +const COMMENTS_DISABLED_CONTEXT: CommentsContextValue = { + comments: EMPTY_COMMENTS, + create: noopOperation, + edit: noopOperation, + getComment: () => undefined, + getCommentPath: () => null, + isRunningSetup: false, + mentionOptions: EMPTY_MENTION_OPTIONS, + remove: noopOperation, + selectedPath: null, + setSelectedPath: noop, + setStatus: noop, + status: 'open', + update: noopOperation, +} + +/** + * @beta + * @hidden + */ +export const CommentsProvider = memo(function CommentsProvider(props: CommentsProviderProps) { + const {children, documentId, documentType} = props + + const {isEnabled} = useCommentsEnabled({ + documentId, + documentType, + }) + + if (!isEnabled) { + return ( + + {children} + + ) + } + + return +}) + +const CommentsProviderInner = memo(function CommentsProviderInner( + props: Omit, +) { + const {children, documentId, documentType} = props + const [selectedPath, setSelectedPath] = useState(null) + const [status, setStatus] = useState('open') + + const {client, runSetup, isRunningSetup} = useCommentsSetup() + const publishedId = getPublishedId(documentId) + const editState = useEditState(publishedId, documentType, 'low') + + const documentValue = useMemo(() => { + return editState.draft || editState.published + }, [editState.draft, editState.published]) + + const { + dispatch, + data = EMPTY_ARRAY, + error, + loading, + } = useCommentsStore({ + documentId: publishedId, + client, + }) + + const mentionOptions = useMentionOptions( + useMemo((): MentionHookOptions => ({documentValue}), [documentValue]), + ) + + const schemaType = useSchema().get(documentType) + const currentUser = useCurrentUser() + const {name: workspaceName, dataset, projectId} = useWorkspace() + + const threadItemsByStatus: ThreadItemsByStatus = useMemo(() => { + if (!schemaType || !currentUser) return EMPTY_COMMENTS_DATA + // Since we only make one query to get all comments using the order `_createdAt desc` – we + // can't know for sure that the comments added through the real time listener will be in the + // correct order. In order to avoid that comments are out of order, we make an additional + // sort here. The comments can be out of order if e.g a comment creation fails and is retried + // later. + const sorted = orderBy(data, ['_createdAt'], ['desc']) + + const items = buildCommentThreadItems({ + comments: sorted, + schemaType, + currentUser, + documentValue, + }) + + return { + open: items.filter((item) => item.parentComment.status === 'open'), + resolved: items.filter((item) => item.parentComment.status === 'resolved'), + } + }, [currentUser, data, documentValue, schemaType]) + + const getThreadLength = useCallback( + (threadId: string) => { + return threadItemsByStatus.open.filter((item) => item.threadId === threadId).length + }, + [threadItemsByStatus.open], + ) + + const getComment = useCallback((id: string) => data?.find((c) => c._id === id), [data]) + + const getCommentPath = useCallback( + (id: string) => { + const comment = getComment(id) + if (!comment) return null + + return comment.target.path.field + }, + [getComment], + ) + + const handleSetSelectedPath = useCallback((nextPath: SelectedPath) => { + // If the path is being set to null, immediately set the selected path to null. + if (nextPath === null) { + setSelectedPath(null) + return + } + + // Sometimes, a path is cleared (set to null) but at the same time a new path is set. + // In this case, we want to make sure that the selected path is set to the new path + // and not the cleared path. Therefore, we set the selected path in a timeout to make + // sure that the selected path is set after the cleared path. + // todo: can this be done in a better way? + setTimeout(() => { + setSelectedPath(nextPath) + }) + }, []) + + const handleOnCreate = useCallback( + async (payload: CommentPostPayload) => { + // If the comment we try to create already exists in the local state and has + // the 'createError' state, we know that we are retrying a comment creation. + // In that case, we want to change the state to 'createRetrying'. + const hasError = data?.find((c) => c._id === payload._id)?._state?.type === 'createError' + + dispatch({ + type: 'COMMENT_ADDED', + payload: { + ...payload, + _state: hasError ? {type: 'createRetrying'} : undefined, + }, + }) + }, + [data, dispatch], + ) + + const handleOnUpdate = useCallback( + (id: string, payload: Partial) => { + dispatch({ + type: 'COMMENT_UPDATED', + payload: { + _id: id, + ...payload, + }, + }) + }, + [dispatch], + ) + + const handleOnEdit = useCallback( + (id: string, payload: CommentEditPayload) => { + dispatch({ + type: 'COMMENT_UPDATED', + payload: { + _id: id, + ...payload, + }, + }) + }, + [dispatch], + ) + + const handleOnCreateError = useCallback( + (id: string, err: Error) => { + // When an error occurs during comment creation, we update the comment state + // to `createError`. This will make the comment appear in the UI as a comment + // that failed to be created. The user can then retry the comment creation. + dispatch({ + type: 'COMMENT_UPDATED', + payload: { + _id: id, + _state: { + error: err, + type: 'createError', + }, + }, + }) + }, + [dispatch], + ) + + const {operation} = useCommentOperations( + useMemo( + (): CommentOperationsHookOptions => ({ + client, + currentUser, + dataset, + documentId: publishedId, + documentType, + projectId, + schemaType, + workspace: workspaceName, + getThreadLength, + // This function runs when the first comment creation is executed. + // It is used to create the addon dataset and configure a client for + // the addon dataset. + runSetup, + // The following callbacks runs when the comment operations are executed. + // They are used to update the local state of the comments immediately after + // a comment operation has been executed. This is done to avoid waiting for + // the real time listener to update the comments and make the UI feel more + // responsive. The comment will be updated again when we receive an mutation + // event from the real time listener. + onCreate: handleOnCreate, + onCreateError: handleOnCreateError, + onEdit: handleOnEdit, + onUpdate: handleOnUpdate, + }), + [ + client, + currentUser, + dataset, + publishedId, + documentType, + projectId, + schemaType, + workspaceName, + getThreadLength, + runSetup, + handleOnCreate, + handleOnCreateError, + handleOnEdit, + handleOnUpdate, + ], + ), + ) + + const ctxValue = useMemo( + (): CommentsContextValue => ({ + setSelectedPath: handleSetSelectedPath, + selectedPath, + + isRunningSetup, + + status, + setStatus, + + getComment, + getCommentPath, + + comments: { + data: threadItemsByStatus, + error, + loading: loading || isRunningSetup, + }, + create: { + execute: operation.create, + }, + remove: { + execute: operation.remove, + }, + edit: { + execute: operation.edit, + }, + update: { + execute: operation.update, + }, + mentionOptions, + }), + [ + handleSetSelectedPath, + selectedPath, + isRunningSetup, + status, + getComment, + getCommentPath, + threadItemsByStatus, + error, + loading, + operation.create, + operation.remove, + operation.edit, + operation.update, + mentionOptions, + ], + ) + + return {children} +}) diff --git a/packages/sanity/src/desk/comments/src/context/comments/index.ts b/packages/sanity/src/desk/comments/src/context/comments/index.ts new file mode 100644 index 00000000000..73880cff6c7 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/context/comments/index.ts @@ -0,0 +1,2 @@ +export * from './CommentsContext' +export * from './CommentsProvider' diff --git a/packages/sanity/src/desk/comments/src/context/comments/types.ts b/packages/sanity/src/desk/comments/src/context/comments/types.ts new file mode 100644 index 00000000000..2948230aa2c --- /dev/null +++ b/packages/sanity/src/desk/comments/src/context/comments/types.ts @@ -0,0 +1,69 @@ +import { + CommentDocument, + CommentOperations, + CommentStatus, + CommentThreadItem, + MentionOptionsHookValue, +} from '../../types' + +interface SelectedPathValue { + /** + * The path to the field that is selected + */ + fieldPath: string + /** + * The origin of where the path was selected from + */ + origin: 'inspector' | 'field' + + /** + * The id of the thread that is selected. If null, there is no specific thread selected. + */ + threadId: string | null +} + +export type SelectedPath = SelectedPathValue | null + +/** + * @beta + * @hidden + */ +export interface CommentsContextValue { + getComment: (id: string) => CommentDocument | undefined + getCommentPath: (id: string) => string | null + + isRunningSetup: boolean + + comments: { + data: { + open: CommentThreadItem[] + resolved: CommentThreadItem[] + } + error: Error | null + loading: boolean + } + + remove: { + execute: CommentOperations['remove'] + } + + create: { + execute: CommentOperations['create'] + } + + edit: { + execute: CommentOperations['edit'] + } + + update: { + execute: CommentOperations['update'] + } + + mentionOptions: MentionOptionsHookValue + + status: CommentStatus + setStatus: (status: CommentStatus) => void + + setSelectedPath: (payload: SelectedPath) => void + selectedPath: SelectedPath +} diff --git a/packages/sanity/src/desk/comments/src/context/index.ts b/packages/sanity/src/desk/comments/src/context/index.ts new file mode 100644 index 00000000000..102f3163849 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/context/index.ts @@ -0,0 +1,3 @@ +export * from './comments' +export * from './setup' +export * from './onboarding' diff --git a/packages/sanity/src/desk/comments/src/context/onboarding/CommentsOnboardingContext.ts b/packages/sanity/src/desk/comments/src/context/onboarding/CommentsOnboardingContext.ts new file mode 100644 index 00000000000..7241c876ee3 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/context/onboarding/CommentsOnboardingContext.ts @@ -0,0 +1,4 @@ +import {createContext} from 'react' +import {CommentsOnboardingContextValue} from './types' + +export const CommentsOnboardingContext = createContext(null) diff --git a/packages/sanity/src/desk/comments/src/context/onboarding/CommentsOnboardingProvider.tsx b/packages/sanity/src/desk/comments/src/context/onboarding/CommentsOnboardingProvider.tsx new file mode 100644 index 00000000000..8891616adf7 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/context/onboarding/CommentsOnboardingProvider.tsx @@ -0,0 +1,51 @@ +import React, {useCallback, useMemo, useState} from 'react' +import {CommentsOnboardingContext} from './CommentsOnboardingContext' +import {CommentsOnboardingContextValue} from './types' + +const VERSION = 1 +const LOCAL_STORAGE_KEY = `sanityStudio:comments:inspector:onboarding:dismissed:v${VERSION}` + +const setLocalStorage = (value: boolean) => { + try { + window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(value)) + } catch (_) { + // Fail silently + } +} + +const getLocalStorage = (): boolean => { + try { + const value = window.localStorage.getItem(LOCAL_STORAGE_KEY) + return value ? JSON.parse(value) : false + } catch (_) { + return false + } +} + +interface CommentsOnboardingProviderProps { + children: React.ReactNode +} + +export function CommentsOnboardingProvider(props: CommentsOnboardingProviderProps) { + const {children} = props + const [dismissed, setDismissed] = useState(getLocalStorage()) + + const handleDismiss = useCallback(() => { + setDismissed(true) + setLocalStorage(true) + }, [setDismissed]) + + const ctxValue = useMemo( + (): CommentsOnboardingContextValue => ({ + setDismissed: handleDismiss, + isDismissed: dismissed, + }), + [handleDismiss, dismissed], + ) + + return ( + + {children} + + ) +} diff --git a/packages/sanity/src/desk/comments/src/context/onboarding/index.ts b/packages/sanity/src/desk/comments/src/context/onboarding/index.ts new file mode 100644 index 00000000000..b45b682c413 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/context/onboarding/index.ts @@ -0,0 +1,2 @@ +export * from './CommentsOnboardingContext' +export * from './CommentsOnboardingProvider' diff --git a/packages/sanity/src/desk/comments/src/context/onboarding/types.ts b/packages/sanity/src/desk/comments/src/context/onboarding/types.ts new file mode 100644 index 00000000000..697db515ff2 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/context/onboarding/types.ts @@ -0,0 +1,4 @@ +export interface CommentsOnboardingContextValue { + isDismissed: boolean + setDismissed: () => void +} diff --git a/packages/sanity/src/desk/comments/src/context/setup/CommentsSetupContext.ts b/packages/sanity/src/desk/comments/src/context/setup/CommentsSetupContext.ts new file mode 100644 index 00000000000..af5d73025b4 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/context/setup/CommentsSetupContext.ts @@ -0,0 +1,8 @@ +import {createContext} from 'react' +import {CommentsSetupContextValue} from './types' + +/** + * @beta + * @hidden + */ +export const CommentsSetupContext = createContext(null) diff --git a/packages/sanity/src/desk/comments/src/context/setup/CommentsSetupProvider.tsx b/packages/sanity/src/desk/comments/src/context/setup/CommentsSetupProvider.tsx new file mode 100644 index 00000000000..76cc96f8856 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/context/setup/CommentsSetupProvider.tsx @@ -0,0 +1,128 @@ +import React, {useCallback, useEffect, useMemo, useState} from 'react' +import {SanityClient} from '@sanity/client' +import {CommentPostPayload} from '../../types' +import {CommentsSetupContext} from './CommentsSetupContext' +import {CommentsSetupContextValue} from './types' +import {useWorkspace, useClient, DEFAULT_STUDIO_CLIENT_OPTIONS, useFeatureEnabled} from 'sanity' + +interface CommentsSetupProviderProps { + children: React.ReactNode +} + +/** + * @beta + * @hidden + */ +export function CommentsSetupProvider(props: CommentsSetupProviderProps) { + const {children} = props + const {dataset, projectId} = useWorkspace() + const originalClient = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + const [addonDatasetClient, setAddonDatasetClient] = useState(null) + const [isRunningSetup, setIsRunningSetup] = useState(false) + + const getAddonDatasetName = useCallback(async (): Promise => { + const res = await originalClient.withConfig({apiVersion: 'vX'}).request({ + uri: `/projects/${projectId}/datasets?datasetProfile=comments&addonFor=${dataset}`, + tag: 'sanity.studio', + }) + + // The response is an array containing the addon dataset. We only expect + // one addon dataset to be returned, so we return the name of the first + // addon dataset in the array. + return res?.[0]?.name + }, [dataset, originalClient, projectId]) + + const handleCreateClient = useCallback( + (addonDatasetName: string) => { + const client = originalClient.withConfig({ + apiVersion: 'v2022-05-09', + dataset: addonDatasetName, + projectId, + requestTagPrefix: 'sanity.studio', + useCdn: false, + withCredentials: true, + }) + + return client + }, + [originalClient, projectId], + ) + + const handleRunSetup = useCallback( + async (comment: CommentPostPayload) => { + setIsRunningSetup(true) + + // Before running the setup, we check if the addon dataset already exists. + // The addon dataset might already exist if another user has already run + // the setup, but the current user has not refreshed the page yet and + // therefore don't have a client for the addon dataset yet. + try { + const addonDatasetName = await getAddonDatasetName() + + if (addonDatasetName) { + const client = handleCreateClient(addonDatasetName) + setAddonDatasetClient(client) + await client.create(comment) + setIsRunningSetup(false) + return + } + } catch (_) { + // If the dataset does not exist we will get an error, but we can ignore + // it since we will create the dataset in the next step. + } + + try { + // 1. Create the addon dataset + const res = await originalClient.withConfig({apiVersion: 'vX'}).request({ + uri: `/comments/${dataset}/setup`, + method: 'POST', + }) + + const datasetName = res?.datasetName + + // 2. We can't continue if the addon dataset name is not returned + if (!datasetName) { + setIsRunningSetup(false) + return + } + + // 3. Create a client for the addon dataset and set it in the context value + // so that the consumers can use it to execute comment operations and set up + // the real time listener for the addon dataset. + const client = handleCreateClient(datasetName) + setAddonDatasetClient(client) + + // 4. Create the comment + await client.create(comment) + } catch (err) { + throw err + } finally { + setIsRunningSetup(false) + } + }, + [dataset, getAddonDatasetName, handleCreateClient, originalClient], + ) + + useEffect(() => { + // On mount, we check if the addon dataset already exists. If it does, we create + // a client for it and set it in the context value so that the consumers can use + // it to execute comment operations and set up the real time listener for the addon + // dataset. + getAddonDatasetName().then((addonDatasetName) => { + if (!addonDatasetName) return + const client = handleCreateClient(addonDatasetName) + setAddonDatasetClient(client) + }) + }, [getAddonDatasetName, handleCreateClient]) + + const ctxValue = useMemo( + (): CommentsSetupContextValue => ({ + client: addonDatasetClient, + runSetup: handleRunSetup, + isRunningSetup, + }), + [addonDatasetClient, handleRunSetup, isRunningSetup], + ) + + return {children} +} diff --git a/packages/sanity/src/desk/comments/src/context/setup/index.ts b/packages/sanity/src/desk/comments/src/context/setup/index.ts new file mode 100644 index 00000000000..31dc8c78b07 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/context/setup/index.ts @@ -0,0 +1,3 @@ +export * from './CommentsSetupContext' +export * from './CommentsSetupProvider' +export * from './types' diff --git a/packages/sanity/src/desk/comments/src/context/setup/types.ts b/packages/sanity/src/desk/comments/src/context/setup/types.ts new file mode 100644 index 00000000000..08d8b148f4b --- /dev/null +++ b/packages/sanity/src/desk/comments/src/context/setup/types.ts @@ -0,0 +1,12 @@ +import {SanityClient} from '@sanity/client' +import {CommentPostPayload} from '../../types' + +/** + * @beta + * @hidden + */ +export interface CommentsSetupContextValue { + client: SanityClient | null + isRunningSetup: boolean + runSetup: (comment: CommentPostPayload) => Promise +} diff --git a/packages/sanity/src/desk/comments/src/helpers.ts b/packages/sanity/src/desk/comments/src/helpers.ts new file mode 100644 index 00000000000..045aab03a51 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/helpers.ts @@ -0,0 +1,20 @@ +import {useMemo, useRef} from 'react' +import {isEqual} from 'lodash' +import {CommentMessage} from './types' +import {isPortableTextSpan, isPortableTextTextBlock} from 'sanity' + +export function useCommentHasChanged(message: CommentMessage): boolean { + const prevMessage = useRef(message) + + return useMemo(() => !isEqual(prevMessage.current, message), [message]) +} + +export function hasCommentMessageValue(value: CommentMessage): boolean { + if (!value) return false + + return value?.some( + (block) => + isPortableTextTextBlock(block) && + (block?.children || [])?.some((c) => (isPortableTextSpan(c) ? c.text : c.userId)), + ) +} diff --git a/packages/sanity/src/desk/comments/src/hooks/index.ts b/packages/sanity/src/desk/comments/src/hooks/index.ts new file mode 100644 index 00000000000..c3228793308 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/hooks/index.ts @@ -0,0 +1,6 @@ +export * from './use-mention-options' +export * from './useCommentOperations' +export * from './useComments' +export * from './useCommentsSetup' +export * from './useCommentsEnabled' +export * from './useCommentsOnboarding' diff --git a/packages/sanity/src/desk/comments/src/hooks/use-mention-options/helpers.ts b/packages/sanity/src/desk/comments/src/hooks/use-mention-options/helpers.ts new file mode 100644 index 00000000000..b9f3a46011d --- /dev/null +++ b/packages/sanity/src/desk/comments/src/hooks/use-mention-options/helpers.ts @@ -0,0 +1,72 @@ +import {SanityDocument} from '@sanity/client' +import {evaluate, parse} from 'groq-js' +import {MentionOptionUser} from '../../types' +import {EvaluationParams, Grant, DocumentValuePermission, PermissionCheckResult} from 'sanity' + +// TODO: +// This code is copied from the `grantsStore.ts` and slightly modified to work with the `MentionOptionUser` type. +// TODO: Refactor this to be a shared function between the `grantsStore.ts` and this file. + +function getParams(user: MentionOptionUser | null): EvaluationParams { + const params: EvaluationParams = {} + + if (user !== null) { + params.identity = user.id + } + + return params +} + +const PARSED_FILTERS_MEMO = new Map() +async function matchesFilter( + user: MentionOptionUser | null, + filter: string, + document: SanityDocument, +) { + if (!PARSED_FILTERS_MEMO.has(filter)) { + // note: it might be tempting to also memoize the result of the evaluation here, + // Currently these filters are typically evaluated whenever a document change, which means they will be evaluated + // quite frequently with different versions of the document. There might be some gains in finding out which subset of document + // properties to use as key (e.g. by looking at the parsed filter and see what properties the filter cares about) + // But as always, it's worth considering if the complexity/memory usage is worth the potential perf gain… + PARSED_FILTERS_MEMO.set(filter, parse(`*[${filter}]`)) + } + const parsed = PARSED_FILTERS_MEMO.get(filter) + + const evalParams = getParams(user) + const {identity} = evalParams + const params: Record = {...evalParams} + const data = await (await evaluate(parsed, {dataset: [document], identity, params})).get() + return data?.length === 1 +} + +export async function grantsPermissionOn( + user: MentionOptionUser | null, + grants: Grant[], + permission: DocumentValuePermission, + document: SanityDocument | null, +): Promise { + if (!document) { + // we say it's granted if null due to initial states + return {granted: true, reason: 'Null document, nothing to check'} + } + + if (!grants.length) { + return {granted: false, reason: 'No document grants'} + } + + const matchingGrants: Grant[] = [] + + for (const grant of grants) { + if (await matchesFilter(user, grant.filter, document)) { + matchingGrants.push(grant) + } + } + + const foundMatch = matchingGrants.some((grant) => grant.permissions.some((p) => p === permission)) + + return { + granted: foundMatch, + reason: foundMatch ? `Matching grant` : `No matching grants found`, + } +} diff --git a/packages/sanity/src/desk/comments/src/hooks/use-mention-options/index.ts b/packages/sanity/src/desk/comments/src/hooks/use-mention-options/index.ts new file mode 100644 index 00000000000..d5c6692938d --- /dev/null +++ b/packages/sanity/src/desk/comments/src/hooks/use-mention-options/index.ts @@ -0,0 +1 @@ +export * from './useMentionOptions' 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 new file mode 100644 index 00000000000..b01f7ec785c --- /dev/null +++ b/packages/sanity/src/desk/comments/src/hooks/use-mention-options/useMentionOptions.ts @@ -0,0 +1,118 @@ +/* eslint-disable max-nested-callbacks */ +import {useState, useEffect, useMemo} from 'react' +import {Observable, concat, forkJoin, map, mergeMap, of, switchMap} from 'rxjs' +import {SanityDocument} from '@sanity/client' +import {sortBy} from 'lodash' +import {Loadable, MentionOptionUser, MentionOptionsHookValue} from '../../types' +import {grantsPermissionOn} from './helpers' +import { + useProjectStore, + useUserStore, + useClient, + DEFAULT_STUDIO_CLIENT_OPTIONS, + ProjectData, +} from 'sanity' + +const INITIAL_STATE: MentionOptionsHookValue = { + data: [], + error: null, + loading: true, +} + +export interface MentionHookOptions { + documentValue: SanityDocument | null +} + +let cachedSystemGroups: [] | null = null + +export function useMentionOptions(opts: MentionHookOptions): MentionOptionsHookValue { + const {documentValue} = opts + + const projectStore = useProjectStore() + const userStore = useUserStore() + const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) + + const [state, setState] = useState(INITIAL_STATE) + + const list$ = useMemo(() => { + // 1. Get the project members and filter out the robot users + const members$: Observable = projectStore + .get() + .pipe(map((res: ProjectData) => res.members?.filter((m) => !m.isRobot))) + + // 2. Map the members to users to get more data of the users such as displayName (used for filtering) + const users$: Observable = members$.pipe( + switchMap(async (members) => { + const ids = members.map(({id}) => id) + const users = await userStore.getUsers(ids) + return users + }), + map((res) => + res.map((user) => ({ + displayName: user.displayName, + id: user.id, + canBeMentioned: false, + })), + ), + ) + + // 3. Get all the system groups. Use the cached response if it exists to avoid unnecessary requests. + const cached = cachedSystemGroups + const systemGroup$ = cached ? of(cached) : client.observable.fetch('*[_type == "system.group"]') + + // 4. Check if the user has read permission on the document and set the `canBeMentioned` property + const grants$: Observable = forkJoin([users$, systemGroup$]).pipe( + mergeMap(async ([users, groups]) => { + if (!cached) { + cachedSystemGroups = groups + } + + const grantPromises = users?.map(async (user) => { + const grants = groups.map((group: any) => { + if (group.members.includes(user.id)) { + return group.grants + } + + return [] + }) + + const flattenedGrants = [...grants].flat() + const {granted} = await grantsPermissionOn(user, flattenedGrants, 'read', documentValue) + + return { + ...user, + canBeMentioned: granted, + } + }) + + const usersWithPermission = await Promise.all(grantPromises || []) + + return usersWithPermission + }), + ) + + // 5. Sort the users alphabetically + const $alphabetical: Observable> = grants$.pipe( + map((res) => ({ + error: null, + loading: false, + data: sortBy(res, 'displayName'), + })), + ) + + return $alphabetical + }, [client.observable, documentValue, projectStore, userStore]) + + useEffect(() => { + const initial$ = of(INITIAL_STATE) + const state$ = concat(initial$, list$) + + const sub = state$.subscribe(setState) + + return () => { + sub.unsubscribe() + } + }, [list$]) + + return state +} diff --git a/packages/sanity/src/desk/comments/src/hooks/useCommentOperations.ts b/packages/sanity/src/desk/comments/src/hooks/useCommentOperations.ts new file mode 100644 index 00000000000..47a62f7f5dc --- /dev/null +++ b/packages/sanity/src/desk/comments/src/hooks/useCommentOperations.ts @@ -0,0 +1,242 @@ +import {useCallback, useMemo} from 'react' +import {uuid} from '@sanity/uuid' +import {CurrentUser, SchemaType} from '@sanity/types' +import {SanityClient} from '@sanity/client' +import { + CommentContext, + CommentCreatePayload, + CommentEditPayload, + CommentOperations, + CommentPostPayload, +} from '../types' +import {useNotificationTarget} from './useNotificationTarget' +import {useWorkspace} from 'sanity' +import {useRouterState} from 'sanity/router' + +export interface CommentOperationsHookValue { + operation: CommentOperations +} + +export interface CommentOperationsHookOptions { + client: SanityClient | null + + currentUser: CurrentUser | null + dataset: string + documentId: string + documentType: string + projectId: string + schemaType: SchemaType | undefined + workspace: string + + getThreadLength?: (threadId: string) => number + + onCreate?: (comment: CommentPostPayload) => void + onCreateError: (id: string, error: Error) => void + onEdit?: (id: string, comment: CommentEditPayload) => void + onRemove?: (id: string) => void + onUpdate?: (id: string, comment: Partial) => void + + runSetup: (comment: CommentPostPayload) => Promise +} + +export function useCommentOperations( + opts: CommentOperationsHookOptions, +): CommentOperationsHookValue { + const { + client, + currentUser, + dataset, + documentId, + documentType, + getThreadLength, + onCreate, + onCreateError, + onEdit, + onRemove, + onUpdate, + projectId, + runSetup, + workspace, + } = opts + + const authorId = currentUser?.id + + const activeToolName = useRouterState( + useCallback( + (routerState) => (typeof routerState.tool === 'string' ? routerState.tool : undefined), + [], + ), + ) + const {tools} = useWorkspace() + const activeTool = useMemo( + () => tools.find((tool) => tool.name === activeToolName), + [activeToolName, tools], + ) + const {getNotificationValue} = useNotificationTarget({documentId, documentType}) + + const handleCreate = useCallback( + async (comment: CommentCreatePayload) => { + // The comment payload might already have an id if, for example, the comment was created + // but the request failed. In that case, we'll reuse the id when retrying to + // create the comment. + const commentId = comment?.id || uuid() + + // Get the current thread length of the thread the comment is being added to. + // We add 1 to the length to account for the comment being added. + const currentThreadLength = (getThreadLength?.(comment.threadId) || 0) + 1 + + const { + documentTitle = '', + url = '', + workspaceTitle = '', + } = getNotificationValue({commentId}) || {} + + const notification: CommentContext['notification'] = { + currentThreadLength, + documentTitle, + url, + workspaceTitle, + } + + const nextComment: CommentPostPayload = { + _id: commentId, + _type: 'comment', + authorId: authorId || '', // improve + lastEditedAt: undefined, + message: comment.message, + parentCommentId: comment.parentCommentId, + status: comment.status, + threadId: comment.threadId, + + context: { + payload: { + workspace, + }, + notification, + tool: activeTool?.name || '', + }, + target: { + path: { + field: comment.fieldPath, + }, + document: { + _dataset: dataset, + _projectId: projectId, + _ref: documentId, + _type: 'crossDatasetReference', + _weak: true, + }, + documentType, + }, + } + + onCreate?.(nextComment) + + // If we don't have a client, that means that the dataset doesn't have an addon dataset. + // Therefore, when the first comment is created, we need to create the addon dataset and create + // a client for it and then post the comment. We do this here, since we know that we have a + // comment to create. + if (!client) { + try { + await runSetup(nextComment) + } catch (err) { + onCreateError?.(nextComment._id, err) + throw err + } + return + } + + try { + await client.create(nextComment) + } catch (err) { + onCreateError?.(nextComment._id, err) + throw err + } + }, + [ + activeTool?.name, + authorId, + client, + dataset, + documentId, + documentType, + getNotificationValue, + getThreadLength, + onCreate, + onCreateError, + projectId, + runSetup, + workspace, + ], + ) + + const handleRemove = useCallback( + async (id: string) => { + if (!client) return + + onRemove?.(id) + + await Promise.all([ + client.delete({query: `*[_type == "comment" && parentCommentId == "${id}"]`}), + client.delete(id), + ]) + }, + [client, onRemove], + ) + + const handleEdit = useCallback( + async (id: string, comment: CommentEditPayload) => { + if (!client) return + + const editedComment = { + message: comment.message, + lastEditedAt: new Date().toISOString(), + } satisfies CommentEditPayload + + onEdit?.(id, editedComment) + + await client.patch(id).set(editedComment).commit() + }, + [client, onEdit], + ) + + const handleUpdate = useCallback( + async (id: string, comment: Partial) => { + if (!client) return + + onUpdate?.(id, comment) + + // If the update contains a status, we'll update the status of all replies + // to the comment as well. + if (comment.status) { + await Promise.all([ + client + .patch({query: `*[_type == "comment" && parentCommentId == "${id}"]`}) + .set({ + status: comment.status, + }) + .commit(), + client.patch(id).set(comment).commit(), + ]) + + return + } + + // Else we'll just update the comment itself + await client?.patch(id).set(comment).commit() + }, + [client, onUpdate], + ) + + const operation = useMemo( + () => ({ + create: handleCreate, + edit: handleEdit, + remove: handleRemove, + update: handleUpdate, + }), + [handleCreate, handleRemove, handleEdit, handleUpdate], + ) + + return {operation} +} diff --git a/packages/sanity/src/desk/comments/src/hooks/useComments.ts b/packages/sanity/src/desk/comments/src/hooks/useComments.ts new file mode 100644 index 00000000000..b640586d505 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/hooks/useComments.ts @@ -0,0 +1,17 @@ +import {useContext} from 'react' +import {CommentsContextValue} from '../context/comments/types' +import {CommentsContext} from '../context' + +/** + * @beta + * @hidden + */ +export function useComments(): CommentsContextValue { + const value = useContext(CommentsContext) + + if (!value) { + throw new Error('useComments must be used within a CommentsProvider') + } + + return value +} diff --git a/packages/sanity/src/desk/comments/src/hooks/useCommentsEnabled.ts b/packages/sanity/src/desk/comments/src/hooks/useCommentsEnabled.ts new file mode 100644 index 00000000000..d5296260f9d --- /dev/null +++ b/packages/sanity/src/desk/comments/src/hooks/useCommentsEnabled.ts @@ -0,0 +1,68 @@ +import {useMemo} from 'react' +import {getPublishedId, useFeatureEnabled, useSource} from 'sanity' + +interface Disabled { + isEnabled: false + reason: 'disabled-by-config' | 'plan-upgrade-required' | 'loading' +} + +interface Enabled { + isEnabled: true + reason: null +} + +interface Loading { + isEnabled: false + reason: 'loading' +} + +type CommentsEnabled = Enabled | Disabled | Loading + +interface CommentsEnabledHookOptions { + documentId: string + documentType: string +} + +/** + * @internal + */ +export function useCommentsEnabled(opts: CommentsEnabledHookOptions): CommentsEnabled { + const {documentId, documentType} = opts + + // 1. Plan check + const {enabled: featureEnabled, isLoading} = useFeatureEnabled('studioComments') + + // 2. Config check + const {enabled} = useSource().document.unstable_comments + const enabledFromConfig = enabled({documentType, documentId: getPublishedId(documentId)}) + + const commentsEnabled = useMemo((): CommentsEnabled => { + if (isLoading) { + return { + isEnabled: false, + reason: 'loading', + } + } + + if (!featureEnabled) { + return { + isEnabled: false, + reason: 'plan-upgrade-required', + } + } + + if (!enabledFromConfig) { + return { + isEnabled: false, + reason: 'disabled-by-config', + } + } + + return { + isEnabled: true, + reason: null, + } + }, [enabledFromConfig, featureEnabled, isLoading]) + + return commentsEnabled +} diff --git a/packages/sanity/src/desk/comments/src/hooks/useCommentsOnboarding.ts b/packages/sanity/src/desk/comments/src/hooks/useCommentsOnboarding.ts new file mode 100644 index 00000000000..2fac2dbe2a6 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/hooks/useCommentsOnboarding.ts @@ -0,0 +1,13 @@ +import {useContext} from 'react' +import {CommentsOnboardingContext} from '../context/onboarding' +import {CommentsOnboardingContextValue} from '../context/onboarding/types' + +export function useCommentsOnboarding(): CommentsOnboardingContextValue { + const ctx = useContext(CommentsOnboardingContext) + + if (!ctx) { + throw new Error('useCommentsOnboarding: missing context value') + } + + return ctx +} diff --git a/packages/sanity/src/desk/comments/src/hooks/useCommentsSetup.ts b/packages/sanity/src/desk/comments/src/hooks/useCommentsSetup.ts new file mode 100644 index 00000000000..c39696a06ca --- /dev/null +++ b/packages/sanity/src/desk/comments/src/hooks/useCommentsSetup.ts @@ -0,0 +1,12 @@ +import {useContext} from 'react' +import {CommentsSetupContext, CommentsSetupContextValue} from '../context' + +export function useCommentsSetup(): CommentsSetupContextValue { + const ctx = useContext(CommentsSetupContext) + + if (!ctx) { + throw new Error('useCommentsSetup: missing context value') + } + + return ctx +} diff --git a/packages/sanity/src/desk/comments/src/hooks/useNotificationTarget.ts b/packages/sanity/src/desk/comments/src/hooks/useNotificationTarget.ts new file mode 100644 index 00000000000..4666fb98bce --- /dev/null +++ b/packages/sanity/src/desk/comments/src/hooks/useNotificationTarget.ts @@ -0,0 +1,67 @@ +import {useCallback} from 'react' +import {useMemoObservable} from 'react-rx' +import {of} from 'rxjs' +import {usePaneRouter} from '../../../components' +import {COMMENTS_INSPECTOR_NAME} from '../../../panes/document/constants' +import {CommentContext} from '../types' +import {getPreviewStateObservable, useDocumentPreviewStore, useSchema, useWorkspace} from 'sanity' + +interface NotificationTargetHookOptions { + documentId: string + documentType: string +} + +interface NotificationTargetHookValue { + /** + * Returns an object with notification-specific values for the selected comment, such as + * the current workspace + document title and full URL to the comment. + * These values are currently used in notification emails. + * + * **Please note:** this will generate a URL for the comment based on the current _active_ pane. + * The current active pane may not necessarily be the right-most desk pane and in these cases, + * the selected comment may not be visible on initial load when visiting these URLs. + */ + getNotificationValue: ({commentId}: {commentId: string}) => CommentContext['notification'] +} + +/** @internal */ +export function useNotificationTarget( + opts: NotificationTargetHookOptions, +): NotificationTargetHookValue { + const {documentId, documentType} = opts || {} + const schemaType = useSchema().get(documentType) + const {title: workspaceTitle} = useWorkspace() + const {createPathWithParams, params} = usePaneRouter() + + const documentPreviewStore = useDocumentPreviewStore() + + const previewState = useMemoObservable(() => { + if (!documentId || !schemaType) return of(null) + return getPreviewStateObservable(documentPreviewStore, schemaType, documentId, '') + }, [documentId, documentPreviewStore, schemaType]) + + const {published, draft} = previewState || {} + const documentTitle = (draft?.title || published?.title || 'Sanity document') as string + + const handleGetNotificationValue = useCallback( + ({commentId}: {commentId: string}) => { + // Generate a path based on the current pane params. + // We force a value for `inspect` to ensure that this is included in URLs when comments + // are created outside of the inspector context (i.e. directly on the field) + // @todo: consider filtering pane router params and culling all non-active RHS panes prior to generating this link + const path = createPathWithParams({ + ...params, + comment: commentId, + inspect: COMMENTS_INSPECTOR_NAME, + }) + const url = `${window.location.origin}${path}` + + return {documentTitle, url, workspaceTitle} + }, + [createPathWithParams, documentTitle, params, workspaceTitle], + ) + + return { + getNotificationValue: handleGetNotificationValue, + } +} diff --git a/packages/sanity/src/desk/comments/src/index.ts b/packages/sanity/src/desk/comments/src/index.ts new file mode 100644 index 00000000000..2c71b97b550 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/index.ts @@ -0,0 +1,7 @@ +export * from './components' +export * from './context' +export * from './helpers' +export * from './hooks' +export * from './store' +export * from './types' +export * from './utils' diff --git a/packages/sanity/src/desk/comments/src/store/index.ts b/packages/sanity/src/desk/comments/src/store/index.ts new file mode 100644 index 00000000000..7b79c72b64a --- /dev/null +++ b/packages/sanity/src/desk/comments/src/store/index.ts @@ -0,0 +1 @@ +export * from './useCommentsStore' diff --git a/packages/sanity/src/desk/comments/src/store/reducer.ts b/packages/sanity/src/desk/comments/src/store/reducer.ts new file mode 100644 index 00000000000..95a77a8351e --- /dev/null +++ b/packages/sanity/src/desk/comments/src/store/reducer.ts @@ -0,0 +1,160 @@ +import {CommentDocument, CommentPostPayload} from '../types' + +interface CommentAddedAction { + payload: CommentDocument | CommentPostPayload + type: 'COMMENT_ADDED' +} + +interface CommentDeletedAction { + id: string + type: 'COMMENT_DELETED' +} + +interface CommentUpdatedAction { + payload: CommentDocument | Partial + type: 'COMMENT_UPDATED' +} + +interface CommentsSetAction { + comments: CommentDocument[] + type: 'COMMENTS_SET' +} + +interface CommentReceivedAction { + payload: CommentDocument + type: 'COMMENT_RECEIVED' +} + +export type CommentsReducerAction = + | CommentAddedAction + | CommentDeletedAction + | CommentUpdatedAction + | CommentsSetAction + | CommentReceivedAction + +export interface CommentsReducerState { + comments: Record +} + +/** + * Transform an array of comments into an object with the comment id as key: + * ``` + * { + * 'comment-1': { _id: 'comment-1', ... }, + * 'comment-2': { _id: 'comment-2', ... }, + * } + * ``` + */ +function createCommentsSet(comments: CommentDocument[]) { + const commentsById = comments.reduce((acc, comment) => ({...acc, [comment._id]: comment}), {}) + return commentsById +} + +export function commentsReducer( + state: CommentsReducerState, + action: CommentsReducerAction, +): CommentsReducerState { + switch (action.type) { + case 'COMMENTS_SET': { + // Create an object with the comment id as key + const commentsById = createCommentsSet(action.comments) + + return { + ...state, + comments: commentsById, + } + } + + case 'COMMENT_ADDED': { + const nextCommentResult = action.payload as CommentDocument + + const nextCommentValue = nextCommentResult satisfies CommentDocument + + const nextComment = { + [nextCommentResult._id]: { + ...state.comments[nextCommentResult._id], + ...nextCommentValue, + _state: nextCommentResult._state || undefined, + // If the comment is created optimistically, it won't have a createdAt date. + // In that case, we'll use the current date. + // The correct date will be set when the comment is created on the server + // and the comment is received in the realtime listener. + _createdAt: nextCommentResult._createdAt || new Date().toISOString(), + } satisfies CommentDocument, + } + + const commentExists = state.comments && state.comments[nextCommentResult._id] + + // The comment might already exist in the store if an optimistic update + // has been performed but the post request failed. In that case we want + // to merge the new comment with the existing one. + if (commentExists) { + return { + ...state, + comments: { + ...state.comments, + ...nextComment, + }, + } + } + + const nextComments = { + ...(state.comments || {}), + ...nextComment, + } + + return { + ...state, + comments: nextComments, + } + } + + case 'COMMENT_RECEIVED': { + const nextCommentResult = action.payload as CommentDocument + + return { + ...state, + comments: { + ...state.comments, + [nextCommentResult._id]: nextCommentResult, + }, + } + } + + case 'COMMENT_DELETED': { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {[action.id]: _, ...restComments} = state.comments + + // Delete all replies to the deleted comment + Object.keys(restComments).forEach((commentId) => { + if (restComments[commentId].parentCommentId === action.id) { + delete restComments[commentId] + } + }) + + return { + ...state, + comments: restComments, + } + } + + case 'COMMENT_UPDATED': { + const updatedComment = action.payload + const id = updatedComment._id as string + + return { + ...state, + comments: { + ...state.comments, + [id]: { + ...state.comments[id], + ...updatedComment, + }, + }, + } + } + + default: + return state + } +} diff --git a/packages/sanity/src/desk/comments/src/store/useCommentsStore.ts b/packages/sanity/src/desk/comments/src/store/useCommentsStore.ts new file mode 100644 index 00000000000..02b517bfdbc --- /dev/null +++ b/packages/sanity/src/desk/comments/src/store/useCommentsStore.ts @@ -0,0 +1,153 @@ +import {useMemo, useEffect, useCallback, useReducer, useState} from 'react' +import {ListenEvent, ListenOptions, SanityClient} from '@sanity/client' +import {catchError, of} from 'rxjs' +import {CommentDocument, Loadable} from '../types' +import {CommentsReducerAction, CommentsReducerState, commentsReducer} from './reducer' +import {getPublishedId} from 'sanity' + +export interface CommentsStoreOptions { + documentId: string + client: SanityClient | null +} + +interface CommentsStoreReturnType extends Loadable { + dispatch: React.Dispatch +} + +const INITIAL_STATE: CommentsReducerState = { + comments: {}, +} + +const LISTEN_OPTIONS: ListenOptions = { + events: ['welcome', 'mutation', 'reconnect'], + includeResult: true, + visibility: 'query', +} + +export const SORT_FIELD = '_createdAt' +export const SORT_ORDER = 'desc' + +const QUERY_FILTERS = [`_type == "comment"`, `target.document._ref == $documentId`] + +const QUERY_PROJECTION = `{ + _createdAt, + _id, + authorId, + lastEditedAt, + message, + parentCommentId, + status, + target, + threadId, +}` + +// Newest comments first +const QUERY_SORT_ORDER = `order(${SORT_FIELD} ${SORT_ORDER})` + +const QUERY = `*[${QUERY_FILTERS.join(' && ')}] ${QUERY_PROJECTION} | ${QUERY_SORT_ORDER}` + +export function useCommentsStore(opts: CommentsStoreOptions): CommentsStoreReturnType { + const {client, documentId} = opts + + const [state, dispatch] = useReducer(commentsReducer, INITIAL_STATE) + const [loading, setLoading] = useState(client !== null) + const [error, setError] = useState(null) + + const params = useMemo(() => ({documentId: getPublishedId(documentId)}), [documentId]) + + const initialFetch = useCallback(async () => { + if (!client) { + setLoading(false) + return + } + + try { + const res = await client.fetch(QUERY, params) + dispatch({type: 'COMMENTS_SET', comments: res}) + setLoading(false) + } catch (err) { + setError(err) + } + }, [client, params]) + + const handleListenerEvent = useCallback( + async (event: ListenEvent>) => { + // Fetch all comments on initial connection + if (event.type === 'welcome') { + setLoading(true) + await initialFetch() + setLoading(false) + } + + // The reconnect event means that we are trying to reconnect to the realtime listener. + // In this case we set loading to true to indicate that we're trying to + // reconnect. Once a connection has been established, the welcome event + // will be received and we'll fetch all comments again (above). + if (event.type === 'reconnect') { + setLoading(true) + } + + // Handle mutations (create, update, delete) from the realtime listener + // and update the comments store accordingly + if (event.type === 'mutation') { + if (event.transition === 'appear') { + const nextComment = event.result as CommentDocument | undefined + + if (nextComment) { + dispatch({ + type: 'COMMENT_RECEIVED', + payload: nextComment, + }) + } + } + + if (event.transition === 'disappear') { + dispatch({type: 'COMMENT_DELETED', id: event.documentId}) + } + + if (event.transition === 'update') { + const updatedComment = event.result as CommentDocument | undefined + + if (updatedComment) { + dispatch({ + type: 'COMMENT_UPDATED', + payload: updatedComment, + }) + } + } + } + }, + [initialFetch], + ) + + const listener$ = useMemo(() => { + if (!client) return of() + + const events$ = client.observable.listen(QUERY, params, LISTEN_OPTIONS).pipe( + catchError((err) => { + setError(err) + return of(err) + }), + ) + + return events$ + }, [client, params]) + + useEffect(() => { + const sub = listener$.subscribe(handleListenerEvent) + + return () => { + sub?.unsubscribe() + } + }, [handleListenerEvent, listener$]) + + // Transform comments object to array + const commentsAsArray = useMemo(() => Object.values(state.comments), [state.comments]) + + return { + data: commentsAsArray, + dispatch, + error, + loading, + } +} diff --git a/packages/sanity/src/desk/comments/src/types.ts b/packages/sanity/src/desk/comments/src/types.ts new file mode 100644 index 00000000000..921f339b579 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/types.ts @@ -0,0 +1,187 @@ +import {PortableTextBlock, User} from '@sanity/types' + +/** + * @beta + * @hidden + */ +export interface Loadable { + data: T | null + error: Error | null + loading: boolean +} + +/** + * @beta + * @hidden + */ +export interface CommentOperations { + create: (comment: CommentCreatePayload) => Promise + edit: (id: string, comment: CommentEditPayload) => Promise + remove: (id: string) => Promise + update: (id: string, comment: Partial) => Promise +} + +/** + * @beta + * @hidden + */ + +export type MentionOptionsHookValue = Loadable + +/** + * @beta + * @hidden + */ +export interface MentionOptionUser extends User { + canBeMentioned: boolean +} + +/** + * @beta + * @hidden + */ +export interface CommentThreadItem { + breadcrumbs: CommentListBreadcrumbs + commentsCount: number + fieldPath: string + parentComment: CommentDocument + replies: CommentDocument[] + threadId: string +} + +/** + * @beta + * @hidden + */ +export type CommentMessage = PortableTextBlock[] | null + +/** + * @beta + * @hidden + */ +export type CommentStatus = 'open' | 'resolved' + +/** + * @beta + * @hidden + */ +export interface CommentPath { + field: string +} + +/** + * @beta + * @hidden + */ +export interface CommentContext { + tool: string + payload?: Record + notification?: { + documentTitle: string + url: string + workspaceTitle: string + currentThreadLength?: number + } +} + +interface CommentCreateRetryingState { + type: 'createRetrying' +} + +interface CommentCreateFailedState { + type: 'createError' + error: Error +} + +/** + * The state is used to track the state of the comment (e.g. if it failed to be created, etc.) + * It is a local value and is not stored on the server. + * When there's no state, the comment is considered to be in a "normal" state (e.g. created successfully). + * + * The state value is primarily used to update the UI. That is, to show an error message or retry button. + */ +type CommentState = CommentCreateFailedState | CommentCreateRetryingState | undefined + +/** + * @beta + * @hidden + */ +export interface CommentDocument { + _type: 'comment' + _createdAt: string + _updatedAt: string + _id: string + _rev: string + + authorId: string + + message: CommentMessage + + threadId: string + + parentCommentId?: string + + status: CommentStatus + + lastEditedAt?: string + + context?: CommentContext + + _state?: CommentState + + target: { + path: CommentPath + documentType: string + document: { + _dataset: string + _projectId: string + _ref: string + _type: 'crossDatasetReference' + _weak: boolean + } + } +} + +/** + * @beta + * @hidden + */ +export type CommentPostPayload = Omit + +/** + * @beta + * @hidden + */ +export interface CommentCreatePayload { + fieldPath: string + id?: string + message: CommentMessage + parentCommentId: string | undefined + status: CommentStatus + threadId: string +} + +/** + * @beta + * @hidden + */ +export type CommentEditPayload = { + message: CommentMessage + lastEditedAt?: string +} + +/** + * @beta + * @hidden + */ +export interface CommentsListBreadcrumbItem { + invalid: boolean + isArrayItem?: boolean + title: string +} + +/** + * @beta + * @hidden + */ +export type CommentListBreadcrumbs = CommentsListBreadcrumbItem[] diff --git a/packages/sanity/src/desk/comments/src/utils/buildCommentBreadcrumbs.ts b/packages/sanity/src/desk/comments/src/utils/buildCommentBreadcrumbs.ts new file mode 100644 index 00000000000..1bdaf1cf985 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/utils/buildCommentBreadcrumbs.ts @@ -0,0 +1,203 @@ +import { + SchemaType, + ObjectField, + isObjectSchemaType, + CurrentUser, + ArraySchemaType, + ConditionalPropertyCallbackContext, + ObjectSchemaType, + ObjectFieldType, + PathSegment, + isArraySchemaType, +} from '@sanity/types' +import {findIndex} from 'lodash' +import * as PathUtils from '@sanity/util/paths' +import {SanityDocument} from '@sanity/client' +import {CommentListBreadcrumbs} from '../types' +import {getSchemaTypeTitle, getValueAtPath, resolveConditionalProperty} from 'sanity' + +function getSchemaField( + schemaType: SchemaType, + fieldPath: string, +): ObjectField | undefined { + const paths = PathUtils.fromString(fieldPath) + const firstPath = paths[0] + + if (firstPath && isObjectSchemaType(schemaType)) { + const field = schemaType?.fields?.find((f) => f.name === firstPath) + + if (field) { + const nextPath = PathUtils.toString(paths.slice(1)) + + if (nextPath) { + return getSchemaField(field.type, nextPath) + } + + return field + } + } + + return undefined +} + +function findArrayItemIndex(array: unknown[], pathSegment: PathSegment): number | false { + if (typeof pathSegment === 'number') { + return pathSegment + } + const index = findIndex(array, pathSegment) + return index === -1 ? false : index +} + +interface BuildCommentBreadcrumbsProps { + documentValue: Partial | null + fieldPath: string + schemaType: SchemaType + currentUser: CurrentUser +} + +/** + * @beta + * @hidden + * + * This function builds a breadcrumb trail for a given comment using its field path. + * It will validate each segment of the path against the document value and/or schema type. + * The path is invalid if: + * - The field is hidden by a conditional field + * - The field is not found in the schema type + * - The field is not found in the document value (array items only) + */ +export function buildCommentBreadcrumbs( + props: BuildCommentBreadcrumbsProps, +): CommentListBreadcrumbs { + const {currentUser, schemaType, fieldPath, documentValue} = props + const paths = PathUtils.fromString(fieldPath) + const fieldPaths: CommentListBreadcrumbs = [] + + let currentSchemaType: ArraySchemaType | ObjectFieldType | null = null + + paths.forEach((seg, index) => { + const currentPath = paths.slice(0, index + 1) + const previousPath = paths.slice(0, index) + + const field = getSchemaField(schemaType, PathUtils.toString(currentPath)) + const isKeySegment = seg.hasOwnProperty('_key') + + const parentValue = getValueAtPath(documentValue, previousPath) + const currentValue = getValueAtPath(documentValue, currentPath) + + const conditionalContext: ConditionalPropertyCallbackContext = { + document: documentValue as SanityDocument, + currentUser, + parent: parentValue, + value: currentValue, + } + + // If the field is a key segment and the parent value is an array, we'll + // try to find the index of the array item in the parent value. + // If the index is not found, we'll mark it as invalid. + // This can happen if the array item has been removed from the document value. + if (isKeySegment && Array.isArray(parentValue)) { + const arrayItemIndex = findArrayItemIndex(parentValue, seg) + + const isNumber = typeof arrayItemIndex === 'number' + + fieldPaths.push({ + invalid: arrayItemIndex === false, + isArrayItem: true, + title: isNumber ? `#${Number(arrayItemIndex) + 1}` : 'Unknown array item', + }) + + return + } + + // If we find a field in the schema type, we'll add it to the breadcrumb trail. + if (field?.type) { + const hidden = resolveConditionalProperty(field.type.hidden, conditionalContext) + + fieldPaths.push({ + invalid: hidden, + isArrayItem: false, + title: getSchemaTypeTitle(field.type), + }) + + // Store the current schema type so we can use it in the next iteration. + currentSchemaType = field.type + + return + } + + if (isArraySchemaType(currentSchemaType)) { + // Get the value of the array field in the document value + const arrayValue: any = getValueAtPath(documentValue, previousPath) + + // Get the object type of the array field in the schema type + // from the array field's `_type` property in the document value. + const objectType = arrayValue?._type + + // Find the object field in the array field's `of` array using + // the object type from the document value. + const objectField = currentSchemaType?.of?.find( + (type) => type.name === objectType, + ) as ObjectSchemaType + + // Find the field in the object field's `fields` array + // using the field name from the path segment. + const currentField = objectField?.fields?.find( + (f) => f.name === seg, + ) as ObjectField + + // If we don't find the object field, we'll mark it as invalid. + // This can happen if the object field has been removed from the schema type. + if (!currentField) { + fieldPaths.push({ + invalid: true, + isArrayItem: false, + title: 'Unknown field', + }) + + return + } + + // Get the title of the current field + const currentTitle = getSchemaTypeTitle(currentField?.type) + + // Resolve the hidden property of the object field + const objectFieldHidden = resolveConditionalProperty( + objectField?.type?.hidden, + conditionalContext, + ) + + // Resolve the hidden property of the current field + const currentFieldHidden = resolveConditionalProperty( + currentField?.type.hidden, + conditionalContext, + ) + + // If the object field or the current field is hidden, we'll mark it as invalid. + const isHidden = objectFieldHidden || currentFieldHidden + + // Add the field to the breadcrumb trail + fieldPaths.push({ + invalid: isHidden, + isArrayItem: false, + title: currentTitle, + }) + + // If the current field is an object field, we'll set it as the current schema type + // so we can use it in the next iteration. + currentSchemaType = currentField?.type + + return + } + + // If we get here, the field is not found in the schema type + // or the document value so we'll mark it as invalid. + fieldPaths.push({ + invalid: true, + isArrayItem: false, + title: 'Unknown field', + }) + }) + + return fieldPaths +} diff --git a/packages/sanity/src/desk/comments/src/utils/buildCommentThreadItems.ts b/packages/sanity/src/desk/comments/src/utils/buildCommentThreadItems.ts new file mode 100644 index 00000000000..8df0c4a2847 --- /dev/null +++ b/packages/sanity/src/desk/comments/src/utils/buildCommentThreadItems.ts @@ -0,0 +1,52 @@ +import {SanityDocument} from '@sanity/client' +import {SchemaType, CurrentUser} from '@sanity/types' +import {CommentDocument, CommentThreadItem} from '../types' +import {buildCommentBreadcrumbs} from './buildCommentBreadcrumbs' + +interface BuildCommentThreadItemsProps { + comments: CommentDocument[] + currentUser: CurrentUser + documentValue: Partial | null + schemaType: SchemaType +} + +/** + * This function formats comments into a structure that is easier to work with in the UI. + * It also validates each comment against the document value and schema type to ensure + * that the comment is valid. If the comment is invalid, it will be omitted from the + * returned array. + */ +export function buildCommentThreadItems(props: BuildCommentThreadItemsProps): CommentThreadItem[] { + const {comments, currentUser, documentValue, schemaType} = props + const parentComments = comments?.filter((c) => !c.parentCommentId) + + const items = parentComments + .map((parentComment) => { + const crumbs = buildCommentBreadcrumbs({ + currentUser, + documentValue, + fieldPath: parentComment.target.path.field, + schemaType, + }) + + const hasInvalidBreadcrumb = crumbs.some((bc) => bc.invalid) + + if (hasInvalidBreadcrumb) return undefined + + const replies = comments?.filter((r) => r.parentCommentId === parentComment._id) + + const commentsCount = [parentComment, ...replies].length + + return { + breadcrumbs: crumbs, + commentsCount, + fieldPath: parentComment.target.path.field, + parentComment, + replies, + threadId: parentComment.threadId, + } + }) + .filter(Boolean) as CommentThreadItem[] + + return items +} diff --git a/packages/sanity/src/desk/comments/src/utils/index.ts b/packages/sanity/src/desk/comments/src/utils/index.ts new file mode 100644 index 00000000000..a5766f2808c --- /dev/null +++ b/packages/sanity/src/desk/comments/src/utils/index.ts @@ -0,0 +1 @@ +export * from './buildCommentBreadcrumbs' diff --git a/packages/sanity/src/desk/components/paneRouter/PaneRouterContext.tsx b/packages/sanity/src/desk/components/paneRouter/PaneRouterContext.tsx index 76af6160f9d..2c12dff7a1f 100644 --- a/packages/sanity/src/desk/components/paneRouter/PaneRouterContext.tsx +++ b/packages/sanity/src/desk/components/paneRouter/PaneRouterContext.tsx @@ -32,4 +32,5 @@ export const PaneRouterContext = createContext({ setParams: () => missingContext(), setPayload: () => missingContext(), navigateIntent: () => missingContext(), + createPathWithParams: () => missingContext(), }) diff --git a/packages/sanity/src/desk/components/paneRouter/PaneRouterProvider.tsx b/packages/sanity/src/desk/components/paneRouter/PaneRouterProvider.tsx index b9d537ccda2..a0baf3e8926 100644 --- a/packages/sanity/src/desk/components/paneRouter/PaneRouterProvider.tsx +++ b/packages/sanity/src/desk/components/paneRouter/PaneRouterProvider.tsx @@ -25,7 +25,7 @@ export function PaneRouterProvider(props: { siblingIndex: number }) { const {children, flatIndex, index, params, payload, siblingIndex} = props - const {navigate, navigateIntent} = useRouter() + const {navigate, navigateIntent, resolvePathFromState} = useRouter() const routerState = useRouterState() const {panes, expand} = usePaneLayout() const routerPaneGroups: RouterPaneGroup[] = useMemo( @@ -36,7 +36,7 @@ export function PaneRouterProvider(props: { const groupIndex = index - 1 - const modifyCurrentGroup = useCallback( + const createNextRouterState = useCallback( (modifier: (siblings: RouterPaneGroup, item: RouterPaneSibling) => RouterPaneGroup) => { const currentGroup = routerPaneGroups[groupIndex] || [] const currentItem = currentGroup[siblingIndex] @@ -48,11 +48,31 @@ export function PaneRouterProvider(props: { ] const nextRouterState = {...(routerState || {}), panes: nextPanes} - setTimeout(() => navigate(nextRouterState), 0) + return nextRouterState + }, + [groupIndex, routerPaneGroups, routerState, siblingIndex], + ) + const modifyCurrentGroup = useCallback( + (modifier: (siblings: RouterPaneGroup, item: RouterPaneSibling) => RouterPaneGroup) => { + const nextRouterState = createNextRouterState(modifier) + setTimeout(() => navigate(nextRouterState), 0) return nextRouterState }, - [groupIndex, navigate, routerPaneGroups, routerState, siblingIndex], + [createNextRouterState, navigate], + ) + + const createPathWithParams: PaneRouterContextValue['createPathWithParams'] = useCallback( + (nextParams) => { + const nextRouterState = createNextRouterState((siblings, item) => [ + ...siblings.slice(0, siblingIndex), + {...item, params: nextParams}, + ...siblings.slice(siblingIndex + 1), + ]) + + return resolvePathFromState(nextRouterState) + }, + [createNextRouterState, resolvePathFromState, siblingIndex], ) const setPayload: PaneRouterContextValue['setPayload'] = useCallback( @@ -195,6 +215,9 @@ export function PaneRouterProvider(props: { // Set the payload for the current pane setPayload, + // A function that returns a path with the given parameters + createPathWithParams, + // Proxied navigation to a given intent. Consider just exposing `router` instead? navigateIntent, }), @@ -208,6 +231,7 @@ export function PaneRouterProvider(props: { handleEditReference, setParams, setPayload, + createPathWithParams, navigateIntent, modifyCurrentGroup, lastPane, diff --git a/packages/sanity/src/desk/components/paneRouter/types.ts b/packages/sanity/src/desk/components/paneRouter/types.ts index f1b7a55b415..b111d0bf10d 100644 --- a/packages/sanity/src/desk/components/paneRouter/types.ts +++ b/packages/sanity/src/desk/components/paneRouter/types.ts @@ -153,6 +153,12 @@ export interface PaneRouterContextValue { */ setPayload: (payload: unknown) => void + /** + * A function that creates a path with the given parameters without navigating to it. + * Useful for creating links that can be e.g. copied to clipboard and shared. + */ + createPathWithParams: (params: Record) => string + /** * Proxied navigation to a given intent. Consider just exposing `router` instead? */ diff --git a/packages/sanity/src/desk/deskTool.ts b/packages/sanity/src/desk/deskTool.ts index 896451f37db..b94145e3004 100644 --- a/packages/sanity/src/desk/deskTool.ts +++ b/packages/sanity/src/desk/deskTool.ts @@ -12,8 +12,9 @@ import {LiveEditBadge} from './documentBadges' import {getIntentState} from './getIntentState' import {router} from './router' import {DeskToolOptions} from './types' -import {validationInspector} from './panes/document/inspectors/validation' +import {comments} from './comments' import {changesInspector} from './panes/document/inspectors/changes' +import {validationInspector} from './panes/document/inspectors/validation' import {definePlugin} from 'sanity' const documentActions = [ @@ -94,6 +95,9 @@ export const deskTool = definePlugin((options) => ({ return Array.from(new Set([...prevInspectors, ...inspectors])) }, }, + + plugins: [comments()], + tools: [ { name: options?.name || 'desk', diff --git a/packages/sanity/src/desk/panes/document/DocumentPaneProvider.tsx b/packages/sanity/src/desk/panes/document/DocumentPaneProvider.tsx index afc3804d08b..e148685a517 100644 --- a/packages/sanity/src/desk/panes/document/DocumentPaneProvider.tsx +++ b/packages/sanity/src/desk/panes/document/DocumentPaneProvider.tsx @@ -1,6 +1,6 @@ import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' import {ObjectSchemaType, Path, SanityDocument, SanityDocumentLike} from '@sanity/types' -import {omit, set} from 'lodash' +import {omit} from 'lodash' import {useToast} from '@sanity/ui' import {fromString as pathFromString, resolveKeyedPath} from '@sanity/util/paths' import isHotkey from 'is-hotkey' @@ -21,16 +21,24 @@ import { } from './constants' import {DocumentInspectorMenuItemsResolver} from './DocumentInspectorMenuItemsResolver' import { + DocumentFieldAction, + DocumentFieldActionNode, DocumentInspector, + DocumentInspectorMenuItem, DocumentPresence, - PatchEvent, - StateTree, - toMutationPatches, + EMPTY_ARRAY, + FieldActionsProvider, + FieldActionsResolver, + getDraftId, getExpandOperations, getPublishedId, + PatchEvent, setAtPath, + StateTree, + toMutationPatches, useConnectionState, useDocumentOperation, + useDocumentValuePermissions, useEditState, useFormState, useInitialValue, @@ -38,19 +46,12 @@ import { useSchema, useSource, useTemplates, + useTimelineSelector, + useTimelineStore, useUnique, useValidationStatus, - getDraftId, - useDocumentValuePermissions, - useTimelineStore, - useTimelineSelector, - DocumentFieldAction, - DocumentInspectorMenuItem, - FieldActionsResolver, - EMPTY_ARRAY, - DocumentFieldActionNode, - FieldActionsProvider, } from 'sanity' +import {CommentsProvider} from '../../comments' /** * @internal @@ -683,7 +684,9 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => { )} - {children} + + {children} + ) diff --git a/packages/sanity/src/desk/panes/document/constants.ts b/packages/sanity/src/desk/panes/document/constants.ts index 87e4ee92f9b..a7c77188a09 100644 --- a/packages/sanity/src/desk/panes/document/constants.ts +++ b/packages/sanity/src/desk/panes/document/constants.ts @@ -16,3 +16,4 @@ export const DEFAULT_MENU_ITEM_GROUPS: PaneMenuItemGroup[] = [{id: 'inspectors'} // inspectors export const HISTORY_INSPECTOR_NAME = 'sanity/desk/history' export const VALIDATION_INSPECTOR_NAME = 'sanity/desk/validation' +export const COMMENTS_INSPECTOR_NAME = 'sanity/desk/comments' diff --git a/packages/sanity/src/desk/router.ts b/packages/sanity/src/desk/router.ts index 7a2803b6666..98ad10500e1 100644 --- a/packages/sanity/src/desk/router.ts +++ b/packages/sanity/src/desk/router.ts @@ -77,7 +77,7 @@ const panePattern = /^([.a-z0-9_-]+),?({.*?})?(?:(;|$))/i const isParam = (str: string) => /^[a-z0-9]+=[^=]+/i.test(str) const isPayload = (str: string) => /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(str) -const exclusiveParams = ['view', 'since', 'rev', 'inspect'] +const exclusiveParams = ['view', 'since', 'rev', 'inspect', 'comment'] type Truthy = T extends false ? never
+ {JSON.stringify(data, null, 2)} +