diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 167264d9c47..abd89246fa7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -25,3 +25,5 @@ # Tom owns the Shopify templates /packages/@sanity/cli/src/actions/init-project/templates/shopify* @thebiggianthead /packages/@sanity/cli/templates/shopify* @thebiggianthead + +/packages/sanity/src/desk/comments/ @hermanwikner diff --git a/dev/studio-e2e-testing/sanity.config.ts b/dev/studio-e2e-testing/sanity.config.ts index c47d8012965..78a58de96c8 100644 --- a/dev/studio-e2e-testing/sanity.config.ts +++ b/dev/studio-e2e-testing/sanity.config.ts @@ -12,7 +12,6 @@ import {defaultDocumentNode, newDocumentOptions, structure} from 'sanity-test-st import {presenceTool} from 'sanity-test-studio/plugins/presence' import {copyAction} from 'sanity-test-studio/fieldActions/copyAction' import {assistFieldActionGroup} from 'sanity-test-studio/fieldActions/assistFieldActionGroup' -import {commentAction} from 'sanity-test-studio/fieldActions/commentFieldAction' import {customInspector} from 'sanity-test-studio/inspectors/custom' import {pasteAction} from 'sanity-test-studio/fieldActions/pasteAction' import {Branding} from './components/Branding' @@ -45,7 +44,7 @@ const sharedSettings = definePlugin({ }, unstable_fieldActions: (prev, ctx) => { if (['fieldActionsTest', 'stringsTest'].includes(ctx.documentType)) { - return [...prev, commentAction, assistFieldActionGroup, copyAction, pasteAction] + return [...prev, assistFieldActionGroup, copyAction, pasteAction] } return prev diff --git a/dev/test-studio/fieldActions/commentFieldAction.tsx b/dev/test-studio/fieldActions/commentFieldAction.tsx deleted file mode 100644 index 6bb6b356923..00000000000 --- a/dev/test-studio/fieldActions/commentFieldAction.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import {CommentIcon} from '@sanity/icons' -import {useCallback} from 'react' -import {defineDocumentFieldAction} from 'sanity' -import {defineActionItem} from './define' - -export const commentAction = defineDocumentFieldAction({ - name: 'test/comment', - useAction({documentId, documentType, path}) { - const onAction = useCallback(() => { - // eslint-disable-next-line no-console - console.log('comment', {documentId, documentType, path}) - }, [documentId, documentType, path]) - - return defineActionItem({ - type: 'action', - icon: CommentIcon, - onAction, - title: 'Comment', - renderAsButton: true, - }) - }, -}) diff --git a/dev/test-studio/sanity.config.ts b/dev/test-studio/sanity.config.ts index 12c708ed827..2cc25ef766f 100644 --- a/dev/test-studio/sanity.config.ts +++ b/dev/test-studio/sanity.config.ts @@ -5,7 +5,6 @@ import {deskTool} from 'sanity/desk' import {muxInput} from 'sanity-plugin-mux-input' import {assist} from '@sanity/assist' import {googleMapsInput} from '@sanity/google-maps-input' -// eslint-disable-next-line import/no-extraneous-dependencies import {tsdoc} from '@sanity/tsdoc/studio' import {theme as tailwindTheme} from './sanity.theme.mjs' import {imageAssetSource} from './assetSources' @@ -39,7 +38,6 @@ import {vercelTheme} from './themes/vercel' import {GoogleLogo, TailwindLogo, VercelLogo} from './components/workspaceLogos' import {copyAction} from './fieldActions/copyAction' import {assistFieldActionGroup} from './fieldActions/assistFieldActionGroup' -import {commentAction} from './fieldActions/commentFieldAction' import {customInspector} from './inspectors/custom' import {pasteAction} from './fieldActions/pasteAction' @@ -70,12 +68,16 @@ const sharedSettings = definePlugin({ }, unstable_fieldActions: (prev, ctx) => { if (['fieldActionsTest', 'stringsTest'].includes(ctx.documentType)) { - return [...prev, commentAction, assistFieldActionGroup, copyAction, pasteAction] + return [...prev, assistFieldActionGroup, copyAction, pasteAction] } return prev }, newDocumentOptions, + + unstable_comments: { + enabled: true, + }, }, plugins: [ deskTool({ diff --git a/dev/test-studio/schema/debug/comments.ts b/dev/test-studio/schema/debug/comments.ts new file mode 100644 index 00000000000..3846857aa40 --- /dev/null +++ b/dev/test-studio/schema/debug/comments.ts @@ -0,0 +1,115 @@ +import {defineType} from 'sanity' + +const DESCRIPTION = 'Comments added to this field should be hidden when the toggle above is checked' + +export const commentsDebug = defineType({ + name: 'commentsDebug', + type: 'document', + title: 'Comments debug', + fields: [ + { + name: 'string', + type: 'string', + title: 'String title', + }, + { + name: 'hideFields', + type: 'boolean', + title: 'Hide fields', + }, + { + type: 'object', + name: 'object', + title: 'Object title', + fields: [ + { + type: 'string', + name: 'string', + title: 'String title', + hidden: ({document}) => Boolean(document?.hideFields), + description: DESCRIPTION, + }, + { + type: 'number', + name: 'number', + title: 'Number title', + }, + ], + }, + { + type: 'array', + name: 'arrayOfObjects', + title: 'Array 1', + of: [ + { + name: 'arrayObject', + type: 'object', + title: 'Array object 1', + fields: [ + { + name: 'string', + type: 'string', + title: 'String 1', + }, + { + name: 'image', + type: 'image', + title: 'Image 1', + hidden: ({document}) => { + return Boolean(document?.hideFields) + }, + description: DESCRIPTION, + }, + { + name: 'nestedArray', + type: 'array', + title: 'Array 2', + of: [ + { + name: 'nestedArrayObject1', + type: 'object', + title: 'Nested array object 1', + fields: [ + { + name: 'string', + type: 'string', + title: 'String 2.1', + }, + { + name: 'image', + type: 'image', + title: 'Image 2.1', + hidden: ({document}) => { + return Boolean(document?.hideFields) + }, + description: DESCRIPTION, + }, + ], + }, + { + type: 'object', + name: 'nestedArrayObject2', + title: 'Nested array object 2', + fields: [ + { + name: 'string', + type: 'string', + title: 'String 2.2', + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + name: 'image', + type: 'image', + title: 'Image title', + hidden: ({document}) => Boolean(document?.hideFields), + description: DESCRIPTION, + }, + ], +}) diff --git a/dev/test-studio/schema/index.ts b/dev/test-studio/schema/index.ts index 3a15300cc8f..fdd3ba5e896 100644 --- a/dev/test-studio/schema/index.ts +++ b/dev/test-studio/schema/index.ts @@ -113,6 +113,7 @@ import {circularCrossDatasetReferenceTest} from './debug/circularCrossDatasetRef import {allNativeInputComponents} from './debug/allNativeInputComponents' import fieldGroupsWithFieldsets from './debug/fieldGroupsWithFieldsets' import ptReference from './debug/ptReference' +import {commentsDebug} from './debug/comments' // @todo temporary, until code input is v3 compatible const codeInputType = { @@ -159,6 +160,7 @@ export const schemaTypes = [ code, codeInputType, // @todo temporary, until code input is v3 compatible color, + commentsDebug, conditionalFields, conditionalFieldset, customBlock, diff --git a/dev/test-studio/structure/constants.ts b/dev/test-studio/structure/constants.ts index 264ae339150..9dd987bfb62 100644 --- a/dev/test-studio/structure/constants.ts +++ b/dev/test-studio/structure/constants.ts @@ -34,27 +34,26 @@ export const PLUGIN_INPUT_TYPES = [ ] export const DEBUG_INPUT_TYPES = [ - 'languageFilterDebug', 'actionsTest', - 'simpleArrayOfObjects', - 'simpleReferences', - 'formInputDebug', + 'allNativeInputComponents', + 'collapsibleObjects', + 'commentsDebug', 'conditionalFieldsTest', 'customInputsTest', 'customInputsWithPatches', 'documentActionsTest', - 'collapsibleObjects', + 'documentWithHoistedPt', 'empty', 'fieldActionsTest', 'fieldComponentsTest', 'fieldsetsTest', 'fieldValidationInferReproDoc', 'focusTest', - 'documentWithHoistedPt', + 'formInputDebug', 'initialValuesTest', 'inspectorsTest', 'invalidPreviews', - 'thesis', + 'languageFilterDebug', 'manyFieldsTest', 'noTitleField', 'poppers', @@ -62,22 +61,24 @@ export const DEBUG_INPUT_TYPES = [ 'previewImageUrlTest', 'previewMediaTest', 'previewSelectBugRepro', + 'ptReference', 'radio', 'readOnlyTest', - 'recursiveDocument', 'recursiveArraysTest', + 'recursiveDocument', 'recursiveObjectTest', 'recursivePopoverTest', 'reservedKeywordsTest', + 'scrollBug', 'select', + 'simpleArrayOfObjects', + 'simpleReferences', + 'thesis', 'typeWithNoToplevelStrings', 'uploadsTest', 'validationTest', - 'allNativeInputComponents', - 'scrollBug', - 'ptReference', - 'virtualizationInObject', 'virtualizationDebug', + 'virtualizationInObject', ] export const CI_INPUT_TYPES = ['conditionalFieldset', 'validationCI', 'textsTest'] diff --git a/packages/sanity/package.json b/packages/sanity/package.json index cb04a1e2115..d05df7be6a5 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -151,6 +151,7 @@ "@sanity/logos": "^2.0.2", "@sanity/mutator": "3.18.1", "@sanity/portable-text-editor": "3.18.1", + "@portabletext/react": "^3.0.0", "@sanity/schema": "3.18.1", "@sanity/types": "3.18.1", "@sanity/ui": "^1.8.3", diff --git a/packages/sanity/playwright-ct/tests/comments/CommentInput.spec.tsx b/packages/sanity/playwright-ct/tests/comments/CommentInput.spec.tsx new file mode 100644 index 00000000000..1b34489d901 --- /dev/null +++ b/packages/sanity/playwright-ct/tests/comments/CommentInput.spec.tsx @@ -0,0 +1,49 @@ +import {expect, test} from '@playwright/experimental-ct-react' +import React from 'react' +import {testHelpers} from '../utils/testHelpers' +import {CommentsInputStory} from './CommentInputStory' + +test.describe('Comments', () => { + test.describe('CommentInput', () => { + test('Should render', async ({mount, page}) => { + await mount() + const $editable = page.getByTestId('comment-input-editable') + await expect($editable).toBeVisible() + }) + + test('Should be able to type into', async ({mount, page}) => { + const {insertPortableText} = testHelpers({page}) + await mount() + const $editable = page.getByTestId('comment-input-editable') + await expect($editable).toBeEditable() + await insertPortableText('My first comment!', $editable) + await expect($editable).toHaveText('My first comment!') + }) + + test('Should bring up mentions menu when typing @', async ({mount, page}) => { + await mount() + const $editable = page.getByTestId('comment-input-editable') + await $editable.waitFor({state: 'visible'}) + await page.keyboard.type('@') + await expect(page.getByTestId('comments-mentions-menu')).toBeVisible() + }) + + test('Should be able to submit', async ({mount, page}) => { + const {insertPortableText} = testHelpers({page}) + let submitted = false + const onSubmit = () => { + submitted = true + } + await mount() + const $editable = page.getByTestId('comment-input-editable') + await expect($editable).toBeEditable() + // Test that blank comments can't be submitted + await page.keyboard.press('Enter') + expect(submitted).toBe(false) + await insertPortableText('This is a comment!', $editable) + await expect($editable).toHaveText('This is a comment!') + await page.keyboard.press('Enter') + expect(submitted).toBe(true) + }) + }) +}) diff --git a/packages/sanity/playwright-ct/tests/comments/CommentInputStory.tsx b/packages/sanity/playwright-ct/tests/comments/CommentInputStory.tsx new file mode 100644 index 00000000000..a7ff572789c --- /dev/null +++ b/packages/sanity/playwright-ct/tests/comments/CommentInputStory.tsx @@ -0,0 +1,47 @@ +import React, {useState} from 'react' +import {CurrentUser, PortableTextBlock} from '@sanity/types' +import {noop} from 'lodash' +import {CommentInput} from '../../../src/desk/comments/src/components/pte/comment-input/CommentInput' +import {TestWrapper} from '../formBuilder/utils/TestWrapper' + +const currentUser: CurrentUser = { + email: '', + id: '', + name: '', + role: '', + roles: [], + profileImage: '', + provider: '', +} + +const SCHEMA_TYPES: [] = [] + +export function CommentsInputStory({ + onDiscardCancel = noop, + onDiscardConfirm = noop, + onSubmit = noop, + value = null, +}: { + onDiscardCancel?: () => void + onDiscardConfirm?: () => void + onSubmit?: () => void + value?: PortableTextBlock[] | null +}) { + const [valueState, setValueState] = useState(value) + return ( + + + + ) +} diff --git a/packages/sanity/src/core/components/userAvatar/UserAvatar.tsx b/packages/sanity/src/core/components/userAvatar/UserAvatar.tsx index ce22b510931..1ed0aefd150 100644 --- a/packages/sanity/src/core/components/userAvatar/UserAvatar.tsx +++ b/packages/sanity/src/core/components/userAvatar/UserAvatar.tsx @@ -80,7 +80,7 @@ const StaticUserAvatar = forwardRef(function StaticUserAvatar( props: Omit & {user: User}, ref: React.ForwardedRef, ) { - const {user, animateArrowFrom, position, size, status, tone} = props + const {user, animateArrowFrom, position, size, status, tone, ...restProps} = props const [imageLoadError, setImageLoadError] = useState(null) const userColor = useUserColor(user.id) const imageUrl = imageLoadError ? undefined : user?.imageUrl @@ -98,6 +98,7 @@ const StaticUserAvatar = forwardRef(function StaticUserAvatar( size={typeof size === 'string' ? LEGACY_TO_UI_AVATAR_SIZES[size] : size} status={status} title={user?.displayName} + {...restProps} /> ) }) diff --git a/packages/sanity/src/core/config/configPropertyReducers.ts b/packages/sanity/src/core/config/configPropertyReducers.ts index b842b6f0c98..13cffdbeaa9 100644 --- a/packages/sanity/src/core/config/configPropertyReducers.ts +++ b/packages/sanity/src/core/config/configPropertyReducers.ts @@ -14,7 +14,10 @@ import type { NewDocumentOptionsContext, ResolveProductionUrlContext, Tool, + DocumentCommentsEnabledContext, + PluginOptions, } from './types' +import {flattenConfig} from './flattenConfig' export const initialDocumentBadges: DocumentBadgeComponent[] = [] @@ -239,3 +242,31 @@ export const documentInspectorsReducer: ConfigPropertyReducer< )}`, ) } + +export const documentCommentsEnabledReducer = (opts: { + config: PluginOptions + context: DocumentCommentsEnabledContext + initialValue: boolean +}): boolean => { + const {config, context, initialValue} = opts + const flattenedConfig = flattenConfig(config, []) + + // There is no concept of 'previous value' in this API. We only care about the final value. + // That is, if a plugin returns true, but the next plugin returns false, the result will be false. + // The last plugin 'wins'. + const result = flattenedConfig.reduce((acc, {config: innerConfig}) => { + const resolver = innerConfig.document?.unstable_comments?.enabled + + if (!resolver && typeof resolver !== 'boolean') return acc + if (typeof resolver === 'function') return resolver(context) + if (typeof resolver === 'boolean') return resolver + + throw new Error( + `Expected \`document.unstable_comments.enabled\` to be a boolean or a function, but received ${getPrintableType( + resolver, + )}`, + ) + }, initialValue) + + return result +} diff --git a/packages/sanity/src/core/config/prepareConfig.ts b/packages/sanity/src/core/config/prepareConfig.ts index a2074f32099..db2f1ebb565 100644 --- a/packages/sanity/src/core/config/prepareConfig.ts +++ b/packages/sanity/src/core/config/prepareConfig.ts @@ -28,6 +28,7 @@ import type { import { documentActionsReducer, documentBadgesReducer, + documentCommentsEnabledReducer, documentInspectorsReducer, documentLanguageFilterReducer, fileAssetSourceResolver, @@ -506,6 +507,16 @@ function resolveSource({ propertyName: 'document.unstable_languageFilter', reducer: documentLanguageFilterReducer, }), + + unstable_comments: { + enabled: (partialContext) => { + return documentCommentsEnabledReducer({ + context: partialContext, + config, + initialValue: false, + }) + }, + }, }, form: { file: { diff --git a/packages/sanity/src/core/config/types.ts b/packages/sanity/src/core/config/types.ts index 9d2c6b64f35..756b4fc0ef0 100644 --- a/packages/sanity/src/core/config/types.ts +++ b/packages/sanity/src/core/config/types.ts @@ -276,6 +276,11 @@ export interface DocumentPluginOptions { * @beta */ newDocumentOptions?: NewDocumentOptionsResolver + + /** @internal */ + unstable_comments?: { + enabled: boolean | ((context: DocumentCommentsEnabledContext) => boolean) + } } /** @@ -461,6 +466,12 @@ export interface DocumentInspectorContext extends ConfigContext { documentType: string } +/** @hidden @beta */ +export interface DocumentCommentsEnabledContext { + documentId?: string + documentType: string +} + /** * @hidden * @beta @@ -564,6 +575,11 @@ export interface Source { * @beta */ inspectors: (props: PartialContext) => DocumentInspector[] + + /** @internal */ + unstable_comments: { + enabled: (props: DocumentCommentsEnabledContext) => boolean + } } /** diff --git a/packages/sanity/src/core/form/components/formField/FormField.tsx b/packages/sanity/src/core/form/components/formField/FormField.tsx index 344bdd00bc2..6639318339b 100644 --- a/packages/sanity/src/core/form/components/formField/FormField.tsx +++ b/packages/sanity/src/core/form/components/formField/FormField.tsx @@ -4,6 +4,7 @@ import React, {memo} from 'react' import {FormNodePresence} from '../../../presence' import {DocumentFieldActionNode} from '../../../config' import {useFieldActions} from '../../field' +import {FieldCommentsProps} from '../../types' import {FormFieldBaseHeader} from './FormFieldBaseHeader' import {FormFieldHeaderText} from './FormFieldHeaderText' @@ -21,6 +22,8 @@ export interface FormFieldProps { * @beta */ __unstable_presence?: FormNodePresence[] + /** @internal @deprecated DO NOT USE */ + __internal_comments?: FieldCommentsProps /** @internal @deprecated ONLY USED BY AI ASSIST PLUGIN */ __internal_slot?: React.ReactNode children: React.ReactNode @@ -48,6 +51,7 @@ export const FormField = memo(function FormField( __unstable_headerActions: actions = EMPTY_ARRAY, __unstable_presence: presence = EMPTY_ARRAY, __internal_slot: slot = null, + __internal_comments: comments, children, description, inputId, @@ -72,7 +76,7 @@ export const FormField = memo(function FormField( */} {title && ( (({theme, $right}) => { const {space} = theme.sanity - return css` position: absolute; - bottom: 0px; + bottom: 0; right: ${$right + space[1]}px; ` }) @@ -29,9 +27,7 @@ const ContentBox = styled(Box)<{ $presenceMaxWidth: number }>(({theme, $presenceMaxWidth}) => { const {space} = theme.sanity - return css` - // Limit the width to preserve space for presence avatars max-width: calc(100% - ${$presenceMaxWidth + space[1]}px); min-width: 75%; ` @@ -43,7 +39,6 @@ const SlotBox = styled(Box)<{ }>(({theme, $right, $fieldActionsVisible}) => { const {space} = theme.sanity const right = $fieldActionsVisible ? $right + space[1] : $right - return css` position: absolute; bottom: 0; @@ -51,22 +46,22 @@ const SlotBox = styled(Box)<{ ` }) -const FieldActionsFloatingCard = styled(Card)(({theme}) => { +const FieldActionsFloatingCard = styled(Card)(({theme}: {theme: Theme}) => { const {space} = theme.sanity - return css` - position: absolute; bottom: 0; - right: 0; + gap: ${space[1] / 2}px; padding: ${space[1] / 2}px; + position: absolute; + right: 0; ` }) const MAX_AVATARS = 4 interface FormFieldBaseHeaderProps { - /** @internal @deprecated ONLY USED BY AI ASSIST PLUGIN */ - __internal_slot?: React.ReactNode + __internal_comments?: FieldCommentsProps // DO NOT USE + __internal_slot?: React.ReactNode // ONLY USED BY AI ASSIST PLUGIN actions?: DocumentFieldActionNode[] content: React.ReactNode fieldFocused: boolean @@ -74,27 +69,51 @@ interface FormFieldBaseHeaderProps { presence?: FormNodePresence[] } -/** @internal */ export function FormFieldBaseHeader(props: FormFieldBaseHeaderProps) { - const {__internal_slot: slot, actions, content, presence, fieldFocused, fieldHovered} = props - - // The state refers to if a group field action menu is open + const { + __internal_comments: comments, + __internal_slot: slot, + actions, + content, + fieldFocused, + fieldHovered, + presence, + } = props + + // State for if an actions menu is open const [menuOpen, setMenuOpen] = useState(false) + // States for floating card element and its width const [floatingCardElement, setFloatingCardElement] = useState(null) + const [floatingCardWidth, setFloatingCardWidth] = useState(0) + // States for slot element and its width const [slotElement, setSlotElement] = useState(null) - - // The amount the presence box should be offset to the right - const [floatingCardWidth, setFloatingCardWidth] = useState(0) const [slotWidth, setSlotWidth] = useState(0) + // Extract comment related data with default values + const { + hasComments = false, + button: commentButton = null, + isAddingComment = false, + } = comments || {} + + // Determine if actions exist and if field actions should be shown const hasActions = actions && actions.length > 0 - const showFieldActions = hasActions && (fieldFocused || fieldHovered || menuOpen) + const showFieldActions = fieldFocused || fieldHovered || menuOpen || isAddingComment + + // Determine the shadow level for the card + const shadow = (showFieldActions && hasActions) || !hasComments ? 3 : undefined - const presenceMaxWidth = calcAvatarStackWidth(MAX_AVATARS) + // Determine if there's a comment button or actions to show. + // We check for `comments.button` since that's the visual element that should be + // used for comments. If no button is provided, we don't have anything to show for comments. + const hasCommentsButtonOrActions = comments?.button || hasActions - // Use the width of the floating card to offset the presence box + // Determine if floating card with actions should be shown + const shouldShowFloatingCard = showFieldActions || hasComments + + // Calculate floating card's width useEffect(() => { if (floatingCardElement) { const {width} = floatingCardElement.getBoundingClientRect() @@ -102,6 +121,7 @@ export function FormFieldBaseHeader(props: FormFieldBaseHeaderProps) { } }, [floatingCardElement, showFieldActions]) + // Calculate slot element's width useEffect(() => { if (slotElement) { const {width} = slotElement.getBoundingClientRect() @@ -109,6 +129,7 @@ export function FormFieldBaseHeader(props: FormFieldBaseHeaderProps) { } }, [slotElement]) + // Construct the slot element if slot is provided const slotEl = useMemo(() => { if (!slot) return null @@ -125,7 +146,7 @@ export function FormFieldBaseHeader(props: FormFieldBaseHeaderProps) { return ( - + {content} @@ -137,15 +158,19 @@ export function FormFieldBaseHeader(props: FormFieldBaseHeaderProps) { {slotEl} - {showFieldActions && ( + {shouldShowFloatingCard && hasCommentsButtonOrActions && ( - + {showFieldActions && hasActions && ( + + )} + + {commentButton} )} diff --git a/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx b/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx index 81b98067e6e..1ab5372ce6d 100644 --- a/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx +++ b/packages/sanity/src/core/form/components/formField/FormFieldSet.tsx @@ -7,6 +7,7 @@ import {FormNodePresence} from '../../../presence' import {DocumentFieldActionNode} from '../../../config' import {useFieldActions} from '../../field' import {createDescriptionId} from '../../members/common/createDescriptionId' +import {FieldCommentsProps} from '../../types' import {FormFieldValidationStatus} from './FormFieldValidationStatus' import {FormFieldSetLegend} from './FormFieldSetLegend' import {focusRingStyle} from './styles' @@ -24,6 +25,8 @@ export interface FormFieldSetProps { * @beta */ __unstable_presence?: FormNodePresence[] + /** @internal @deprecated DO NOT USE */ + __internal_comments?: FieldCommentsProps /** @internal @deprecated ONLY USED BY AI ASSIST PLUGIN */ __internal_slot?: React.ReactNode children: React.ReactNode | (() => React.ReactNode) @@ -96,9 +99,10 @@ export const FormFieldSet = forwardRef(function FormFieldSet( ref: React.ForwardedRef, ) { const { + __internal_comments: comments, + __internal_slot: slot = null, __unstable_headerActions: actions = EMPTY_ARRAY, __unstable_presence: presence = EMPTY_ARRAY, - __internal_slot: slot = null, children, collapsed, collapsible, @@ -150,6 +154,7 @@ export const FormFieldSet = forwardRef(function FormFieldSet( return ( ( /** The value you want to respond to changes in. */ diff --git a/packages/sanity/src/core/form/index.ts b/packages/sanity/src/core/form/index.ts index d86ac84796c..2682899361d 100644 --- a/packages/sanity/src/core/form/index.ts +++ b/packages/sanity/src/core/form/index.ts @@ -16,3 +16,4 @@ export * from './useFormValue' export * from './utils/mutationPatch' export * from './utils/path' export * from './utils/TransformPatches' +export * from './hooks/useDidUpdate' diff --git a/packages/sanity/src/core/form/inputs/CrossDatasetReferenceInput/CrossDatasetReferenceInput.tsx b/packages/sanity/src/core/form/inputs/CrossDatasetReferenceInput/CrossDatasetReferenceInput.tsx index 17ad23d5931..c638c64f1f6 100644 --- a/packages/sanity/src/core/form/inputs/CrossDatasetReferenceInput/CrossDatasetReferenceInput.tsx +++ b/packages/sanity/src/core/form/inputs/CrossDatasetReferenceInput/CrossDatasetReferenceInput.tsx @@ -29,13 +29,13 @@ import {FIXME} from '../../../FIXME' import {ChangeIndicator} from '../../../changeIndicators' import {PreviewCard} from '../../../components' import {useDidUpdate} from '../../hooks/useDidUpdate' +import {useFeatureEnabled} from '../../../hooks' import {CrossDatasetReferenceInfo, CrossDatasetSearchHit, SearchState} from './types' import {OptionPreview} from './OptionPreview' import {GetReferenceInfoFn, useReferenceInfo} from './useReferenceInfo' import {PreviewReferenceValue} from './PreviewReferenceValue' import {ReferenceAutocomplete} from './ReferenceAutocomplete' import {DisabledFeatureWarning} from './DisabledFeatureWarning' -import {useFeatureEnabled} from './useFeatureEnabled' import {useProjectId} from './utils/useProjectId' const INITIAL_SEARCH_STATE: SearchState = { diff --git a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx index 2eeebc6f66b..b8d48facae4 100644 --- a/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx +++ b/packages/sanity/src/core/form/inputs/ReferenceInput/ReferenceField.tsx @@ -33,7 +33,7 @@ import {useScrollIntoViewOnFocusWithin} from '../../hooks/useScrollIntoViewOnFoc import {useDidUpdate} from '../../hooks/useDidUpdate' import {set, unset} from '../../patch' import {AlertStrip} from '../../components/AlertStrip' -import {FieldActionsResolver} from '../../field' +import {FieldActionsProvider, FieldActionsResolver} from '../../field' import {DocumentFieldActionNode} from '../../../config' import {useFormPublishedId} from '../../useFormPublishedId' import {useReferenceInput} from './useReferenceInput' @@ -68,7 +68,7 @@ const MENU_POPOVER_PROPS = {portal: true, tone: 'default'} as const export function ReferenceField(props: ReferenceFieldProps) { const elementRef = useRef(null) - const {schemaType, path, open, inputId, children, inputProps, __internal_slot: slot} = props + const {schemaType, path, open, inputId, children, inputProps} = props const {readOnly, focused, renderPreview, onChange} = props.inputProps const [fieldActionsNodes, setFieldActionNodes] = useState([]) @@ -292,53 +292,58 @@ export function ReferenceField(props: ReferenceFieldProps) { /> )} - - {isEditing ? ( - {children} - ) : ( - - - - - - - {menu} - - {footer} - - - )} - + + {isEditing ? ( + {children} + ) : ( + + + + + + + {menu} + + {footer} + + + )} + + ) } diff --git a/packages/sanity/src/core/form/store/conditional-property/resolveConditionalProperty.ts b/packages/sanity/src/core/form/store/conditional-property/resolveConditionalProperty.ts index b0498a114a0..e5121793da6 100644 --- a/packages/sanity/src/core/form/store/conditional-property/resolveConditionalProperty.ts +++ b/packages/sanity/src/core/form/store/conditional-property/resolveConditionalProperty.ts @@ -1,6 +1,9 @@ /* eslint-disable no-nested-ternary */ import {ConditionalProperty, CurrentUser} from '@sanity/types' +/** + * @internal + */ export interface ConditionalPropertyCallbackContext { parent?: unknown document?: Record @@ -8,6 +11,9 @@ export interface ConditionalPropertyCallbackContext { value: unknown } +/** + * @internal + */ export function resolveConditionalProperty( property: ConditionalProperty, context: ConditionalPropertyCallbackContext, diff --git a/packages/sanity/src/core/form/store/index.ts b/packages/sanity/src/core/form/store/index.ts index 6b3a98a41e4..57a9a4a57c5 100644 --- a/packages/sanity/src/core/form/store/index.ts +++ b/packages/sanity/src/core/form/store/index.ts @@ -3,3 +3,5 @@ export * from './types' export * from './utils/getExpandOperations' export * from './useFormState' export type {FIXME_SanityDocument} from './formState' // eslint-disable-line camelcase + +export {resolveConditionalProperty} from './conditional-property' diff --git a/packages/sanity/src/core/form/studio/inputResolver/fieldResolver.tsx b/packages/sanity/src/core/form/studio/inputResolver/fieldResolver.tsx index caf70a01612..e6bca94dbf0 100644 --- a/packages/sanity/src/core/form/studio/inputResolver/fieldResolver.tsx +++ b/packages/sanity/src/core/form/studio/inputResolver/fieldResolver.tsx @@ -51,6 +51,7 @@ function PrimitiveField(field: FieldProps) { +/** @internal */ export function useFeatureEnabled(featureKey: string): Features { const versionedClient = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) diff --git a/packages/sanity/src/core/store/_legacy/project/types.ts b/packages/sanity/src/core/store/_legacy/project/types.ts index 15bf9709af0..760a45aacfa 100644 --- a/packages/sanity/src/core/store/_legacy/project/types.ts +++ b/packages/sanity/src/core/store/_legacy/project/types.ts @@ -1,3 +1,4 @@ +import {Role} from '@sanity/types' import {Observable} from 'rxjs' /** @@ -26,6 +27,7 @@ export interface ProjectData { isCurrentUser: boolean isRobot: boolean role: string + roles: Role[] }[] features: string[] pendingInvites: number diff --git a/packages/sanity/src/desk/comments/index.ts b/packages/sanity/src/desk/comments/index.ts new file mode 100644 index 00000000000..5490ada2b73 --- /dev/null +++ b/packages/sanity/src/desk/comments/index.ts @@ -0,0 +1,2 @@ +export * from './plugin' +export * from './src' diff --git a/packages/sanity/src/desk/comments/plugin/field/CommentField.tsx b/packages/sanity/src/desk/comments/plugin/field/CommentField.tsx new file mode 100644 index 00000000000..0815509d5b0 --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/field/CommentField.tsx @@ -0,0 +1,340 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {uuid} from '@sanity/uuid' +import * as PathUtils from '@sanity/util/paths' +import {PortableTextBlock} from '@sanity/types' +import {Stack, useBoundaryElement} from '@sanity/ui' +import styled, {css} from 'styled-components' +import scrollIntoViewIfNeeded, {Options} from 'scroll-into-view-if-needed' +import {useInView, motion, AnimatePresence, Variants} from 'framer-motion' +import {hues} from '@sanity/color' +import {COMMENTS_INSPECTOR_NAME} from '../../../panes/document/constants' +import {useDocumentPane} from '../../../panes/document/useDocumentPane' +import {useCommentsEnabled, useComments, CommentCreatePayload} from '../../src' +import {CommentFieldButton} from './CommentFieldButton' +import {FieldProps, getSchemaTypeTitle, useCurrentUser} from 'sanity' + +const HIGHLIGHT_BLOCK_VARIANTS: Variants = { + initial: { + opacity: 0, + }, + animate: { + opacity: 1, + }, + exit: { + opacity: 0, + }, +} + +export function CommentField(props: FieldProps) { + const {documentId, documentType} = useDocumentPane() + + const {isEnabled} = useCommentsEnabled({ + documentId, + documentType, + }) + + if (!isEnabled) { + return props.renderDefault(props) + } + + return +} + +const SCROLL_INTO_VIEW_OPTIONS: ScrollIntoViewOptions = { + behavior: 'smooth', + block: 'start', + inline: 'nearest', +} + +const HighlightDiv = styled(motion.div)(({theme}) => { + const {radius, space, color} = theme.sanity + const bg = hues.blue[color.dark ? 900 : 50].hex + + return css` + mix-blend-mode: ${color.dark ? 'screen' : 'multiply'}; + border-radius: ${radius[3]}px; + top: -${space[2]}px; + left: -${space[2]}px; + bottom: -${space[2]}px; + right: -${space[2]}px; + pointer-events: none; + position: absolute; + z-index: 1; + width: calc(100% + ${space[2] * 2}px); + height: calc(100% + ${space[2] * 2}px); + background-color: ${bg}; + ` +}) + +const FieldStack = styled(Stack)` + position: relative; +` + +function CommentFieldInner(props: FieldProps) { + const [open, setOpen] = useState(false) + const [value, setValue] = useState(null) + const [shouldScrollToThread, setShouldScrollToThread] = useState(false) + const rootElementRef = useRef(null) + + const {element: boundaryElement} = useBoundaryElement() + + const {openInspector, inspector} = useDocumentPane() + const currentUser = useCurrentUser() + const { + comments, + create, + isRunningSetup, + mentionOptions, + selectedPath, + setSelectedPath, + setStatus, + status, + } = useComments() + + const inView = useInView(rootElementRef) + + const fieldTitle = useMemo(() => getSchemaTypeTitle(props.schemaType), [props.schemaType]) + const currentComments = useMemo(() => comments.data[status], [comments.data, status]) + + const [shouldHighlight, setShouldHighlight] = useState(false) + + const commentsInspectorOpen = useMemo(() => { + return inspector?.name === COMMENTS_INSPECTOR_NAME + }, [inspector?.name]) + + // Determine if the current field is selected + const isSelected = useMemo(() => { + if (!commentsInspectorOpen) return false + if (selectedPath?.origin === 'field') return false + return selectedPath?.fieldPath === PathUtils.toString(props.path) + }, [commentsInspectorOpen, props.path, selectedPath?.fieldPath, selectedPath?.origin]) + + // Get the most recent thread ID for the current field. This is used to query the + // DOM for the thread in order to be able to scroll to it. + const currentThreadId = useMemo(() => { + const pathString = PathUtils.toString(props.path) + + return currentComments.find((comment) => comment.fieldPath === pathString)?.threadId + }, [currentComments, props.path]) + + // Total number of comments for the current field + const count = useMemo(() => { + const stringPath = PathUtils.toString(props.path) + + const commentsCount = comments.data.open + .map((c) => (c.fieldPath === stringPath ? c.commentsCount : 0)) + .reduce((acc, val) => acc + val, 0) + + return commentsCount || 0 + }, [comments.data.open, props.path]) + + const hasComments = Boolean(count > 0) + + // A function that scrolls to the thread group with the given ID + const handleScrollToThread = useCallback( + (threadId: string) => { + if (commentsInspectorOpen && shouldScrollToThread && threadId) { + const node = document.querySelector(`[data-group-id="${threadId}"]`) + + if (node) { + node.scrollIntoView(SCROLL_INTO_VIEW_OPTIONS) + setShouldScrollToThread(false) + } + } + }, + [shouldScrollToThread, commentsInspectorOpen], + ) + + const handleOpenInspector = useCallback( + () => openInspector(COMMENTS_INSPECTOR_NAME), + [openInspector], + ) + + const handleClick = useCallback(() => { + // Since the button in the field only reflects the number of open comments, we + // want to switch to open comments when the user clicks the button so that + // the code below can scroll to the thread. + if (hasComments && status === 'resolved') { + setStatus('open') + } + + if (hasComments) { + setOpen(false) + openInspector(COMMENTS_INSPECTOR_NAME) + } else { + setOpen((v) => !v) + } + + // If the field has comments, we want to open the inspector, scroll to the comment + // thread and set the path as selected so that the comment is highlighted when the + // user clicks the button. + if (currentThreadId) { + setShouldScrollToThread(true) + handleScrollToThread(currentThreadId) + setSelectedPath({ + fieldPath: PathUtils.toString(props.path), + origin: 'field', + threadId: null, + }) + } + }, [ + hasComments, + status, + currentThreadId, + setStatus, + openInspector, + handleScrollToThread, + setSelectedPath, + props.path, + ]) + + const handleCommentAdd = useCallback(() => { + if (value) { + // Since this is a new comment, we generate a new thread ID + const newThreadId = uuid() + + // Construct the comment payload + const nextComment: CommentCreatePayload = { + fieldPath: PathUtils.toString(props.path), + message: value, + parentCommentId: undefined, + status: 'open', + threadId: newThreadId, + } + + // Execute the create mutation + create.execute(nextComment) + + // If a comment is added to a field when viewing resolved comments, we switch + // to open comments and scroll to the comment that was just added + // Open the inspector when a new comment is added + handleOpenInspector() + + // Set the status to 'open' so that the comment is visible + setStatus('open') + + // Reset the value + setValue(null) + + // Enable scrolling to the thread and scroll to the thread. + // New comments appear at the top, however, the user may have scrolled down + // to read older comments. Therefore, we scroll up to the thread so that + // the user can see the new comment. + requestAnimationFrame(() => { + // Set the path as selected so that the new comment is highlighted + setSelectedPath({ + fieldPath: PathUtils.toString(props.path), + origin: 'field', + threadId: newThreadId, + }) + + setShouldScrollToThread(true) + handleScrollToThread(newThreadId) + }) + } + }, [ + create, + handleOpenInspector, + handleScrollToThread, + props.path, + setSelectedPath, + setStatus, + value, + ]) + + const handleDiscard = useCallback(() => setValue(null), []) + + useEffect(() => { + if (currentThreadId) { + handleScrollToThread(currentThreadId) + } + }, [currentThreadId, handleScrollToThread]) + + const scrollIntoViewIfNeededOpts = useMemo( + () => + ({ + ...SCROLL_INTO_VIEW_OPTIONS, + boundary: boundaryElement, + scrollMode: 'if-needed', + block: 'start', + }) satisfies Options, + [boundaryElement], + ) + + useEffect(() => { + // When the field is selected, we want to scroll it into + // view (if needed) and highlight it. + if (isSelected && rootElementRef.current) { + scrollIntoViewIfNeeded(rootElementRef.current, scrollIntoViewIfNeededOpts) + } + }, [boundaryElement, isSelected, props.path, scrollIntoViewIfNeededOpts, selectedPath]) + + useEffect(() => { + const showHighlight = inView && isSelected + + setShouldHighlight(showHighlight) + + const timer = setTimeout(() => { + setShouldHighlight(false) + }, 1200) + + return () => clearTimeout(timer) + }, [currentComments, inView, isSelected, props.path, selectedPath]) + + const internalComments: FieldProps['__internal_comments'] = useMemo( + () => ({ + button: currentUser && ( + + ), + hasComments, + isAddingComment: open, + }), + [ + currentUser, + count, + fieldTitle, + mentionOptions, + handleClick, + handleCommentAdd, + handleDiscard, + open, + value, + isRunningSetup, + hasComments, + ], + ) + + return ( + + + {shouldHighlight && ( + + )} + + + {props.renderDefault({ + ...props, + // eslint-disable-next-line camelcase + __internal_comments: internalComments, + })} + + ) +} diff --git a/packages/sanity/src/desk/comments/plugin/field/CommentFieldButton.tsx b/packages/sanity/src/desk/comments/plugin/field/CommentFieldButton.tsx new file mode 100644 index 00000000000..b29e6231649 --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/field/CommentFieldButton.tsx @@ -0,0 +1,206 @@ +import React, {useCallback, useMemo, useRef, useState} from 'react' +import { + Box, + Button, + Flex, + Popover, + Stack, + Text, + Tooltip, + TooltipProps, + useClickOutside, +} from '@sanity/ui' +import styled from 'styled-components' +import { + CommentMessage, + CommentInput, + CommentInputHandle, + hasCommentMessageValue, + AddCommentIcon, + CommentIcon, + MentionOptionsHookValue, +} from '../../src' +import {CurrentUser, PortableTextBlock} from 'sanity' + +const TOOLTIP_DELAY: TooltipProps['delay'] = {open: 500} + +const TooltipText = styled(Text)` + width: max-content; +` + +const ContentStack = styled(Stack)` + width: 320px; +` + +interface CommentFieldButtonProps { + count: number + currentUser: CurrentUser + fieldTitle: string + isRunningSetup: boolean + mentionOptions: MentionOptionsHookValue + onChange: (value: PortableTextBlock[]) => void + onClick?: () => void + onCommentAdd: () => void + onDiscard: () => void + onInputKeyDown?: (event: React.KeyboardEvent) => void + open: boolean + setOpen: (open: boolean) => void + value: CommentMessage +} + +export function CommentFieldButton(props: CommentFieldButtonProps) { + const { + count, + currentUser, + fieldTitle, + isRunningSetup, + mentionOptions, + onChange, + onClick, + onCommentAdd, + onDiscard, + onInputKeyDown, + open, + setOpen, + value, + } = props + const [popoverElement, setPopoverElement] = useState(null) + const commentInputHandle = useRef(null) + const hasComments = Boolean(count > 0) + + const closePopover = useCallback(() => setOpen(false), [setOpen]) + + const handleSubmit = useCallback(() => { + onCommentAdd() + closePopover() + }, [closePopover, onCommentAdd]) + + const hasValue = useMemo(() => hasCommentMessageValue(value), [value]) + + const startDiscard = useCallback(() => { + if (!hasValue) { + closePopover() + return + } + + commentInputHandle.current?.discardDialogController.open() + }, [closePopover, hasValue]) + + const handleInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // Don't act if the input already prevented this event + if (event.isDefaultPrevented()) { + return + } + // Discard the input text + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + startDiscard() + } + // Call parent handler + if (onInputKeyDown) onInputKeyDown(event) + }, + [onInputKeyDown, startDiscard], + ) + + const handleDiscardCancel = useCallback(() => { + commentInputHandle.current?.discardDialogController.close() + }, []) + + const handleDiscardConfirm = useCallback(() => { + commentInputHandle.current?.discardDialogController.close() + closePopover() + onDiscard() + }, [closePopover, onDiscard]) + + useClickOutside(startDiscard, [popoverElement]) + + const placeholder = ( + <> + Add comment to {fieldTitle} + + ) + + if (!hasComments) { + const content = ( + + + + ) + + return ( + +
+ + Add comment + + } + > +
+
+ ) + } + + return ( + + View comment{count > 1 ? 's' : ''} + + } + delay={TOOLTIP_DELAY} + fallbackPlacements={['bottom']} + > + + + ) +} diff --git a/packages/sanity/src/desk/comments/plugin/field/index.ts b/packages/sanity/src/desk/comments/plugin/field/index.ts new file mode 100644 index 00000000000..b2013088000 --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/field/index.ts @@ -0,0 +1 @@ +export * from './CommentField' diff --git a/packages/sanity/src/desk/comments/plugin/index.ts b/packages/sanity/src/desk/comments/plugin/index.ts new file mode 100644 index 00000000000..ac3853c1f37 --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/index.ts @@ -0,0 +1,21 @@ +import {commentsInspector} from './inspector' +import {CommentField} from './field' +import {CommentsLayout} from './layout' +import {definePlugin} from 'sanity' + +export const comments = definePlugin({ + name: 'sanity/desk/comments', + document: { + inspectors: [commentsInspector], + }, + form: { + components: { + field: CommentField, + }, + }, + studio: { + components: { + layout: CommentsLayout, + }, + }, +}) diff --git a/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspector.tsx b/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspector.tsx new file mode 100644 index 00000000000..2b697ff50a7 --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspector.tsx @@ -0,0 +1,296 @@ +import {Flex, useToast} from '@sanity/ui' +import React, {Fragment, useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {Path} from '@sanity/types' +import * as PathUtils from '@sanity/util/paths' +import {usePaneRouter} from '../../../components' +import {EMPTY_PARAMS} from '../../../constants' +import {useDocumentPane} from '../../../panes/document/useDocumentPane' +import { + useComments, + CommentsListHandle, + CommentCreatePayload, + CommentEditPayload, + CommentStatus, + CommentDeleteDialog, + CommentsList, + CommentsOnboardingPopover, + useCommentsOnboarding, +} from '../../src' +import {CommentsInspectorHeader} from './CommentsInspectorHeader' +import {DocumentInspectorProps, useCurrentUser, useUnique} from 'sanity' + +interface CommentToDelete { + commentId: string + isParent: boolean +} + +export function CommentsInspector(props: DocumentInspectorProps) { + const {onClose} = props + + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [commentToDelete, setCommentToDelete] = useState(null) + const [deleteLoading, setDeleteLoading] = useState(false) + const [deleteError, setDeleteError] = useState(null) + const commentsListHandleRef = useRef(null) + + const currentUser = useCurrentUser() + const {params, createPathWithParams, setParams} = usePaneRouter() + const uniqueParams = useUnique(params) || (EMPTY_PARAMS as Partial<{comment?: string}>) + const commentIdParamRef = useRef(uniqueParams?.comment) + + const pushToast = useToast().push + const {onPathOpen, ready} = useDocumentPane() + + const {isDismissed, setDismissed} = useCommentsOnboarding() + + const { + comments, + create, + edit, + getComment, + getCommentPath, + isRunningSetup, + mentionOptions, + remove, + selectedPath, + setSelectedPath, + setStatus, + status, + update, + } = useComments() + + const currentComments = useMemo(() => comments.data[status], [comments, status]) + + const loading = useMemo(() => { + // The comments and the document are loaded separately which means that + // the comments might be ready before the document is ready. Since the user should + // be able to interact with the document from the comments inspector, we need to make sure + // that the document is ready before we allow the user to interact with the comments. + return comments.loading || !ready + }, [comments.loading, ready]) + + const handleChangeView = useCallback( + (nextView: CommentStatus) => { + setStatus(nextView) + setSelectedPath(null) + }, + [setSelectedPath, setStatus], + ) + + const handleCloseInspector = useCallback(() => { + onClose() + setSelectedPath(null) + }, [onClose, setSelectedPath]) + + const handleCopyLink = useCallback( + (id: string) => { + const path = createPathWithParams({ + ...params, + comment: id, + }) + + const url = `${window.location.origin}${path}` + + navigator.clipboard + .writeText(url) + .then(() => { + pushToast({ + closable: true, + status: 'info', + title: 'Copied link to clipboard', + }) + }) + .catch(() => { + pushToast({ + closable: true, + status: 'error', + title: 'Unable to copy link to clipboard', + }) + }) + }, + [createPathWithParams, params, pushToast], + ) + + const handleCreateRetry = useCallback( + (id: string) => { + const comment = getComment(id) + if (!comment) return + + create.execute({ + fieldPath: comment.target.path.field, + id: comment._id, + message: comment.message, + parentCommentId: comment.parentCommentId, + status: comment.status, + threadId: comment.threadId, + }) + }, + [create, getComment], + ) + + const closeDeleteDialog = useCallback(() => { + if (deleteLoading) return + setShowDeleteDialog(false) + setCommentToDelete(null) + }, [deleteLoading]) + + const handlePathSelect = useCallback( + (path: Path, threadId?: string) => { + onPathOpen(path) + setSelectedPath({ + fieldPath: PathUtils.toString(path), + origin: 'inspector', + threadId: threadId || null, + }) + }, + [onPathOpen, setSelectedPath], + ) + + const handleNewThreadCreate = useCallback( + (payload: CommentCreatePayload) => { + create.execute(payload) + + setSelectedPath({ + fieldPath: payload.fieldPath, + origin: 'inspector', + threadId: payload.threadId, + }) + }, + [create, setSelectedPath], + ) + + const handleReply = useCallback( + (payload: CommentCreatePayload) => { + create.execute(payload) + }, + [create], + ) + + const handleEdit = useCallback( + (id: string, payload: CommentEditPayload) => { + edit.execute(id, payload) + }, + [edit], + ) + + const handleStatusChange = useCallback( + (id: string, nextStatus: CommentStatus) => { + update.execute(id, { + status: nextStatus, + }) + }, + [update], + ) + + const onDeleteStart = useCallback( + (id: string) => { + const parent = currentComments.find((c) => c.parentComment?._id === id) + const isParent = Boolean(parent && parent?.replies?.length > 0) + + setShowDeleteDialog(true) + setCommentToDelete({ + commentId: id, + isParent, + }) + }, + + [currentComments], + ) + + const handleDeleteConfirm = useCallback( + async (id: string) => { + try { + setDeleteLoading(true) + await remove.execute(id) + closeDeleteDialog() + } catch (err) { + setDeleteError(err) + } finally { + setDeleteLoading(false) + } + }, + [closeDeleteDialog, remove], + ) + + const handleScrollToComment = useCallback( + (id: string, fieldPath: string) => { + if (fieldPath) { + requestAnimationFrame(() => { + setSelectedPath({ + fieldPath, + origin: 'inspector', + threadId: null, + }) + + commentsListHandleRef.current?.scrollToComment(id) + + setParams({ + ...params, + comment: undefined, + }) + + commentIdParamRef.current = undefined + }) + } + }, + [params, setParams, setSelectedPath], + ) + + useEffect(() => { + const path = getCommentPath(commentIdParamRef.current || '') + + if (path && !loading && commentIdParamRef.current) { + handleScrollToComment(commentIdParamRef.current, path) + } + }, [getCommentPath, handleScrollToComment, loading]) + + return ( + + {commentToDelete && showDeleteDialog && ( + + )} + + + + + + + {currentUser && ( + + )} + + + ) +} diff --git a/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspectorHeader.tsx b/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspectorHeader.tsx new file mode 100644 index 00000000000..0e77925525b --- /dev/null +++ b/packages/sanity/src/desk/comments/plugin/inspector/CommentsInspectorHeader.tsx @@ -0,0 +1,95 @@ +import {CheckmarkIcon, ChevronDownIcon, DoubleChevronRightIcon} from '@sanity/icons' +import {Button, Card, Flex, Menu, MenuButton, MenuItem, Text} from '@sanity/ui' +import {startCase} from 'lodash' +import React, {forwardRef, useCallback} from 'react' +import styled from 'styled-components' +import {BetaBadge, CommentStatus} from '../../src' + +const Root = styled(Card)({ + position: 'relative', + zIndex: 1, + lineHeight: 0, + + '&:after': { + content: '""', + display: 'block', + position: 'absolute', + left: 0, + bottom: -1, + right: 0, + borderBottom: '1px solid var(--card-border-color)', + opacity: 0.5, + }, +}) + +interface CommentsInspectorHeaderProps { + onClose: () => void + onViewChange: (view: CommentStatus) => void + view: CommentStatus +} + +export const CommentsInspectorHeader = forwardRef(function CommentsInspectorHeader( + props: CommentsInspectorHeaderProps, + ref: React.ForwardedRef, +) { + const {onClose, onViewChange, view} = props + + const handleSetOpenView = useCallback(() => onViewChange('open'), [onViewChange]) + const handleSetResolvedView = useCallback(() => onViewChange('resolved'), [onViewChange]) + + return ( + + + + + Comments + + + + + + + + } + menu={ + + + + + } + popover={{placement: 'bottom-end'}} + /> + +