Skip to content

Commit

Permalink
fix: Parabol poker task overwrites, ignore prototypes for equality ch…
Browse files Browse the repository at this point in the history
…eck on tiptap (#10609)

Signed-off-by: Matt Krick <matt.krick@gmail.com>
  • Loading branch information
mattkrick authored Dec 16, 2024
1 parent a14a7ab commit 7a93bfc
Show file tree
Hide file tree
Showing 10 changed files with 95 additions and 56 deletions.
9 changes: 4 additions & 5 deletions packages/client/components/ParabolScopingSearchResultItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {PALETTE} from '~/styles/paletteV3'
import {ParabolScopingSearchResultItem_task$key} from '../__generated__/ParabolScopingSearchResultItem_task.graphql'
import {UpdatePokerScopeMutation as TUpdatePokerScopeMutation} from '../__generated__/UpdatePokerScopeMutation.graphql'
import {useTipTapTaskEditor} from '../hooks/useTipTapTaskEditor'
import {isEqualWhenSerialized} from '../shared/isEqualWhenSerialized'
import {Threshold} from '../types/constEnums'
import Checkbox from './Checkbox'
import {TipTapEditor} from './promptResponse/TipTapEditor'
Expand Down Expand Up @@ -105,13 +106,11 @@ const ParabolScopingSearchResultItem = (props: Props) => {
DeleteTaskMutation(atmosphere, {taskId: serviceTaskId})
return
}
const nextContent = JSON.stringify(editor.getJSON())
if (content === nextContent) {
return
}
const nextContentJSON = editor.getJSON()
if (isEqualWhenSerialized(nextContentJSON, JSON.parse(content))) return
const updatedTask = {
id: serviceTaskId,
content: nextContent
content: JSON.stringify(nextContentJSON)
}
UpdateTaskMutation(atmosphere, {updatedTask}, {})
}
Expand Down
21 changes: 14 additions & 7 deletions packages/client/components/PokerEstimateHeaderCardParabol.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import styled from '@emotion/styled'
import {useEventCallback} from '@mui/material'
import graphql from 'babel-plugin-relay/macro'
import {useState} from 'react'
import {useFragment} from 'react-relay'
Expand All @@ -11,6 +12,7 @@ import useAtmosphere from '../hooks/useAtmosphere'
import useTaskChildFocus from '../hooks/useTaskChildFocus'
import {useTipTapTaskEditor} from '../hooks/useTipTapTaskEditor'
import UpdateTaskMutation from '../mutations/UpdateTaskMutation'
import {isEqualWhenSerialized} from '../shared/isEqualWhenSerialized'
import CardButton from './CardButton'
import IconLabel from './IconLabel'
import {TipTapEditor} from './promptResponse/TipTapEditor'
Expand Down Expand Up @@ -78,17 +80,22 @@ const PokerEstimateHeaderCardParabol = (props: Props) => {
const isDesktop = useBreakpoint(Breakpoint.SIDEBAR_LEFT)
const {useTaskChild} = useTaskChildFocus(taskId)
const {teamId} = task
const {editor, linkState, setLinkState} = useTipTapTaskEditor(content, {atmosphere, teamId})
const onBlur = () => {
const onBlur = useEventCallback(() => {
if (!editor || editor.isEmpty) return
const nextContent = JSON.stringify(editor.getJSON())
if (content === nextContent) return
const nextContent = editor.getJSON()
if (isEqualWhenSerialized(nextContent, JSON.parse(content))) return
const updatedTask = {
id: taskId,
content: nextContent
content: JSON.stringify(nextContent)
}
UpdateTaskMutation(atmosphere, {updatedTask}, {})
}
})
const {editor, linkState, setLinkState} = useTipTapTaskEditor(content, {
atmosphere,
teamId,
onBlur
})

const toggleExpand = () => {
setIsExpanded((isExpanded) => !isExpanded)
}
Expand All @@ -98,7 +105,7 @@ const PokerEstimateHeaderCardParabol = (props: Props) => {
<HeaderCardWrapper isDesktop={isDesktop}>
<HeaderCard>
<Content>
<EditorWrapper isExpanded={isExpanded} onBlur={onBlur}>
<EditorWrapper isExpanded={isExpanded}>
<TipTapEditor
editor={editor}
linkState={linkState}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import styled from '@emotion/styled'
import {Link} from '@mui/icons-material'
import {Editor as EditorState} from '@tiptap/core'
import {Editor} from '@tiptap/core'
import {JSONContent} from '@tiptap/react'
import graphql from 'babel-plugin-relay/macro'
import {useMemo} from 'react'
Expand Down Expand Up @@ -182,12 +182,12 @@ const TeamPromptResponseCard = (props: Props) => {
const nonViewerEmptyResponsePlaceholder = isMeetingEnded ? 'No response' : 'No response yet...'

const {onError, onCompleted, submitMutation, submitting} = useMutationProps()
const handleSubmit = useEventCallback((editorState: EditorState) => {
const handleSubmit = useEventCallback((editor: Editor) => {
if (submitting) return
submitMutation()

const content = JSON.stringify(editorState.getJSON())
const plaintextContent = editorState.getText()
const content = JSON.stringify(editor.getJSON())
const plaintextContent = editor.getText()

UpsertTeamPromptResponseMutation(
atmosphere,
Expand Down
7 changes: 4 additions & 3 deletions packages/client/components/ThreadedCommentBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import UpdateCommentContentMutation from '~/mutations/UpdateCommentContentMutati
import isTempId from '~/utils/relay/isTempId'
import useEventCallback from '../hooks/useEventCallback'
import {useTipTapCommentEditor} from '../hooks/useTipTapCommentEditor'
import {isEqualWhenSerialized} from '../shared/isEqualWhenSerialized'
import anonymousAvatar from '../styles/theme/images/anonymous-avatar.svg'
import deletedAvatar from '../styles/theme/images/deleted-avatar-placeholder.svg'
import {PARABOL_AI_USER_ID} from '../utils/constants'
Expand Down Expand Up @@ -95,12 +96,12 @@ const ThreadedCommentBase = (props: Props) => {
const onSubmit = useEventCallback(() => {
if (submitting || isTempId(commentId) || !editor || editor.isEmpty) return
editor.setEditable(false)
const nextContent = JSON.stringify(editor.getJSON())
if (content === nextContent) return
const nextContentJSON = editor.getJSON()
if (isEqualWhenSerialized(nextContentJSON, JSON.parse(content))) return
submitMutation()
UpdateCommentContentMutation(
atmosphere,
{commentId, content: nextContent, meetingId},
{commentId, content: JSON.stringify(nextContentJSON), meetingId},
{onError, onCompleted}
)
})
Expand Down
38 changes: 18 additions & 20 deletions packages/client/components/promptResponse/PromptResponseEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import styled from '@emotion/styled'
import {Editor as EditorState} from '@tiptap/core'
import {Editor} from '@tiptap/core'
import Mention from '@tiptap/extension-mention'
import Placeholder from '@tiptap/extension-placeholder'
import {JSONContent, useEditor} from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import areEqual from 'fbjs/lib/areEqual'
import {useCallback, useEffect, useMemo, useState} from 'react'
import {PALETTE} from '~/styles/paletteV3'
import {Radius} from '~/types/constEnums'
import useAtmosphere from '../../hooks/useAtmosphere'
import {isEqualWhenSerialized} from '../../shared/isEqualWhenSerialized'
import {tiptapEmojiConfig} from '../../utils/tiptapEmojiConfig'
import {tiptapMentionConfig} from '../../utils/tiptapMentionConfig'
import BaseButton from '../BaseButton'
Expand Down Expand Up @@ -40,7 +40,7 @@ interface Props {
autoFocus?: boolean
teamId: string
content: JSONContent | null
handleSubmit?: (editor: EditorState) => void
handleSubmit?: (editor: Editor) => void
readOnly: boolean
placeholder?: string
draftStorageKey?: string
Expand Down Expand Up @@ -74,31 +74,29 @@ const PromptResponseEditor = (props: Props) => {
)

const onUpdate = useCallback(
({editor: editorState}: {editor: EditorState}) => {
({editor}: {editor: Editor}) => {
setEditing(true)
if (draftStorageKey) {
window.localStorage.setItem(draftStorageKey, JSON.stringify(editorState.getJSON()))
window.localStorage.setItem(draftStorageKey, JSON.stringify(editor.getJSON()))
}
},
[setEditing, draftStorageKey]
)

const onSubmit = useCallback(
(newEditorState: EditorState) => {
setEditing(false)
const newContent = newEditorState.getJSON()
const onSubmit = useCallback(() => {
if (!editor) return
setEditing(false)
const newContentJSON = editor.getJSON()

// to avoid creating an empty post on first blur
if (!content && newEditorState.isEmpty) return
// to avoid creating an empty post on first blur
if (!content && editor.isEmpty) return

if (areEqual(content, newContent)) return
if (isEqualWhenSerialized(content, newContentJSON)) return

handleSubmit?.(newEditorState)
},
[setEditing, content, handleSubmit]
)
handleSubmit?.(editor)
}, [setEditing, content, handleSubmit])

const onCancel = (editor: EditorState) => {
const onCancel = () => {
setEditing(false)
editor?.commands.setContent(content)
if (draftStorageKey) {
Expand Down Expand Up @@ -146,7 +144,7 @@ const PromptResponseEditor = (props: Props) => {
}

const draftContent: JSONContent = JSON.parse(maybeDraft)
if (areEqual(content, draftContent)) return
if (isEqualWhenSerialized(content, draftContent)) return

setEditing(true)
editor?.commands.setContent(draftContent)
Expand All @@ -166,13 +164,13 @@ const PromptResponseEditor = (props: Props) => {
// about it.
<div className='flex items-center justify-end'>
{!!content && isEditing && (
<CancelButton onClick={() => editor && onCancel(editor)} size='medium'>
<CancelButton onClick={() => onCancel()} size='medium'>
Cancel
</CancelButton>
)}
{(!content || isEditing) && (
<SubmitButton
onClick={() => editor && onSubmit(editor)}
onClick={() => onSubmit()}
size='medium'
disabled={!editor || editor.isEmpty}
>
Expand Down
15 changes: 7 additions & 8 deletions packages/client/hooks/useTipTapEditorContent.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
import {Editor, generateHTML, JSONContent} from '@tiptap/react'
import {Editor, JSONContent} from '@tiptap/react'
import {useMemo, useRef} from 'react'
import {serverTipTapExtensions} from '../shared/tiptap/serverTipTapExtensions'
import {isEqualWhenSerialized} from '../shared/isEqualWhenSerialized'

export const useTipTapEditorContent = (content: string) => {
const editorRef = useRef<Editor | null>(null)
const contentJSONRef = useRef<JSONContent>()
// When receiving new content, it's important to make sure it's different from the current value
// Unnecessary re-renders mess up things like the coordinates of the link menu
const contentJSON = useMemo(() => {
const newContent = JSON.parse(content)
const newContentJSON = JSON.parse(content) as JSONContent
// use HTML because text won't include data that we don't see (e.g. mentions) and JSON key order is non-deterministic >:-(
const oldHTML = editorRef.current ? editorRef.current.getHTML() : ''
const newHTML = generateHTML(newContent, serverTipTapExtensions)
if (oldHTML !== newHTML) {
contentJSONRef.current = newContent
const oldContentJSON = editorRef.current ? editorRef.current.getJSON() : {}
if (!isEqualWhenSerialized(newContentJSON, oldContentJSON)) {
contentJSONRef.current = newContentJSON
}
return contentJSONRef.current as JSONContent
return contentJSONRef.current!
}, [content])

return [contentJSON, editorRef] as const
Expand Down
8 changes: 6 additions & 2 deletions packages/client/hooks/useTipTapTaskEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ export const useTipTapTaskEditor = (
atmosphere?: Atmosphere
teamId?: string
readOnly?: boolean
// onBlur here vs. on the component means when the component mounts with new editor content
// (e.g. HeaderCard changes taskId) the onBlur won't fire, which is probably desireable
onBlur?: () => void
}
) => {
const {atmosphere, teamId, readOnly} = options
const {atmosphere, teamId, readOnly, onBlur} = options
const [contentJSON, editorRef] = useTipTapEditorContent(content)
const [linkState, setLinkState] = useState<LinkMenuState>(null)
editorRef.current = useEditor(
Expand All @@ -47,9 +50,10 @@ export const useTipTapTaskEditor = (
})
],
editable: !readOnly,
onBlur,
autofocus: generateText(contentJSON, serverTipTapExtensions).length === 0
},
[contentJSON, readOnly]
[contentJSON, readOnly, onBlur]
)
return {editor: editorRef.current, linkState, setLinkState}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import useMutationProps from '../../../../hooks/useMutationProps'
import {useTipTapIcebreakerEditor} from '../../../../hooks/useTipTapIcebreakerEditor'
import UpdateNewCheckInQuestionMutation from '../../../../mutations/UpdateNewCheckInQuestionMutation'
import {useModifyCheckInQuestionMutation} from '../../../../mutations/useModifyCheckInQuestionMutation'
import {isEqualWhenSerialized} from '../../../../shared/isEqualWhenSerialized'
import {convertTipTapTaskContent} from '../../../../shared/tiptap/convertTipTapTaskContent'
import {PALETTE} from '../../../../styles/paletteV3'
import {Button} from '../../../../ui/Button/Button'
Expand Down Expand Up @@ -115,13 +116,17 @@ const NewCheckInQuestion = (props: Props) => {
if (!editor) return
const {isFocused} = editor
if (!isFocused) {
const nextCheckInQuestion = JSON.stringify(editor.getJSON())
if (nextCheckInQuestion === checkInQuestion) return
const nextCheckInQuestionJSON = editor.getJSON()
if (
checkInQuestion &&
isEqualWhenSerialized(nextCheckInQuestionJSON, JSON.parse(checkInQuestion))
)
return
UpdateNewCheckInQuestionMutation(
atmosphere,
{
meetingId,
checkInQuestion: nextCheckInQuestion
checkInQuestion: JSON.stringify(nextCheckInQuestionJSON)
},
{onCompleted, onError}
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import styled from '@emotion/styled'
import {Editor} from '@tiptap/core'
import graphql from 'babel-plugin-relay/macro'
import areEqual from 'fbjs/lib/areEqual'
import {memo, useEffect, useRef, useState} from 'react'
import {useFragment} from 'react-relay'
import {OutcomeCardContainer_task$key} from '~/__generated__/OutcomeCardContainer_task.graphql'
Expand All @@ -14,6 +13,7 @@ import useAtmosphere from '../../../../hooks/useAtmosphere'
import useTaskChildFocus from '../../../../hooks/useTaskChildFocus'
import DeleteTaskMutation from '../../../../mutations/DeleteTaskMutation'
import UpdateTaskMutation from '../../../../mutations/UpdateTaskMutation'
import {isEqualWhenSerialized} from '../../../../shared/isEqualWhenSerialized'
import OutcomeCard from '../../components/OutcomeCard/OutcomeCard'

const Wrapper = styled('div')({
Expand Down Expand Up @@ -85,11 +85,11 @@ const OutcomeCardContainer = memo((props: Props) => {
DeleteTaskMutation(atmosphere, {taskId})
return
}
const nextContent = JSON.stringify(editor.getJSON())
if (areEqual(JSON.parse(content), editor.getJSON())) return
const nextContentJSON = editor.getJSON()
if (isEqualWhenSerialized(JSON.parse(content), nextContentJSON)) return
const updatedTask = {
id: taskId,
content: nextContent
content: JSON.stringify(nextContentJSON)
}
UpdateTaskMutation(atmosphere, {updatedTask, area}, {})
}
Expand Down
26 changes: 26 additions & 0 deletions packages/client/shared/isEqualWhenSerialized.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Checks equality ignoring object order & prototypes
export const isEqualWhenSerialized = (value1: unknown, value2: unknown): boolean => {
// Check for strict equality first
if (value1 === value2) return true

// Handle cases where one is null or undefined
if (value1 === null || value2 === null || value1 === undefined || value2 === undefined)
return false

// Fallback for other types (like functions, symbols, etc.)
if (typeof value1 !== 'object' || typeof value2 !== 'object') return false

const keys1 = Object.keys(value1)
const keys2 = Object.keys(value2)

// Check if both have the same number of keys
if (keys1.length !== keys2.length) return false

// Check if both have the same keys
if (!keys1.every((key) => keys2.includes(key))) return false

// Recursively check each key's value
return keys1.every((key) =>
isEqualWhenSerialized(value1[key as keyof typeof value1], value2[key as keyof typeof value2])
)
}

0 comments on commit 7a93bfc

Please sign in to comment.