diff --git a/packages/client/components/MentionDropdown.tsx b/packages/client/components/MentionDropdown.tsx index 5c3c5d3732a..49d0a0c57ec 100644 --- a/packages/client/components/MentionDropdown.tsx +++ b/packages/client/components/MentionDropdown.tsx @@ -6,7 +6,10 @@ import TypeAheadLabel from './TypeAheadLabel' export default forwardRef( ( - props: SuggestionProps<{id: string; preferredName: string; picture: string}, MentionNodeAttrs>, + props: SuggestionProps< + {userId: string; preferredName: string; picture: string}, + MentionNodeAttrs + >, ref ) => { const {command, items, query} = props @@ -14,7 +17,7 @@ export default forwardRef( const selectItem = (idx: number) => { const item = items[idx] if (!item) return - command({id: item.id, label: item.preferredName}) + command({id: item.userId, label: item.preferredName}) } const upHandler = () => { @@ -60,7 +63,7 @@ export default forwardRef( className={ 'flex w-full cursor-pointer items-center rounded-md px-4 py-1 text-sm leading-8 text-slate-700 outline-none hover:!bg-slate-200 hover:text-slate-900 focus:bg-slate-200 data-highlighted:bg-slate-100 data-highlighted:text-slate-900' } - key={item.id} + key={item.userId} onClick={() => selectItem(idx)} > diff --git a/packages/client/components/NewAzureIssueInput.tsx b/packages/client/components/NewAzureIssueInput.tsx index 624dfdfbda8..7d26ea24dcf 100644 --- a/packages/client/components/NewAzureIssueInput.tsx +++ b/packages/client/components/NewAzureIssueInput.tsx @@ -16,8 +16,8 @@ import useForm from '../hooks/useForm' import {PortalStatus} from '../hooks/usePortal' import useTimedState from '../hooks/useTimedState' import UpdatePokerScopeMutation from '../mutations/UpdatePokerScopeMutation' +import {convertTipTapTaskContent} from '../shared/tiptap/convertTipTapTaskContent' import {CompletedHandler} from '../types/relayMutations' -import convertToTaskContent from '../utils/draftjs/convertToTaskContent' import Legitity from '../validation/Legitity' import Checkbox from './Checkbox' import NewAzureIssueMenu from './NewAzureIssueMenu' @@ -187,7 +187,7 @@ const NewAzureIssueInput = (props: Props) => { teamId, userId, meetingId, - content: convertToTaskContent(`${newIssueTitle} #archived`), + content: convertTipTapTaskContent(newIssueTitle, ['archived']), plaintextContent: newIssueTitle, status: 'active' as const, integration: { diff --git a/packages/client/components/NewGitHubIssueInput.tsx b/packages/client/components/NewGitHubIssueInput.tsx index 0e760e58075..081c35f618d 100644 --- a/packages/client/components/NewGitHubIssueInput.tsx +++ b/packages/client/components/NewGitHubIssueInput.tsx @@ -18,8 +18,8 @@ import useTimedState from '../hooks/useTimedState' import CreateTaskMutation from '../mutations/CreateTaskMutation' import UpdatePokerScopeMutation from '../mutations/UpdatePokerScopeMutation' import GitHubIssueId from '../shared/gqlIds/GitHubIssueId' +import {convertTipTapTaskContent} from '../shared/tiptap/convertTipTapTaskContent' import {CompletedHandler} from '../types/relayMutations' -import convertToTaskContent from '../utils/draftjs/convertToTaskContent' import Legitity from '../validation/Legitity' import Checkbox from './Checkbox' import NewGitHubIssueMenu from './NewGitHubIssueMenu' @@ -187,7 +187,7 @@ const NewGitHubIssueInput = (props: Props) => { teamId, userId, meetingId, - content: convertToTaskContent(`${newIssueTitle} #archived`), + content: convertTipTapTaskContent(newIssueTitle, ['archived']), plaintextContent: newIssueTitle, status: 'active' as const, integration: { diff --git a/packages/client/components/NewGitLabIssueInput.tsx b/packages/client/components/NewGitLabIssueInput.tsx index 34ad73f9243..a48ff4a7b1e 100644 --- a/packages/client/components/NewGitLabIssueInput.tsx +++ b/packages/client/components/NewGitLabIssueInput.tsx @@ -16,8 +16,8 @@ import {PortalStatus} from '../hooks/usePortal' import useTimedState from '../hooks/useTimedState' import CreateTaskMutation from '../mutations/CreateTaskMutation' import UpdatePokerScopeMutation from '../mutations/UpdatePokerScopeMutation' +import {convertTipTapTaskContent} from '../shared/tiptap/convertTipTapTaskContent' import {CompletedHandler} from '../types/relayMutations' -import convertToTaskContent from '../utils/draftjs/convertToTaskContent' import Legitity from '../validation/Legitity' import Checkbox from './Checkbox' import NewGitLabIssueMenu from './NewGitLabIssueMenu' @@ -201,7 +201,7 @@ const NewGitLabIssueInput = (props: Props) => { teamId, userId, meetingId, - content: convertToTaskContent(`${newIssueTitle} #archived`), + content: convertTipTapTaskContent(newIssueTitle, ['archived']), plaintextContent: newIssueTitle, status: 'active' as const, integration: { diff --git a/packages/client/components/NewJiraIssueInput.tsx b/packages/client/components/NewJiraIssueInput.tsx index f3c0de103ba..2fd44f3ce3b 100644 --- a/packages/client/components/NewJiraIssueInput.tsx +++ b/packages/client/components/NewJiraIssueInput.tsx @@ -17,8 +17,8 @@ import CreateTaskMutation from '../mutations/CreateTaskMutation' import UpdatePokerScopeMutation from '../mutations/UpdatePokerScopeMutation' import JiraIssueId from '../shared/gqlIds/JiraIssueId' import JiraProjectId from '../shared/gqlIds/JiraProjectId' +import {convertTipTapTaskContent} from '../shared/tiptap/convertTipTapTaskContent' import {CompletedHandler} from '../types/relayMutations' -import convertToTaskContent from '../utils/draftjs/convertToTaskContent' import Legitity from '../validation/Legitity' import Checkbox from './Checkbox' import NewJiraIssueMenu from './NewJiraIssueMenu' @@ -199,7 +199,7 @@ const NewJiraIssueInput = (props: Props) => { teamId, userId, meetingId, - content: convertToTaskContent(`${newIssueTitle} #archived`), + content: convertTipTapTaskContent(newIssueTitle, ['archived']), plaintextContent: newIssueTitle, status: 'active' as const, integration: { diff --git a/packages/client/components/NullableTask/NullableTask.tsx b/packages/client/components/NullableTask/NullableTask.tsx index 6ef2353f43a..bffff47fba6 100644 --- a/packages/client/components/NullableTask/NullableTask.tsx +++ b/packages/client/components/NullableTask/NullableTask.tsx @@ -1,12 +1,12 @@ import graphql from 'babel-plugin-relay/macro' -import {convertFromRaw} from 'draft-js' -import {useMemo} from 'react' import {useFragment} from 'react-relay' import {AreaEnum, TaskStatusEnum} from '~/__generated__/UpdateTaskMutation.graphql' import {NullableTask_task$key} from '../../__generated__/NullableTask_task.graphql' import useAtmosphere from '../../hooks/useAtmosphere' +import {useTipTapTaskEditor} from '../../hooks/useTipTapTaskEditor' import OutcomeCardContainer from '../../modules/outcomeCard/containers/OutcomeCard/OutcomeCardContainer' -import makeEmptyStr from '../../utils/draftjs/makeEmptyStr' +import isTaskArchived from '../../utils/isTaskArchived' +import isTempId from '../../utils/relay/isTempId' import NullCard from '../NullCard/NullCard' interface Props { @@ -36,6 +36,7 @@ const NullableTask = (props: Props) => { # from this place upward the tree, the task components are also used outside of meetings, thus we default to null here fragment NullableTask_task on Task @argumentDefinitions(meetingId: {type: "ID", defaultValue: null}) { + id content createdBy createdByUser { @@ -45,30 +46,35 @@ const NullableTask = (props: Props) => { __typename } status + teamId + tags ...OutcomeCardContainer_task @arguments(meetingId: $meetingId) } `, taskRef ) - const {content, createdBy, createdByUser, integration} = task + const {content, createdBy, createdByUser, integration, teamId, id: taskId, tags} = task + const isIntegration = !!integration?.__typename const {preferredName} = createdByUser - const contentState = useMemo(() => { - try { - return convertFromRaw(JSON.parse(content)) - } catch (e) { - return convertFromRaw(JSON.parse(makeEmptyStr())) - } - }, [content]) - const atmosphere = useAtmosphere() + const isArchived = isTaskArchived(tags) + const readOnly = isTempId(taskId) || isArchived || !!isDraggingOver || isIntegration + const {editor, linkState, setLinkState} = useTipTapTaskEditor(content, { + atmosphere, + teamId, + readOnly + }) - const showOutcome = contentState.hasText() || createdBy === atmosphere.viewerId || integration + const showOutcome = + editor && (!editor.isEmpty || createdBy === atmosphere.viewerId || isIntegration) return showOutcome ? ( (({isEditingThisItem}) => ({ backgroundColor: isEditingThisItem ? PALETTE.SLATE_100 : 'transparent', @@ -34,14 +30,6 @@ const Task = styled('div')({ width: '100%' }) -const StyledTaskEditor = styled(TaskEditor)({ - width: '100%', - paddingTop: 4, - fontSize: '16px', - lineHeight: 'normal', - height: 'auto' -}) - interface Props { meetingId: string usedServiceTaskIds: Set @@ -80,8 +68,7 @@ const ParabolScopingSearchResultItem = (props: Props) => { const disabled = !isSelected && usedServiceTaskIds.size >= Threshold.MAX_POKER_STORIES const atmosphere = useAtmosphere() const {onCompleted, onError, submitMutation, submitting} = useMutationProps() - const [editorState, setEditorState] = useEditorState(content) - const editorRef = useRef(null) + const {editor, linkState, setLinkState} = useTipTapTaskEditor(content, {atmosphere, teamId}) const {useTaskChild, addTaskChild, removeTaskChild, isTaskFocused} = useTaskChildFocus(serviceTaskId) const isEditingThisItem = !plaintextContent @@ -107,44 +94,26 @@ const ParabolScopingSearchResultItem = (props: Props) => { } const handleTaskUpdate = () => { + if (!editor) return const isFocused = isTaskFocused() - const area: AreaEnum = 'meeting' - if (isAndroid) { - const editorEl = editorRef.current - if (!editorEl || editorEl.type !== 'textarea') return - const {value} = editorEl - if (!value && !isFocused) { - DeleteTaskMutation(atmosphere, {taskId: serviceTaskId}) - } else { - const initialContentState = editorState.getCurrentContent() - const initialText = initialContentState.getPlainText() - if (initialText === value) return - const updatedTask = { - id: serviceTaskId, - content: convertToTaskContent(value) - } - UpdateTaskMutation(atmosphere, {updatedTask, area}, {onCompleted: updatePokerScope}) - } + if (editor.isEmpty && !isFocused) { + DeleteTaskMutation(atmosphere, {taskId: serviceTaskId}) return } - const nextContentState = editorState.getCurrentContent() - const hasText = nextContentState.hasText() - if (!hasText && !isFocused) { - DeleteTaskMutation(atmosphere, {taskId: serviceTaskId}) - } else { - const nextContent = JSON.stringify(convertToRaw(nextContentState)) - if (nextContent === content) return - const updatedTask = { - id: serviceTaskId, - content: nextContent - } - UpdateTaskMutation(atmosphere, {updatedTask, area}, {onCompleted: updatePokerScope}) + const nextContent = JSON.stringify(editor.getJSON()) + if (content === nextContent) { + return } + const updatedTask = { + id: serviceTaskId, + content: nextContent + } + UpdateTaskMutation(atmosphere, {updatedTask}, {}) } const ref = useRef(null) useScrollIntoView(ref, isEditingThisItem) - + if (!editor) return null return ( { @@ -167,14 +136,11 @@ const ParabolScopingSearchResultItem = (props: Props) => { addTaskChild('root') }} > - useTaskChild('editor-link-changer')} /> diff --git a/packages/client/components/PokerEstimateHeaderCardParabol.tsx b/packages/client/components/PokerEstimateHeaderCardParabol.tsx index 2d7de26b41d..81a9534e395 100644 --- a/packages/client/components/PokerEstimateHeaderCardParabol.tsx +++ b/packages/client/components/PokerEstimateHeaderCardParabol.tsx @@ -1,22 +1,19 @@ import styled from '@emotion/styled' import graphql from 'babel-plugin-relay/macro' -import {convertToRaw} from 'draft-js' -import {useRef, useState} from 'react' +import {useState} from 'react' import {useFragment} from 'react-relay' import useBreakpoint from '~/hooks/useBreakpoint' -import useEditorState from '~/hooks/useEditorState' -import useTaskChildFocus from '~/hooks/useTaskChildFocus' import {Elevation} from '~/styles/elevation' import {PALETTE} from '~/styles/paletteV3' import {Breakpoint} from '~/types/constEnums' -import isAndroid from '~/utils/draftjs/isAndroid' import {PokerEstimateHeaderCardParabol_task$key} from '../__generated__/PokerEstimateHeaderCardParabol_task.graphql' import useAtmosphere from '../hooks/useAtmosphere' +import useTaskChildFocus from '../hooks/useTaskChildFocus' +import {useTipTapTaskEditor} from '../hooks/useTipTapTaskEditor' import UpdateTaskMutation from '../mutations/UpdateTaskMutation' -import convertToTaskContent from '../utils/draftjs/convertToTaskContent' import CardButton from './CardButton' import IconLabel from './IconLabel' -import TaskEditor from './TaskEditor/TaskEditor' +import {TipTapEditor} from './promptResponse/TipTapEditor' const HeaderCardWrapper = styled('div')<{isDesktop: boolean}>(({isDesktop}) => ({ display: 'flex', @@ -52,13 +49,6 @@ const EditorWrapper = styled('div')<{isExpanded: boolean}>(({isExpanded}) => ({ transition: 'all 300ms' })) -const StyledTaskEditor = styled(TaskEditor)({ - width: '100%', - padding: '0 0', - lineHeight: 'normal', - height: 'auto' -}) - const Content = styled('div')({ flex: 1, paddingRight: 4 @@ -86,54 +76,34 @@ const PokerEstimateHeaderCardParabol = (props: Props) => { const atmosphere = useAtmosphere() const [isExpanded, setIsExpanded] = useState(true) const isDesktop = useBreakpoint(Breakpoint.SIDEBAR_LEFT) - const [editorState, setEditorState] = useEditorState(content) - const editorRef = useRef(null) const {useTaskChild} = useTaskChildFocus(taskId) - const {teamId} = task + const {editor, linkState, setLinkState} = useTipTapTaskEditor(content, {atmosphere, teamId}) const onBlur = () => { - if (isAndroid) { - const editorEl = editorRef.current - if (!editorEl || editorEl.type !== 'textarea') return - const {value} = editorEl - if (!value) return - const initialContentState = editorState.getCurrentContent() - const initialText = initialContentState.getPlainText() - if (initialText === value) return - const updatedTask = { - id: taskId, - content: convertToTaskContent(value) - } - UpdateTaskMutation(atmosphere, {updatedTask, area: 'meeting'}, {}) - return - } - const nextContentState = editorState.getCurrentContent() - const hasText = nextContentState.hasText() - if (!hasText) return - const nextContent = JSON.stringify(convertToRaw(nextContentState)) - if (nextContent === content) return + if (!editor || editor.isEmpty) return + const nextContent = JSON.stringify(editor.getJSON()) + if (content === nextContent) return const updatedTask = { id: taskId, content: nextContent } - UpdateTaskMutation(atmosphere, {updatedTask, area: 'meeting'}, {}) + UpdateTaskMutation(atmosphere, {updatedTask}, {}) } const toggleExpand = () => { setIsExpanded((isExpanded) => !isExpanded) } + if (!editor) return null return ( - useTaskChild('editor-link-changer')} /> diff --git a/packages/client/components/TaskEditor/TaskEditor.tsx b/packages/client/components/TaskEditor/TaskEditor.tsx deleted file mode 100644 index 1626bc98a17..00000000000 --- a/packages/client/components/TaskEditor/TaskEditor.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import styled from '@emotion/styled' -import { - DraftEditorCommand, - DraftHandleValue, - Editor, - EditorProps, - EditorState, - getDefaultKeyBinding -} from 'draft-js' -import * as React from 'react' -import {RefObject, Suspense, useEffect, useRef} from 'react' -import completeEntity from '~/utils/draftjs/completeEntity' -import linkify from '~/utils/linkify' -import {UseTaskChild} from '../../hooks/useTaskChildFocus' -import {AriaLabels, Card} from '../../types/constEnums' -import {textTags} from '../../utils/constants' -import entitizeText from '../../utils/draftjs/entitizeText' -import isAndroid from '../../utils/draftjs/isAndroid' -import isRichDraft from '../../utils/draftjs/isRichDraft' -import lazyPreload from '../../utils/lazyPreload' -import './Draft.css' -import blockStyleFn from './blockStyleFn' -import useTaskPlugins from './useTaskPlugins' - -const RootEditor = styled('div')<{noText: boolean; readOnly: boolean | undefined}>( - ({noText, readOnly}) => ({ - cursor: readOnly ? undefined : 'text', - fontSize: Card.FONT_SIZE, - lineHeight: Card.LINE_HEIGHT, - padding: `0 ${Card.PADDING}`, - height: noText ? '2.75rem' : undefined // Use this if the placeholder wraps - }) -) - -const AndroidEditorFallback = lazyPreload( - () => import(/* webpackChunkName: 'AndroidEditorFallback' */ '../AndroidEditorFallback') -) - -const TaskEditorFallback = styled(AndroidEditorFallback)({ - padding: 0 -}) - -type DraftProps = Pick< - EditorProps, - | 'editorState' - | 'handleBeforeInput' - | 'handleKeyCommand' - | 'handleReturn' - | 'keyBindingFn' - | 'readOnly' -> - -interface Props extends DraftProps { - editorRef: RefObject - setEditorState: (newEditorState: EditorState) => void - teamId: string - useTaskChild: UseTaskChild - dataCy: string - className?: string -} - -const TaskEditor = (props: Props) => { - const {editorRef, editorState, readOnly, setEditorState, dataCy, className} = props - const entityPasteStartRef = useRef<{anchorOffset: number; anchorKey: string} | undefined>() - const { - removeModal, - renderModal, - handleChange, - handleBeforeInput, - handleKeyCommand, - keyBindingFn, - handleReturn - } = useTaskPlugins({...props}) - - useEffect(() => { - if (!editorState.getCurrentContent().hasText()) { - editorRef.current && editorRef.current.focus() - } - }, []) - - const onRemoveModal = () => { - if (removeModal) { - removeModal() - } - } - - const onChange = (editorState: EditorState) => { - const {current: entityPasteStart} = entityPasteStartRef - if (entityPasteStart) { - const {anchorOffset, anchorKey} = entityPasteStart - const selectionState = editorState.getSelection().merge({ - anchorOffset, - anchorKey - }) - const contentState = entitizeText(editorState.getCurrentContent(), selectionState) - entityPasteStartRef.current = undefined - if (contentState) { - setEditorState(EditorState.push(editorState, contentState, 'apply-entity')) - return - } - } - if (!editorState.getSelection().getHasFocus()) { - onRemoveModal() - } else if (handleChange) { - handleChange(editorState) - } - setEditorState(editorState) - } - - const onReturn: EditorProps['handleReturn'] = (e) => { - if (handleReturn) { - return handleReturn(e, editorState) - } - if (!e.shiftKey && !renderModal) { - editorRef.current && editorRef.current.blur() - return 'handled' - } - return 'not-handled' - } - - const onKeyDownFallback: React.KeyboardEventHandler = (e) => { - if (e.key !== 'Enter' || e.shiftKey) return - e.preventDefault() - editorRef.current && editorRef.current.blur() - } - - const nextKeyCommand = (command: DraftEditorCommand) => { - if (handleKeyCommand) { - return handleKeyCommand(command, editorState, Date.now()) - } - return 'not-handled' - } - - const onKeyBindingFn: EditorProps['keyBindingFn'] = (e) => { - if (keyBindingFn) { - const result = keyBindingFn(e) - if (result) { - return result - } - } - if (e.key === 'Escape') { - e.preventDefault() - onRemoveModal() - return 'not-handled' - } - return getDefaultKeyBinding(e) - } - - const onBeforeInput = (char: string) => { - if (handleBeforeInput) { - return handleBeforeInput(char, editorState, Date.now()) - } - return 'not-handled' - } - - const onPastedText = (text: string): DraftHandleValue => { - if (text) { - textTags.forEach((tag) => { - if (text.indexOf(tag) !== -1) { - const selection = editorState.getSelection() - entityPasteStartRef.current = { - anchorOffset: selection.getAnchorOffset(), - anchorKey: selection.getAnchorKey() - } - } - }) - } - const links = linkify.match(text) - const url = links && links[0]!.url.trim() - const trimmedText = text.trim() - if (url === trimmedText) { - const nextEditorState = completeEntity(editorState, 'LINK', {href: url}, trimmedText, { - keepSelection: true - }) - setEditorState(nextEditorState) - return 'handled' - } - return 'not-handled' - } - - const noText = !editorState.getCurrentContent().hasText() - const placeholder = 'Describe what “Done” looks like' - const useFallback = isAndroid && !readOnly - const showFallback = useFallback && !isRichDraft(editorState) - return ( - - {showFallback ? ( - }> - - - ) : ( - - )} - {renderModal && renderModal()} - - ) -} - -export default TaskEditor diff --git a/packages/client/components/TaskEditor/useTaskPlugins.ts b/packages/client/components/TaskEditor/useTaskPlugins.ts deleted file mode 100644 index f84fcb4ac45..00000000000 --- a/packages/client/components/TaskEditor/useTaskPlugins.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {EditorProps, EditorState} from 'draft-js' -import {RefObject} from 'react' -import useKeyboardShortcuts from '../../hooks/useKeyboardShortcuts' -import useMarkdown from '../../hooks/useMarkdown' -import {UseTaskChild} from '../../hooks/useTaskChildFocus' -import {SetEditorState} from '../../types/draft' -import useEmojis from './useEmojis' -import useLinks from './useLinks' -import useSuggestions from './useSuggestions' - -type Handlers = Pick< - EditorProps, - 'handleKeyCommand' | 'keyBindingFn' | 'handleBeforeInput' | 'handleReturn' -> - -interface CustomProps { - editorState: EditorState - setEditorState: SetEditorState - editorRef: RefObject - teamId: string - useTaskChild: UseTaskChild -} - -type Props = Handlers & CustomProps - -const useTaskPlugins = (props: Props) => { - const { - editorState, - handleReturn, - keyBindingFn, - handleKeyCommand, - handleBeforeInput, - setEditorState, - editorRef, - useTaskChild, - teamId - } = props - const ks = useKeyboardShortcuts(editorState, setEditorState, {handleKeyCommand, keyBindingFn}) - const md = useMarkdown(editorState, setEditorState, { - handleKeyCommand: ks.handleKeyCommand, - keyBindingFn: ks.keyBindingFn, - handleBeforeInput - }) - const sug = useSuggestions(editorState, setEditorState, { - handleReturn, - teamId, - keyBindingFn: md.keyBindingFn, - onChange: md.onChange - }) - const emoji = useEmojis(editorState, setEditorState, { - keyBindingFn: sug.keyBindingFn, - renderModal: sug.renderModal, - removeModal: sug.removeModal, - onChange: sug.onChange - }) - const lnk = useLinks(editorState, setEditorState, { - onChange: emoji.onChange, - keyBindingFn: emoji.keyBindingFn, - handleBeforeInput: md.handleBeforeInput, - handleKeyCommand: md.handleKeyCommand, - removeModal: emoji.removeModal, - renderModal: emoji.renderModal, - editorRef, - useTaskChild - }) - - return { - handleReturn: sug.handleReturn, - ...lnk - } -} - -export default useTaskPlugins diff --git a/packages/client/components/TaskInvolves.tsx b/packages/client/components/TaskInvolves.tsx index 9fa622a1459..8493f5a3e40 100644 --- a/packages/client/components/TaskInvolves.tsx +++ b/packages/client/components/TaskInvolves.tsx @@ -1,19 +1,19 @@ import styled from '@emotion/styled' import graphql from 'babel-plugin-relay/macro' -import {Editor} from 'draft-js' import {useFragment} from 'react-relay' import NotificationAction from '~/components/NotificationAction' import OutcomeCardStatusIndicator from '~/modules/outcomeCard/components/OutcomeCardStatusIndicator/OutcomeCardStatusIndicator' import {cardShadow} from '~/styles/elevation' -import convertToTaskContent from '~/utils/draftjs/convertToTaskContent' import {TaskInvolves_notification$key} from '../__generated__/TaskInvolves_notification.graphql' import useAtmosphere from '../hooks/useAtmosphere' -import useEditorState from '../hooks/useEditorState' import useMutationProps from '../hooks/useMutationProps' import useRouter from '../hooks/useRouter' +import {useTipTapTaskEditor} from '../hooks/useTipTapTaskEditor' import SetNotificationStatusMutation from '../mutations/SetNotificationStatusMutation' +import {convertTipTapTaskContent} from '../shared/tiptap/convertTipTapTaskContent' import {ASSIGNEE, MENTIONEE} from '../utils/constants' import NotificationTemplate from './NotificationTemplate' +import {TipTapEditor} from './promptResponse/TipTapEditor' const involvementWord = { [ASSIGNEE]: 'assigned', @@ -58,7 +58,7 @@ interface Props { } const deletedTask = { - content: convertToTaskContent('<>'), + content: convertTipTapTaskContent('<>'), status: 'done', tags: [] as string[], user: { @@ -103,9 +103,9 @@ const TaskInvolves = (props: Props) => { const {picture: changeAuthorPicture, preferredName: changeAuthorName} = changeAuthor const {name: teamName, id: teamId} = team const action = involvementWord[involvement] - const [editorState] = useEditorState(content) const {submitMutation, onCompleted, onError, submitting} = useMutationProps() const atmosphere = useAtmosphere() + const {editor} = useTipTapTaskEditor(content, {readOnly: true}) const {history} = useRouter() const gotoBoard = () => { @@ -120,6 +120,7 @@ const TaskInvolves = (props: Props) => { history.push(`/team/${teamId}${archiveSuffix}`) } const preposition = involvement === MENTIONEE ? ' in' : '' + if (!editor) return null return ( { {tags.includes('private') && } {tags.includes('archived') && } - { - /*noop*/ - }} - /> + {user?.preferredName || changeAuthorName} diff --git a/packages/client/components/TaskTagDropdown.tsx b/packages/client/components/TaskTagDropdown.tsx index d55a87d4fbf..99a97c01269 100644 --- a/packages/client/components/TaskTagDropdown.tsx +++ b/packages/client/components/TaskTagDropdown.tsx @@ -11,7 +11,7 @@ export const TaskTagDropdown = forwardRef( const selectItem = (idx: number) => { const item = items[idx] if (!item) return - command(item) + command({id: item.id}) } const upHandler = () => { diff --git a/packages/client/components/promptResponse/BubbleMenuButton.tsx b/packages/client/components/promptResponse/BubbleMenuButton.tsx new file mode 100644 index 00000000000..fe06b468cbd --- /dev/null +++ b/packages/client/components/promptResponse/BubbleMenuButton.tsx @@ -0,0 +1,20 @@ +import {ReactNode} from 'react' +import {Button} from '../../ui/Button/Button' + +interface Props extends React.ButtonHTMLAttributes { + isActive?: boolean + children: ReactNode +} +export const BubbleMenuButton = (props: Props) => { + const {children, isActive, ...rest} = props + return ( + + ) +} diff --git a/packages/client/components/promptResponse/PromptResponseEditor.tsx b/packages/client/components/promptResponse/PromptResponseEditor.tsx index 04a8c4a20e2..f033f90c047 100644 --- a/packages/client/components/promptResponse/PromptResponseEditor.tsx +++ b/packages/client/components/promptResponse/PromptResponseEditor.tsx @@ -1,54 +1,21 @@ import styled from '@emotion/styled' -import {Link} from '@mui/icons-material' import {Editor as EditorState} from '@tiptap/core' import Mention from '@tiptap/extension-mention' import Placeholder from '@tiptap/extension-placeholder' -import {BubbleMenu, EditorContent, JSONContent, useEditor} from '@tiptap/react' +import {JSONContent, useEditor} from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import areEqual from 'fbjs/lib/areEqual' -import {useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {useCallback, useEffect, useMemo, useState} from 'react' import {PALETTE} from '~/styles/paletteV3' import {Radius} from '~/types/constEnums' import useAtmosphere from '../../hooks/useAtmosphere' import {tiptapEmojiConfig} from '../../utils/tiptapEmojiConfig' import {tiptapMentionConfig} from '../../utils/tiptapMentionConfig' import BaseButton from '../BaseButton' -import isTextSelected from './isTextSelected' import {LoomExtension, unfurlLoomLinks} from './loomExtension' +import {TipTapEditor} from './TipTapEditor' import {TiptapLinkExtension} from './TiptapLinkExtension' -import TipTapLinkMenu, {LinkMenuState} from './TipTapLinkMenu' - -const LinkIcon = styled(Link)({ - height: 18, - width: 18 -}) - -const BubbleMenuWrapper = styled('div')({ - display: 'flex', - alignItems: 'center', - background: '#FFFFFF', - border: '1px solid', - borderRadius: '4px', - borderColor: PALETTE.SLATE_600, - padding: '4px' -}) - -const BubbleMenuButton = styled(BaseButton)<{isActive?: boolean}>(({isActive}) => ({ - height: '20px', - width: '22px', - padding: '4px 0px 4px 0px', - borderRadius: '2px', - background: isActive ? PALETTE.SLATE_300 : undefined, - ':hover': { - background: PALETTE.SLATE_300 - } -})) - -const SubmissionButtonWrapper = styled('div')({ - display: 'flex', - justifyContent: 'flex-end', - alignItems: 'center' -}) +import {LinkMenuState} from './TipTapLinkMenu' const SubmitButton = styled(BaseButton)<{disabled?: boolean}>(({disabled}) => ({ backgroundColor: disabled ? PALETTE.SLATE_200 : PALETTE.SKY_500, @@ -98,8 +65,6 @@ const PromptResponseEditor = (props: Props) => { [rawContent, readOnly] ) - const editorRef = useRef(null) - const setEditing = useCallback( (newIsEditing: boolean) => { setIsEditing(newIsEditing) @@ -143,10 +108,6 @@ const PromptResponseEditor = (props: Props) => { const [linkState, setLinkState] = useState(null) - const openLinkEditor = () => { - setLinkState('edit') - } - const editor = useEditor( { content, @@ -191,63 +152,19 @@ const PromptResponseEditor = (props: Props) => { editor?.commands.setContent(draftContent) }, [editor]) - const shouldShowBubbleMenu = () => { - if (!editor || editor.isActive('link')) return false - return isTextSelected(editor) - } - + if (!editor) return null return ( <> -
- {editor && !readOnly && ( - <> -
- - - editor.chain().focus().toggleBold().run()} - isActive={editor.isActive('bold')} - > - B - - editor.chain().focus().toggleItalic().run()} - isActive={editor.isActive('italic')} - > - I - - editor.chain().focus().toggleStrike().run()} - isActive={editor.isActive('strike')} - > - S - - - - - - -
- { - editor.commands.focus() - setLinkState(linkState) - }} - linkState={linkState} - /> - - )} - -
+ {!readOnly && ( // The render conditions for these buttons *should* only be true when 'readOnly' is false, but let's be explicit // about it. - +
{!!content && isEditing && ( editor && onCancel(editor)} size='medium'> Cancel @@ -262,7 +179,7 @@ const PromptResponseEditor = (props: Props) => { {!content ? 'Submit' : 'Update'} )} - +
)} ) diff --git a/packages/client/components/promptResponse/StandardBubbleMenu.tsx b/packages/client/components/promptResponse/StandardBubbleMenu.tsx new file mode 100644 index 00000000000..8a61f47cd32 --- /dev/null +++ b/packages/client/components/promptResponse/StandardBubbleMenu.tsx @@ -0,0 +1,50 @@ +import {Link} from '@mui/icons-material' +import {BubbleMenu, Editor} from '@tiptap/react' +import {BubbleMenuButton} from './BubbleMenuButton' +import isTextSelected from './isTextSelected' +import {LinkMenuState} from './TipTapLinkMenu' + +interface Props extends React.ButtonHTMLAttributes { + editor: Editor + setLinkState: (linkState: LinkMenuState) => void +} +export const StandardBubbleMenu = (props: Props) => { + const {editor, setLinkState} = props + + const shouldShowBubbleMenu = () => { + if (!editor || editor.isActive('link')) return false + return isTextSelected(editor) + } + + const openLinkEditor = () => { + setLinkState('edit') + } + + return ( + +
+ editor.chain().focus().toggleBold().run()} + isActive={editor.isActive('bold')} + > + B + + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive('italic')} + > + I + + editor.chain().focus().toggleStrike().run()} + isActive={editor.isActive('strike')} + > + S + + + + +
+
+ ) +} diff --git a/packages/client/components/promptResponse/TipTapEditor.tsx b/packages/client/components/promptResponse/TipTapEditor.tsx new file mode 100644 index 00000000000..cec1ec0a347 --- /dev/null +++ b/packages/client/components/promptResponse/TipTapEditor.tsx @@ -0,0 +1,33 @@ +import {Editor, EditorContent} from '@tiptap/react' +import {StandardBubbleMenu} from './StandardBubbleMenu' +import TipTapLinkMenu, {LinkMenuState} from './TipTapLinkMenu' + +interface Props extends React.ButtonHTMLAttributes { + editor: Editor + linkState?: LinkMenuState + setLinkState?: (linkState: LinkMenuState) => void + showBubbleMenu?: boolean + useLinkEditor?: () => void +} +export const TipTapEditor = (props: Props) => { + const {editor, linkState, setLinkState, showBubbleMenu, useLinkEditor} = props + return ( +
+ {showBubbleMenu && setLinkState && ( + + )} + {setLinkState && linkState && ( + { + editor.commands.focus() + setLinkState(linkState) + }} + linkState={linkState} + useLinkEditor={useLinkEditor} + /> + )} + +
+ ) +} diff --git a/packages/client/components/promptResponse/TipTapLinkEditor.tsx b/packages/client/components/promptResponse/TipTapLinkEditor.tsx index 8fdfa7c4e68..51b31194f70 100644 --- a/packages/client/components/promptResponse/TipTapLinkEditor.tsx +++ b/packages/client/components/promptResponse/TipTapLinkEditor.tsx @@ -7,10 +7,11 @@ export type props = { initialUrl: string initialText: string onSetLink: (link: {text: string; url: string}) => void + useLinkEditor?: () => void } export const TipTapLinkEditor = (props: props) => { - const {onSetLink, initialUrl, initialText} = props + const {useLinkEditor, onSetLink, initialUrl, initialText} = props const [url, setUrl] = useState(initialUrl) const [text, setText] = useState(initialText) @@ -29,6 +30,7 @@ export const TipTapLinkEditor = (props: props) => { }, [url, text, isValidUrl, onSetLink] ) + useLinkEditor?.() return (
diff --git a/packages/client/components/promptResponse/TipTapLinkMenu.tsx b/packages/client/components/promptResponse/TipTapLinkMenu.tsx index 01a93be6a01..8ad39cd0c72 100644 --- a/packages/client/components/promptResponse/TipTapLinkMenu.tsx +++ b/packages/client/components/promptResponse/TipTapLinkMenu.tsx @@ -1,6 +1,6 @@ import * as Popover from '@radix-ui/react-popover' import {Editor, getMarkRange, getMarkType, getTextBetween, useEditorState} from '@tiptap/react' -import {useCallback} from 'react' +import {useCallback, useRef} from 'react' import {TipTapLinkEditor} from './TipTapLinkEditor' import {TipTapLinkPreview} from './TipTapLinkPreview' @@ -23,18 +23,10 @@ interface Props { editor: Editor linkState: LinkMenuState setLinkState: (linkState: LinkMenuState) => void + useLinkEditor?: () => void } export const TipTapLinkMenu = (props: Props) => { - const {editor, linkState, setLinkState} = props - - const getRect = () => { - if (!editor) return {top: 0, left: 0} - const coords = editor.view.coordsAtPos(editor.state.selection.from) - return { - top: coords.top, - left: coords.left - } - } + const {editor, linkState, setLinkState, useLinkEditor} = props const {link, text} = useEditorState({ editor, @@ -111,20 +103,37 @@ export const TipTapLinkMenu = (props: Props) => { .run() setLinkState(null) }, [editor]) - - const rect = getRect() - const transform = `translate(${rect.left}px,${rect.top + 20}px)` const onOpenChange = (willOpen: boolean) => { setLinkState(willOpen ? (editor.isActive('link') ? 'preview' : 'edit') : null) } + const transformRef = useRef(undefined) + const getTransform = () => { + const coords = editor.view.coordsAtPos(editor.state.selection.from) + const {left, top} = coords + if (left !== 0 && top !== 0) { + transformRef.current = `translate(${coords.left}px,${coords.top + 20}px)` + } + return transformRef.current + } + if (!linkState) return null return ( - -
+ { + // necessary for link preview to preview focusing the first button + e.preventDefault() + }} + > +
{linkState === 'edit' && ( - + )} {linkState === 'preview' && ( diff --git a/packages/client/hooks/useTipTapEditorContent.ts b/packages/client/hooks/useTipTapEditorContent.ts new file mode 100644 index 00000000000..0397d5cf679 --- /dev/null +++ b/packages/client/hooks/useTipTapEditorContent.ts @@ -0,0 +1,22 @@ +import {Editor, generateHTML, JSONContent} from '@tiptap/react' +import {useMemo, useRef} from 'react' +import {serverTipTapExtensions} from '../shared/tiptap/serverTipTapExtensions' + +export const useTipTapEditorContent = (content: string) => { + const editorRef = useRef(null) + const contentJSONRef = useRef() + // 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) + // 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 + } + return contentJSONRef.current as JSONContent + }, [content]) + + return [contentJSON, editorRef] as const +} diff --git a/packages/client/hooks/useTipTapTaskEditor.ts b/packages/client/hooks/useTipTapTaskEditor.ts new file mode 100644 index 00000000000..be59c4ec080 --- /dev/null +++ b/packages/client/hooks/useTipTapTaskEditor.ts @@ -0,0 +1,55 @@ +import Mention from '@tiptap/extension-mention' +import Placeholder from '@tiptap/extension-placeholder' +import {generateText, useEditor} from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import {useState} from 'react' +import Atmosphere from '../Atmosphere' +import {LoomExtension} from '../components/promptResponse/loomExtension' +import {TiptapLinkExtension} from '../components/promptResponse/TiptapLinkExtension' +import {LinkMenuState} from '../components/promptResponse/TipTapLinkMenu' +import {mentionConfig, serverTipTapExtensions} from '../shared/tiptap/serverTipTapExtensions' +import {tiptapEmojiConfig} from '../utils/tiptapEmojiConfig' +import {tiptapMentionConfig} from '../utils/tiptapMentionConfig' +import {tiptapTagConfig} from '../utils/tiptapTagConfig' +import {useTipTapEditorContent} from './useTipTapEditorContent' + +export const useTipTapTaskEditor = ( + content: string, + options: { + atmosphere?: Atmosphere + teamId?: string + readOnly?: boolean + } +) => { + const {atmosphere, teamId, readOnly} = options + const [contentJSON, editorRef] = useTipTapEditorContent(content) + const [linkState, setLinkState] = useState(null) + editorRef.current = useEditor( + { + content: contentJSON, + extensions: [ + StarterKit, + LoomExtension, + Placeholder.configure({ + showOnlyWhenEditable: false, + placeholder: 'Describe what “Done” looks like' + }), + Mention.extend({name: 'taskTag'}).configure(tiptapTagConfig), + Mention.configure( + atmosphere && teamId ? tiptapMentionConfig(atmosphere, teamId) : mentionConfig + ), + Mention.extend({name: 'emojiMention'}).configure(tiptapEmojiConfig), + TiptapLinkExtension.configure({ + openOnClick: false, + popover: { + setLinkState + } + }) + ], + editable: !readOnly, + autofocus: generateText(contentJSON, serverTipTapExtensions).length === 0 + }, + [contentJSON, readOnly] + ) + return {editor: editorRef.current, linkState, setLinkState} +} diff --git a/packages/client/modules/demo/ClientGraphQLServer.ts b/packages/client/modules/demo/ClientGraphQLServer.ts index 0351e4381dc..9a3810b6839 100644 --- a/packages/client/modules/demo/ClientGraphQLServer.ts +++ b/packages/client/modules/demo/ClientGraphQLServer.ts @@ -1,4 +1,4 @@ -import {stateToHTML} from 'draft-js-export-html' +import {generateHTML, generateJSON, generateText} from '@tiptap/core' import EventEmitter from 'eventemitter3' import {parse, stringify} from 'flatted' import ms from 'ms' @@ -17,6 +17,9 @@ import { NewMeetingPhase } from '../../../server/postgres/types/NewMeetingPhase' import {Task as ITask} from '../../../server/postgres/types/index.d' +import {getTagsFromTipTapTask} from '../../shared/tiptap/getTagsFromTipTapTask' +import {serverTipTapExtensions} from '../../shared/tiptap/serverTipTapExtensions' +import {splitTipTapContent} from '../../shared/tiptap/splitTipTapContent' import { ExternalLinks, MeetingSettingsThreshold, @@ -26,9 +29,6 @@ import { import {DISCUSS, GROUP, REFLECT, VOTE} from '../../utils/constants' import dndNoise from '../../utils/dndNoise' import extractTextFromDraftString from '../../utils/draftjs/extractTextFromDraftString' -import getTagsFromEntityMap from '../../utils/draftjs/getTagsFromEntityMap' -import makeEmptyStr from '../../utils/draftjs/makeEmptyStr' -import splitDraftContent from '../../utils/draftjs/splitDraftContent' import findStageById from '../../utils/meetings/findStageById' import sleep from '../../utils/sleep' import getGroupSmartTitle from '../../utils/smartGroup/getGroupSmartTitle' @@ -295,6 +295,17 @@ class ClientGraphQLServer extends (EventEmitter as GQLDemoEmitter) { } } }, + tiptapMentionConfigQuery: () => { + return { + viewer: { + ...this.db.users[0], + team: { + ...this.db.team, + teamMembers: this.db.teamMembers + } + } + } + }, NewMeetingSummaryQuery: () => { return { viewer: { @@ -464,8 +475,9 @@ class ClientGraphQLServer extends (EventEmitter as GQLDemoEmitter) { // if the human deleted the task, exit fast if (!task) return null const {content} = task - const {title, contentState} = splitDraftContent(content) - const bodyHTML = stateToHTML(contentState) + const doc = JSON.parse(content) + const {title, bodyContent} = splitTipTapContent(doc) + const bodyHTML = generateHTML(bodyContent, serverTipTapExtensions) if (integrationProviderService === 'github') { Object.assign(task, { @@ -1269,11 +1281,13 @@ class ClientGraphQLServer extends (EventEmitter as GQLDemoEmitter) { const now = new Date().toJSON() const taskId = newTask.id || this.getTempId('task') const {discussionId, threadParentId, threadSortOrder, sortOrder, status} = newTask - const content = newTask.content || makeEmptyStr() - const {entityMap} = JSON.parse(content) - const tags = getTagsFromEntityMap(entityMap) + const content = + (newTask.content as string) || + JSON.stringify(generateJSON('

', serverTipTapExtensions)) + const doc = JSON.parse(content) + const tags = getTagsFromTipTapTask(doc) const user = this.db.users.find((user) => user.id === userId) - const plaintextContent = extractTextFromDraftString(content) + const plaintextContent = generateText(doc, serverTipTapExtensions) const task = { __typename: 'Task', __isThreadable: 'Task', @@ -1420,7 +1434,7 @@ class ClientGraphQLServer extends (EventEmitter as GQLDemoEmitter) { const taskUpdates = { content, status, - tags: content ? getTagsFromEntityMap(JSON.parse(content).entityMap) : undefined, + tags: content ? getTagsFromTipTapTask(JSON.parse(content)) : undefined, teamId: demoTeamId, sortOrder, userId: updatedTask.userId || task.userId diff --git a/packages/client/modules/demo/taskLookup.ts b/packages/client/modules/demo/taskLookup.ts index 4de3f578ed8..4febb6dbd2c 100644 --- a/packages/client/modules/demo/taskLookup.ts +++ b/packages/client/modules/demo/taskLookup.ts @@ -1,26 +1,61 @@ +import {generateJSON} from '@tiptap/core' +import {serverTipTapExtensions} from '../../shared/tiptap/serverTipTapExtensions' + const taskLookup = { botRef1: [ - `{"blocks":[{"key":"2t991","text":"Create a process for making a collective decision, together","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}` + JSON.stringify( + generateJSON( + '

Create a process for making a collective decision, together

', + serverTipTapExtensions + ) + ) ], botRef2: [ - `{"blocks":[{"key":"2t992","text":"Document our testing process","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}`, - `{"blocks":[{"key":"2t902","text":"When onboarding new employees, have them document our processes as they learn them","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}` + JSON.stringify(generateJSON('

Document our testing process

', serverTipTapExtensions)), + JSON.stringify( + generateJSON( + '

When onboarding new employees, have them document our processes as they learn them

', + serverTipTapExtensions + ) + ) ], botRef3: [ - `{"blocks":[{"key":"2t993","text":"Set a timer for speakers during meetings","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}` + JSON.stringify( + generateJSON('

Set a timer for speakers during meetings

', serverTipTapExtensions) + ) ], botRef4: [ - `{"blocks":[{"key":"2t994","text":"Propose which kind of decisions need to be made by the whole group","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}` + JSON.stringify( + generateJSON( + '

Propose which kind of decisions need to be made by the whole group

', + serverTipTapExtensions + ) + ) ], botRef5: [ - `{"blocks":[{"key":"2t995","text":"Create a policy for when to decide in-person vs. when to decide over Slack, and who needs to be involved for each type","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}` + JSON.stringify( + generateJSON( + '

Create a policy for when to decide in-person vs. when to decide over Slack, and who needs to be involved for each type

', + serverTipTapExtensions + ) + ) ], botRef6: [ - `{"blocks":[{"key":"2t996","text":"Try no-meeting Thursdays","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}`, - `{"blocks":[{"key":"2t906","text":"Use our planning meetings to discover which meetings and attendees to schedule","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}` + JSON.stringify(generateJSON('

Try no-meeting Thursdays

', serverTipTapExtensions)), + JSON.stringify( + generateJSON( + '

Use our planning meetings to discover which meetings and attendees to schedule

', + serverTipTapExtensions + ) + ) ], botRef7: [ - `{"blocks":[{"key":"2t997","text":"Research reputable methods for prioritizing work that the team can review together","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}` + JSON.stringify( + generateJSON( + '

Research reputable methods for prioritizing work that the team can review together

', + serverTipTapExtensions + ) + ) ], botRef8: [] } as const diff --git a/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/EmailTaskCard.tsx b/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/EmailTaskCard.tsx index 9d54764f9c7..f2a50fa3500 100644 --- a/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/EmailTaskCard.tsx +++ b/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/EmailTaskCard.tsx @@ -1,15 +1,14 @@ +import {generateHTML} from '@tiptap/html' import graphql from 'babel-plugin-relay/macro' -import {convertFromRaw, Editor, EditorState} from 'draft-js' import {EmailTaskCard_task$key} from 'parabol-client/__generated__/EmailTaskCard_task.graphql' -import editorDecorators from 'parabol-client/components/TaskEditor/decorators' import {PALETTE} from 'parabol-client/styles/paletteV3' import {FONT_FAMILY} from 'parabol-client/styles/typographyV2' import {taskStatusColors} from 'parabol-client/utils/taskStatus' import * as React from 'react' -import {useMemo, useRef} from 'react' import {useFragment} from 'react-relay' import {TaskStatusEnum} from '../../../../../__generated__/EmailTaskCard_task.graphql' -import convertToTaskContent from '../../../../../utils/draftjs/convertToTaskContent' +import {convertTipTapTaskContent} from '../../../../../shared/tiptap/convertTipTapTaskContent' +import {serverTipTapExtensions} from '../../../../../shared/tiptap/serverTipTapExtensions' interface Props { task: EmailTaskCard_task$key | null @@ -45,7 +44,7 @@ const statusStyle = (status: TaskStatusEnum) => ({ }) const deletedTask = { - content: convertToTaskContent('<>'), + content: convertTipTapTaskContent('<>'), status: 'done', tags: [] as string[], user: { @@ -66,15 +65,7 @@ const EmailTaskCard = (props: Props) => { taskRef ) const {content, status} = task || deletedTask - const contentState = useMemo(() => convertFromRaw(JSON.parse(content)), [content]) - const editorStateRef = useRef() - const getEditorState = () => { - return editorStateRef.current - } - editorStateRef.current = EditorState.createWithContent( - contentState, - editorDecorators(getEditorState) - ) + const htmlContent = generateHTML(JSON.parse(content), serverTipTapExtensions) return ( @@ -97,13 +88,7 @@ const EmailTaskCard = (props: Props) => { - { - /**/ - }} - /> +
diff --git a/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/TeamPromptResponseSummaryCard.tsx b/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/TeamPromptResponseSummaryCard.tsx index 9ba97448509..296979cd630 100644 --- a/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/TeamPromptResponseSummaryCard.tsx +++ b/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/TeamPromptResponseSummaryCard.tsx @@ -5,7 +5,7 @@ import {TeamPromptResponseSummaryCard_stage$key} from 'parabol-client/__generate import * as React from 'react' import {useFragment} from 'react-relay' import {PALETTE} from '~/styles/paletteV3' -import {serverTipTapExtensions} from '../../../../../../server/utils/serverTipTapExtensions' +import {serverTipTapExtensions} from '../../../../../shared/tiptap/serverTipTapExtensions' const responseSummaryCardStyles: React.CSSProperties = { padding: '12px', diff --git a/packages/client/modules/outcomeCard/components/OutcomeCard/OutcomeCard.tsx b/packages/client/modules/outcomeCard/components/OutcomeCard/OutcomeCard.tsx index f7d03e9f753..e714856a2d2 100644 --- a/packages/client/modules/outcomeCard/components/OutcomeCard/OutcomeCard.tsx +++ b/packages/client/modules/outcomeCard/components/OutcomeCard/OutcomeCard.tsx @@ -1,24 +1,25 @@ import styled from '@emotion/styled' +import {Editor} from '@tiptap/core' import graphql from 'babel-plugin-relay/macro' -import {EditorState} from 'draft-js' -import {memo, RefObject} from 'react' +import {memo} from 'react' import {useFragment} from 'react-relay' import {OutcomeCard_task$key} from '~/__generated__/OutcomeCard_task.graphql' import {AreaEnum, TaskStatusEnum} from '~/__generated__/UpdateTaskMutation.graphql' import EditingStatus from '~/components/EditingStatus/EditingStatus' import {PALETTE} from '~/styles/paletteV3' import IntegratedTaskContent from '../../../../components/IntegratedTaskContent' -import TaskEditor from '../../../../components/TaskEditor/TaskEditor' +import {TipTapEditor} from '../../../../components/promptResponse/TipTapEditor' +import {LinkMenuState} from '../../../../components/promptResponse/TipTapLinkMenu' import TaskIntegrationLink from '../../../../components/TaskIntegrationLink' import TaskWatermark from '../../../../components/TaskWatermark' import useAtmosphere from '../../../../hooks/useAtmosphere' import useTaskChildFocus, {UseTaskChild} from '../../../../hooks/useTaskChildFocus' +import UpdateTaskMutation from '../../../../mutations/UpdateTaskMutation' import {cardFocusShadow, cardHoverShadow, cardShadow, Elevation} from '../../../../styles/elevation' import cardRootStyles from '../../../../styles/helpers/cardRootStyles' import {Card} from '../../../../types/constEnums' import isTaskArchived from '../../../../utils/isTaskArchived' import isTaskPrivate from '../../../../utils/isTaskPrivate' -import isTempId from '../../../../utils/relay/isTempId' import {taskStatusLabels} from '../../../../utils/taskStatus' import TaskFooter from '../OutcomeCardFooter/TaskFooter' import OutcomeCardStatusIndicator from '../OutcomeCardStatusIndicator/OutcomeCardStatusIndicator' @@ -60,13 +61,13 @@ interface Props { area: AreaEnum isTaskFocused: boolean isTaskHovered: boolean - editorRef: RefObject - editorState: EditorState + editor: Editor + linkState: LinkMenuState + setLinkState: (linkState: LinkMenuState) => void handleCardUpdate: () => void isAgenda: boolean isDraggingOver: TaskStatusEnum | undefined task: OutcomeCard_task$key - setEditorState: (newEditorState: EditorState) => void useTaskChild: UseTaskChild dataCy: string } @@ -76,13 +77,13 @@ const OutcomeCard = memo((props: Props) => { area, isTaskFocused, isTaskHovered, - editorRef, - editorState, + editor, + linkState, + setLinkState, handleCardUpdate, isAgenda, isDraggingOver, task: taskRef, - setEditorState, useTaskChild, dataCy } = props @@ -100,9 +101,6 @@ const OutcomeCard = memo((props: Props) => { } status tags - team { - id - } # grab userId to ensure sorting on connections works userId isHighlighted(meetingId: $meetingId) @@ -114,13 +112,35 @@ const OutcomeCard = memo((props: Props) => { ) const isPrivate = isTaskPrivate(task.tags) const isArchived = isTaskArchived(task.tags) - const {integration, status, id: taskId, team, isHighlighted, editors} = task + const toggleTag = (tagId: string) => { + const {state, view} = editor + const {doc, schema, tr} = state + if (task.tags.includes(tagId)) { + doc.descendants((node, pos) => { + if (node.type.name === 'taskTag' && node.attrs.id === tagId) { + tr.delete(pos, pos + node.nodeSize) + } + }) + view.dispatch(tr) + const nextContent = JSON.stringify(editor.getJSON()) + UpdateTaskMutation(atmosphere, {updatedTask: {id: taskId, content: nextContent}}, {}) + return + } + // Create the mention node + const mentionNode = schema.nodes.taskTag!.create({id: tagId}) + + // Insert the mention node at the end + const transaction = tr.insert(doc.content.size, mentionNode) + view.dispatch(transaction) + const nextContent = JSON.stringify(editor.getJSON()) + UpdateTaskMutation(atmosphere, {updatedTask: {id: taskId, content: nextContent}}, {}) + } + const {integration, status, id: taskId, isHighlighted, editors} = task const atmosphere = useAtmosphere() const {viewerId} = atmosphere const otherEditors = editors.filter((editor) => editor.userId !== viewerId) const isEditing = editors.length > otherEditors.length const {addTaskChild, removeTaskChild} = useTaskChildFocus(taskId) - const {id: teamId} = team const type = integration?.__typename const statusTitle = `Card status: ${taskStatusLabels[status]}` const privateTitle = ', marked as #private' @@ -158,14 +178,11 @@ const OutcomeCard = memo((props: Props) => { }} onFocus={() => addTaskChild('root')} > - useTaskChild('editor-link-changer')} /> )} @@ -174,7 +191,7 @@ const OutcomeCard = memo((props: Props) => { dataCy={`${dataCy}`} area={area} cardIsActive={isTaskFocused || isTaskHovered || isEditing} - editorState={editorState} + toggleTag={toggleTag} isAgenda={isAgenda} task={task} useTaskChild={useTaskChild} diff --git a/packages/client/modules/outcomeCard/components/OutcomeCardFooter/TaskFooter.tsx b/packages/client/modules/outcomeCard/components/OutcomeCardFooter/TaskFooter.tsx index f9c1423555b..ab7cf06a0ac 100644 --- a/packages/client/modules/outcomeCard/components/OutcomeCardFooter/TaskFooter.tsx +++ b/packages/client/modules/outcomeCard/components/OutcomeCardFooter/TaskFooter.tsx @@ -1,6 +1,5 @@ import styled from '@emotion/styled' import graphql from 'babel-plugin-relay/macro' -import {EditorState} from 'draft-js' import {Fragment} from 'react' import {useFragment} from 'react-relay' import {AreaEnum} from '~/__generated__/UpdateTaskMutation.graphql' @@ -13,7 +12,6 @@ import {UseTaskChild} from '../../../../hooks/useTaskChildFocus' import {Card} from '../../../../types/constEnums' import {CompletedHandler} from '../../../../types/relayMutations' import {USER_DASH} from '../../../../utils/constants' -import removeContentTag from '../../../../utils/draftjs/removeContentTag' import isTaskArchived from '../../../../utils/isTaskArchived' import setLocalTaskError from '../../../../utils/relay/setLocalTaskError' import OutcomeCardMessage from '../OutcomeCardMessage/OutcomeCardMessage' @@ -52,7 +50,7 @@ const AvatarBlock = styled('div')({ interface Props { area: AreaEnum cardIsActive: boolean - editorState: EditorState + toggleTag: (tag: string) => void isAgenda: boolean task: TaskFooter_task$key useTaskChild: UseTaskChild @@ -60,12 +58,11 @@ interface Props { } const TaskFooter = (props: Props) => { - const {area, cardIsActive, editorState, isAgenda, task: taskRef, useTaskChild, dataCy} = props + const {area, cardIsActive, toggleTag, isAgenda, task: taskRef, useTaskChild, dataCy} = props const task = useFragment( graphql` fragment TaskFooter_task on Task { id - content error integration { __typename @@ -105,7 +102,7 @@ const TaskFooter = (props: Props) => { } const atmosphere = useAtmosphere() const showTeam = area === USER_DASH - const {content, id: taskId, error, integration, tags, userId} = task + const {id: taskId, error, integration, tags, userId} = task const isArchived = isTaskArchived(tags) const canAssignUser = !integration && !isArchived const canAssignTeam = !isArchived @@ -141,16 +138,14 @@ const TaskFooter = (props: Props) => { /> )} {isArchived ? ( - removeContentTag('archived', atmosphere, taskId, content, area)} - > + toggleTag('archived')}> ) : ( void isAgenda: boolean task: any useTaskChild: UseTaskChild @@ -25,7 +24,7 @@ const TaskFooterTagMenu = lazyPreload( ) const TaskFooterTagMenuToggle = (props: Props) => { - const {area, editorState, isAgenda, mutationProps, task, useTaskChild, dataCy} = props + const {area, toggleTag, isAgenda, mutationProps, task, useTaskChild, dataCy} = props const {togglePortal, originRef, menuPortal, menuProps} = useMenu(MenuPosition.UPPER_RIGHT) const { tooltipPortal, @@ -52,7 +51,7 @@ const TaskFooterTagMenuToggle = (props: Props) => { {menuPortal( void // TODO make area enum more fine grained to get rid of isAgenda isAgenda: boolean mutationProps: MenuMutationProps @@ -34,13 +31,12 @@ interface Props { } const TaskFooterTagMenu = (props: Props) => { - const {area, menuProps, editorState, isAgenda, task: taskRef, useTaskChild} = props + const {area, menuProps, toggleTag, isAgenda, task: taskRef, useTaskChild} = props const task = useFragment( graphql` fragment TaskFooterTagMenu_task on Task { ...TaskFooterTagMenuStatusItem_task id - content status tags } @@ -49,13 +45,8 @@ const TaskFooterTagMenu = (props: Props) => { ) useTaskChild('tag') const atmosphere = useAtmosphere() - const {id: taskId, status: taskStatus, tags, content} = task + const {id: taskId, status: taskStatus, tags} = task const isPrivate = isTaskPrivate(tags) - const handlePrivate = () => { - isPrivate - ? removeContentTag('private', atmosphere, taskId, content, area) - : addContentTag('#private', atmosphere, taskId, editorState.getCurrentContent(), area) - } return ( {statusItems @@ -80,7 +71,7 @@ const TaskFooterTagMenu = (props: Props) => { } - onClick={handlePrivate} + onClick={() => toggleTag('private')} /> {isAgenda ? ( { } - onClick={() => - addContentTag('#archived', atmosphere, taskId, editorState.getCurrentContent(), area) - } + onClick={() => toggleTag('archived')} /> )} diff --git a/packages/client/modules/outcomeCard/containers/OutcomeCard/OutcomeCardContainer.tsx b/packages/client/modules/outcomeCard/containers/OutcomeCard/OutcomeCardContainer.tsx index de54a30f7ab..c9216412a4e 100644 --- a/packages/client/modules/outcomeCard/containers/OutcomeCard/OutcomeCardContainer.tsx +++ b/packages/client/modules/outcomeCard/containers/OutcomeCard/OutcomeCardContainer.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled' +import {Editor} from '@tiptap/core' import graphql from 'babel-plugin-relay/macro' -import {ContentState, convertToRaw} from 'draft-js' import {memo, useEffect, useRef, useState} from 'react' import {useFragment} from 'react-relay' import {OutcomeCardContainer_task$key} from '~/__generated__/OutcomeCardContainer_task.graphql' @@ -8,13 +8,11 @@ import {AreaEnum, TaskStatusEnum} from '~/__generated__/UpdateTaskMutation.graph import useClickAway from '~/hooks/useClickAway' import useScrollIntoView from '~/hooks/useScrollIntoVIew' import SetTaskHighlightMutation from '~/mutations/SetTaskHighlightMutation' +import {LinkMenuState} from '../../../../components/promptResponse/TipTapLinkMenu' import useAtmosphere from '../../../../hooks/useAtmosphere' -import useEditorState from '../../../../hooks/useEditorState' import useTaskChildFocus from '../../../../hooks/useTaskChildFocus' import DeleteTaskMutation from '../../../../mutations/DeleteTaskMutation' import UpdateTaskMutation from '../../../../mutations/UpdateTaskMutation' -import convertToTaskContent from '../../../../utils/draftjs/convertToTaskContent' -import isAndroid from '../../../../utils/draftjs/isAndroid' import OutcomeCard from '../../components/OutcomeCard/OutcomeCard' const Wrapper = styled('div')({ @@ -23,7 +21,9 @@ const Wrapper = styled('div')({ interface Props { area: AreaEnum - contentState: ContentState + editor: Editor + linkState: LinkMenuState + setLinkState: (linkState: LinkMenuState) => void className?: string isAgenda: boolean | undefined isDraggingOver: TaskStatusEnum | undefined @@ -36,7 +36,9 @@ interface Props { const OutcomeCardContainer = memo((props: Props) => { const { - contentState, + editor, + linkState, + setLinkState, className, isDraggingOver, task: taskRef, @@ -63,9 +65,7 @@ const OutcomeCardContainer = memo((props: Props) => { const atmosphere = useAtmosphere() const ref = useRef(null) const [isTaskHovered, setIsTaskHovered] = useState(false) - const editorRef = useRef(null) - const [editorState, setEditorState] = useEditorState(content) const {useTaskChild, isTaskFocused} = useTaskChildFocus(taskId) const isHighlighted = isTaskHovered || !!isDraggingOver @@ -81,40 +81,20 @@ const OutcomeCardContainer = memo((props: Props) => { const handleCardUpdate = () => { const isFocused = isTaskFocused() - if (isAndroid) { - const editorEl = editorRef.current - if (!editorEl || editorEl.type !== 'textarea') return - const {value} = editorEl - if (!value.trim() && !isFocused) { - DeleteTaskMutation(atmosphere, {taskId}) - } else { - const initialContentState = editorState.getCurrentContent() - const initialText = initialContentState.getPlainText() - if (initialText === value) return - const updatedTask = { - id: taskId, - content: convertToTaskContent(value) - } - UpdateTaskMutation(atmosphere, {updatedTask, area}, {}) - } + if (editor.isEmpty && !isFocused) { + DeleteTaskMutation(atmosphere, {taskId}) return } - const nextContentState = editorState.getCurrentContent() - const hasText = nextContentState.getPlainText().trim().length > 0 - if (!hasText && !isFocused) { - DeleteTaskMutation(atmosphere, {taskId}) - } else { - const content = JSON.stringify(convertToRaw(nextContentState)) - const initialContent = JSON.stringify(convertToRaw(contentState)) - if (content === initialContent) return - const updatedTask = { - id: taskId, - content - } - UpdateTaskMutation(atmosphere, {updatedTask, area}, {}) + const nextContent = JSON.stringify(editor.getJSON()) + if (content === nextContent) return + const updatedTask = { + id: taskId, + content: nextContent } + UpdateTaskMutation(atmosphere, {updatedTask, area}, {}) } - useScrollIntoView(ref, !contentState.hasText()) + + useScrollIntoView(ref, editor.isEmpty) useClickAway(ref, () => setIsTaskHovered(false)) return ( { diff --git a/packages/client/modules/teamDashboard/components/TeamArchive/TeamArchive.tsx b/packages/client/modules/teamDashboard/components/TeamArchive/TeamArchive.tsx index 12719f7227e..d2a575ae26c 100644 --- a/packages/client/modules/teamDashboard/components/TeamArchive/TeamArchive.tsx +++ b/packages/client/modules/teamDashboard/components/TeamArchive/TeamArchive.tsx @@ -12,7 +12,6 @@ import { } from 'react-virtualized' import {GridCellRenderer, GridCoreProps} from 'react-virtualized/dist/es/Grid' import {TeamArchive_team$key} from '~/__generated__/TeamArchive_team.graphql' -import extractTextFromDraftString from '~/utils/draftjs/extractTextFromDraftString' import getSafeRegex from '~/utils/getSafeRegex' import toTeamMemberId from '~/utils/relay/toTeamMemberId' import {TeamArchiveArchivedTasksQuery} from '../../../../__generated__/TeamArchiveArchivedTasksQuery.graphql' @@ -140,6 +139,7 @@ const TeamArchive = (props: Props) => { teamId userId content + plaintextContent ...NullableTask_task } } @@ -191,7 +191,7 @@ const TeamArchive = (props: Props) => { if (!dashSearch) return teamMemberFilteredTasks const dashSearchRegex = getSafeRegex(dashSearch, 'i') const filteredEdges = teamMemberFilteredTasks.edges.filter((edge) => - extractTextFromDraftString(edge.node.content).match(dashSearchRegex) + edge.node.plaintextContent.match(dashSearchRegex) ) return {...teamMemberFilteredTasks, edges: filteredEdges} }, [dashSearch, teamMemberFilteredTasks]) diff --git a/packages/client/mutations/BatchArchiveTasksMutation.ts b/packages/client/mutations/BatchArchiveTasksMutation.ts index f95935aa9ec..42c871314f0 100644 --- a/packages/client/mutations/BatchArchiveTasksMutation.ts +++ b/packages/client/mutations/BatchArchiveTasksMutation.ts @@ -3,8 +3,8 @@ import {commitMutation} from 'react-relay' import {BatchArchiveTasksMutation_tasks$data} from '~/__generated__/BatchArchiveTasksMutation_tasks.graphql' import {Task as ITask} from '../../server/postgres/types/index.d' import {BatchArchiveTasksMutation as TBatchArchiveTasksMutation} from '../__generated__/BatchArchiveTasksMutation.graphql' +import {getTagsFromTipTapTask} from '../shared/tiptap/getTagsFromTipTapTask' import {SharedUpdater, StandardMutation} from '../types/relayMutations' -import getTagsFromEntityMap from '../utils/draftjs/getTagsFromEntityMap' import handleRemoveTasks from './handlers/handleRemoveTasks' import handleUpsertTasks from './handlers/handleUpsertTasks' @@ -38,8 +38,8 @@ export const batchArchiveTasksTaskUpdater: SharedUpdater { const content = archivedTask.getValue('content') - const {entityMap} = JSON.parse(content) - const nextTags = getTagsFromEntityMap(entityMap) + const doc = JSON.parse(content) + const nextTags = getTagsFromTipTapTask(doc) archivedTask.setValue(nextTags, 'tags') handleUpsertTasks(archivedTask as any, store) handleRemoveTasks(archivedTask as any, store) diff --git a/packages/client/mutations/CreateTaskIntegrationMutation.ts b/packages/client/mutations/CreateTaskIntegrationMutation.ts index d87e8d15b2f..30fbd710a71 100644 --- a/packages/client/mutations/CreateTaskIntegrationMutation.ts +++ b/packages/client/mutations/CreateTaskIntegrationMutation.ts @@ -1,12 +1,13 @@ +import {generateHTML} from '@tiptap/core' import graphql from 'babel-plugin-relay/macro' -import {stateToHTML} from 'draft-js-export-html' import {commitMutation} from 'react-relay' import {RecordSourceSelectorProxy} from 'relay-runtime' import JiraProjectId from '~/shared/gqlIds/JiraProjectId' import {CreateTaskIntegrationMutation as TCreateTaskIntegrationMutation} from '../__generated__/CreateTaskIntegrationMutation.graphql' +import {serverTipTapExtensions} from '../shared/tiptap/serverTipTapExtensions' +import {splitTipTapContent} from '../shared/tiptap/splitTipTapContent' import {StandardMutation} from '../types/relayMutations' import SendClientSideEvent from '../utils/SendClientSideEvent' -import splitDraftContent from '../utils/draftjs/splitDraftContent' import getMeetingPathParams from '../utils/meetings/getMeetingPathParams' import createProxyRecord from '../utils/relay/createProxyRecord' @@ -99,8 +100,8 @@ const jiraTaskIntegrationOptimisticUpdater = ( if (!task) return const contentStr = task.getValue('content') as string if (!contentStr) return - const {title: summary, contentState} = splitDraftContent(contentStr) - const descriptionHTML = stateToHTML(contentState) + const {title: summary, bodyContent} = splitTipTapContent(JSON.parse(contentStr)) + const descriptionHTML = generateHTML(bodyContent, serverTipTapExtensions) const optimisticIntegration = { summary, descriptionHTML, @@ -127,8 +128,8 @@ const githubTaskIntegrationOptimisitcUpdater = ( }) const contentStr = task.getValue('content') as string if (!contentStr) return - const {title, contentState} = splitDraftContent(contentStr) - const bodyHTML = stateToHTML(contentState) + const {title, bodyContent} = splitTipTapContent(JSON.parse(contentStr)) + const bodyHTML = generateHTML(bodyContent, serverTipTapExtensions) const optimisticIntegration = { title, bodyHTML, @@ -153,8 +154,8 @@ const gitlabTaskIntegrationOptimisitcUpdater = ( }) const contentStr = task.getValue('content') as string if (!contentStr) return - const {title, contentState} = splitDraftContent(contentStr) - const descriptionHtml = stateToHTML(contentState) + const {title, bodyContent} = splitTipTapContent(JSON.parse(contentStr)) + const descriptionHtml = generateHTML(bodyContent, serverTipTapExtensions) const webPath = `${fullPath}/-/issues/0` const optimisticIntegration = { title, @@ -179,8 +180,8 @@ const jiraServerTaskIntegrationOptimisticUpdater = ( if (!task) return const contentStr = task.getValue('content') as string if (!contentStr) return - const {title: summary, contentState} = splitDraftContent(contentStr) - const descriptionHTML = stateToHTML(contentState) + const {title: summary, bodyContent} = splitTipTapContent(JSON.parse(contentStr)) + const descriptionHTML = generateHTML(bodyContent, serverTipTapExtensions) const optimisticIntegration = { summary, descriptionHTML, @@ -201,8 +202,8 @@ const azureTaskIntegrationOptimisitcUpdater = ( if (!task) return const contentStr = task.getValue('content') as string if (!contentStr) return - const {title, contentState} = splitDraftContent(contentStr) - const descriptionHTML = stateToHTML(contentState) + const {title, bodyContent} = splitTipTapContent(JSON.parse(contentStr)) + const descriptionHTML = generateHTML(bodyContent, serverTipTapExtensions) const optimisticIntegration = { id: '?', title, diff --git a/packages/client/mutations/CreateTaskMutation.ts b/packages/client/mutations/CreateTaskMutation.ts index 0ff880cdf4d..4e238bd6647 100644 --- a/packages/client/mutations/CreateTaskMutation.ts +++ b/packages/client/mutations/CreateTaskMutation.ts @@ -1,13 +1,14 @@ +import {generateJSON, generateText, JSONContent} from '@tiptap/core' import graphql from 'babel-plugin-relay/macro' import {commitMutation} from 'react-relay' import AzureDevOpsProjectId from '~/shared/gqlIds/AzureDevOpsProjectId' -import extractTextFromDraftString from '~/utils/draftjs/extractTextFromDraftString' import Atmosphere from '../Atmosphere' import {CreateTaskMutation as TCreateTaskMutation} from '../__generated__/CreateTaskMutation.graphql' import {CreateTaskMutation_notification$data} from '../__generated__/CreateTaskMutation_notification.graphql' import {CreateTaskMutation_task$data} from '../__generated__/CreateTaskMutation_task.graphql' import GitHubIssueId from '../shared/gqlIds/GitHubIssueId' import JiraProjectId from '../shared/gqlIds/JiraProjectId' +import {serverTipTapExtensions} from '../shared/tiptap/serverTipTapExtensions' import { OnNextHandler, OnNextHistoryContext, @@ -15,7 +16,6 @@ import { SharedUpdater, StandardMutation } from '../types/relayMutations' -import makeEmptyStr from '../utils/draftjs/makeEmptyStr' import clientTempId from '../utils/relay/clientTempId' import createProxyRecord from '../utils/relay/createProxyRecord' import getOptimisticTaskEditor from '../utils/relay/getOptimisticTaskEditor' @@ -108,9 +108,11 @@ export const createTaskTaskUpdater: SharedUpdater if (!task) return const taskId = task.getValue('id') const content = task.getValue('content') - const rawContent = JSON.parse(content) - const {blocks} = rawContent - const isEditing = blocks.length === 0 || (blocks.length === 1 && blocks[0].text === '') + const rawContent = JSON.parse(content) as JSONContent + const isEditing = + !rawContent.content || + rawContent.content.length === 0 || + (rawContent.content.length === 1 && rawContent.content[0]?.text === '') const editorPayload = getOptimisticTaskEditor(store, taskId, isEditing) handleEditTask(editorPayload, store) handleUpsertTasks(task, store) @@ -161,7 +163,7 @@ const CreateTaskMutation: StandardMutation

', serverTipTapExtensions)), title: plaintextContent, plaintextContent } diff --git a/packages/client/mutations/UpdatePokerScopeMutation.ts b/packages/client/mutations/UpdatePokerScopeMutation.ts index 6f641fdcaa3..96473c3d08c 100644 --- a/packages/client/mutations/UpdatePokerScopeMutation.ts +++ b/packages/client/mutations/UpdatePokerScopeMutation.ts @@ -1,19 +1,20 @@ import graphql from 'babel-plugin-relay/macro' -import {stateToHTML} from 'draft-js-export-html' import {commitMutation} from 'react-relay' import GitLabIssueId from '~/shared/gqlIds/GitLabIssueId' //import AzureDevOpsIssueId from '~/shared/gqlIds/AzureDevOpsIssueId' +import {generateHTML} from '@tiptap/core' import {UpdatePokerScopeMutation as TUpdatePokerScopeMutation} from '../__generated__/UpdatePokerScopeMutation.graphql' import GitHubIssueId from '../shared/gqlIds/GitHubIssueId' import JiraIssueId from '../shared/gqlIds/JiraIssueId' +import {convertTipTapTaskContent} from '../shared/tiptap/convertTipTapTaskContent' +import {serverTipTapExtensions} from '../shared/tiptap/serverTipTapExtensions' +import {splitTipTapContent} from '../shared/tiptap/splitTipTapContent' import {PALETTE} from '../styles/paletteV3' import {BaseLocalHandlers, StandardMutation} from '../types/relayMutations' -import SendClientSideEvent from '../utils/SendClientSideEvent' -import convertToTaskContent from '../utils/draftjs/convertToTaskContent' -import splitDraftContent from '../utils/draftjs/splitDraftContent' import getSearchQueryFromMeeting from '../utils/getSearchQueryFromMeeting' import clientTempId from '../utils/relay/clientTempId' import createProxyRecord from '../utils/relay/createProxyRecord' +import SendClientSideEvent from '../utils/SendClientSideEvent' graphql` fragment UpdatePokerScopeMutation_meeting on UpdatePokerScopeSuccess { @@ -169,8 +170,8 @@ const UpdatePokerScopeMutation: StandardMutation { + content.content!.push({ + type: 'paragraph', + content: [ + { + type: 'taskTag', + attrs: { + id: tag, + label: null + } + } + ] + }) + return content +} + +export default addTagToTask diff --git a/packages/client/shared/tiptap/convertTipTapTaskContent.ts b/packages/client/shared/tiptap/convertTipTapTaskContent.ts new file mode 100644 index 00000000000..b93c0f01e13 --- /dev/null +++ b/packages/client/shared/tiptap/convertTipTapTaskContent.ts @@ -0,0 +1,13 @@ +import {generateJSON} from '@tiptap/html' +import {TaskTag} from 'parabol-server/postgres/types' +import {serverTipTapExtensions} from '~/shared/tiptap/serverTipTapExtensions' + +export const convertTipTapTaskContent = (text: string, tags?: TaskTag[]) => { + let body = `

${text}

` + if (tags) { + tags.forEach((tag) => { + body = body + `#${tag}` + }) + } + return JSON.stringify(generateJSON(body, serverTipTapExtensions)) +} diff --git a/packages/client/shared/tiptap/getAllNodesAttributesByType.ts b/packages/client/shared/tiptap/getAllNodesAttributesByType.ts new file mode 100644 index 00000000000..8bf272f8383 --- /dev/null +++ b/packages/client/shared/tiptap/getAllNodesAttributesByType.ts @@ -0,0 +1,31 @@ +import {JSONContent} from '@tiptap/core' +import {Attrs} from '@tiptap/pm/model' + +export const getAllNodesAttributesByType = ( + doc: JSONContent, + nodeType: string +) => { + let mentions: T[] = [] + + // Handle arrays + if (Array.isArray(doc)) { + for (const item of doc) { + mentions = mentions.concat(getAllNodesAttributesByType(item, nodeType)) + } + } + + // Handle objects + if (doc && typeof doc === 'object') { + // Check if current object is a mention + if (doc.type === nodeType) { + mentions.push(doc.attrs as T) + } + + // Recursively search content if it exists + if (doc.content) { + mentions = mentions.concat(getAllNodesAttributesByType(doc.content, nodeType)) + } + } + + return mentions +} diff --git a/packages/client/shared/tiptap/getTagsFromTipTapTask.ts b/packages/client/shared/tiptap/getTagsFromTipTapTask.ts new file mode 100644 index 00000000000..5336c4207f6 --- /dev/null +++ b/packages/client/shared/tiptap/getTagsFromTipTapTask.ts @@ -0,0 +1,8 @@ +import {JSONContent} from '@tiptap/core' +import {TaskTag} from '../../../server/postgres/types' +import {getAllNodesAttributesByType} from './getAllNodesAttributesByType' + +export const getTagsFromTipTapTask = (content: JSONContent) => { + const tagAttributes = getAllNodesAttributesByType<{id: TaskTag}>(content, 'taskTag') + return [...new Set(tagAttributes.map(({id}) => id))] +} diff --git a/packages/client/shared/tiptap/isDraftJSContent.ts b/packages/client/shared/tiptap/isDraftJSContent.ts new file mode 100644 index 00000000000..f135181ddd5 --- /dev/null +++ b/packages/client/shared/tiptap/isDraftJSContent.ts @@ -0,0 +1,8 @@ +import {JSONContent} from '@tiptap/core' +import {RawDraftContentState} from 'draft-js' + +export const isDraftJSContent = ( + content: JSONContent | RawDraftContentState +): content is RawDraftContentState => { + return 'blocks' in content +} diff --git a/packages/client/shared/tiptap/removeMentionKeepText.ts b/packages/client/shared/tiptap/removeMentionKeepText.ts new file mode 100644 index 00000000000..066cb2f08aa --- /dev/null +++ b/packages/client/shared/tiptap/removeMentionKeepText.ts @@ -0,0 +1,29 @@ +import {JSONContent} from '@tiptap/core' + +export const removeMentionKeepText = ( + node: JSONContent, + eqFn: (userId: string) => boolean +): JSONContent => { + // Base case: if the node is a 'mention', replace it + if (node.type === 'mention' && node.attrs && eqFn(node.attrs.id)) { + return { + type: 'span', + content: [ + { + text: node.attrs.label, + type: 'text' + } + ] + } + } + + // If the node has content, recursively process each child node + if (Array.isArray(node.content)) { + return { + ...node, + content: node.content.map((obj) => removeMentionKeepText(obj, eqFn)) + } + } + // If the node is not a 'mention' and has no content, return it as is + return node +} diff --git a/packages/client/shared/tiptap/removeNodeByType.ts b/packages/client/shared/tiptap/removeNodeByType.ts new file mode 100644 index 00000000000..220cdfe7067 --- /dev/null +++ b/packages/client/shared/tiptap/removeNodeByType.ts @@ -0,0 +1,21 @@ +import {JSONContent} from '@tiptap/core' + +export const removeNodeByType = (json: JSONContent, nodeTypeToRemove: string): JSONContent => { + if (Array.isArray(json)) { + return json + .map((node) => removeNodeByType(node, nodeTypeToRemove)) + .filter((item) => item.type !== nodeTypeToRemove) + } + + if (json && typeof json === 'object') { + const newObj = {...json} + + // Recursively process content if it exists + if (newObj.content) { + newObj.content = removeNodeByType(newObj.content, nodeTypeToRemove) as JSONContent[] + } + return newObj + } + // Return primitive values as-is + return json +} diff --git a/packages/client/shared/tiptap/serverTipTapExtensions.ts b/packages/client/shared/tiptap/serverTipTapExtensions.ts new file mode 100644 index 00000000000..8c019d992b0 --- /dev/null +++ b/packages/client/shared/tiptap/serverTipTapExtensions.ts @@ -0,0 +1,30 @@ +import {mergeAttributes} from '@tiptap/core' +import BaseLink from '@tiptap/extension-link' +import Mention, {MentionNodeAttrs, MentionOptions} from '@tiptap/extension-mention' +import StarterKit from '@tiptap/starter-kit' +import {LoomExtension} from '../../components/promptResponse/loomExtension' +import {tiptapTagConfig} from '../../utils/tiptapTagConfig' + +export const mentionConfig: Partial> = { + renderText({node}) { + return node.attrs.label + }, + renderHTML({options, node}) { + return ['span', options.HTMLAttributes, `${node.attrs.label ?? node.attrs.id}`] + } +} +export const serverTipTapExtensions = [ + StarterKit, + LoomExtension, + Mention.configure(mentionConfig), + Mention.extend({name: 'taskTag'}).configure(tiptapTagConfig), + BaseLink.extend({ + parseHTML() { + return [{tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])'}] + }, + + renderHTML({HTMLAttributes}) { + return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {class: 'link'}), 0] + } + }) +] diff --git a/packages/client/shared/tiptap/splitTipTapContent.ts b/packages/client/shared/tiptap/splitTipTapContent.ts new file mode 100644 index 00000000000..fd57bac164d --- /dev/null +++ b/packages/client/shared/tiptap/splitTipTapContent.ts @@ -0,0 +1,17 @@ +import {generateText, JSONContent} from '@tiptap/core' +import {serverTipTapExtensions} from './serverTipTapExtensions' + +export const splitTipTapContent = (doc: JSONContent, maxLength = 256) => { + const [firstBlock, ...bodyBlocks] = doc.content! + const fullTitle = generateText({...doc, content: [firstBlock!]}, serverTipTapExtensions) + if (fullTitle.length < maxLength) { + const bodyText = generateText({...doc, content: bodyBlocks}, serverTipTapExtensions) + const content = bodyText.trim().length > 0 ? bodyBlocks : doc.content! + return {title: fullTitle, bodyContent: {...doc, content}} + } + return { + title: fullTitle.slice(0, maxLength), + // repeat the full title in the body since we had to truncate it + bodyContent: doc + } +} diff --git a/packages/client/types/modules.d.ts b/packages/client/types/modules.d.ts index 33ce7803049..dbb21cd9384 100644 --- a/packages/client/types/modules.d.ts +++ b/packages/client/types/modules.d.ts @@ -14,7 +14,6 @@ declare module 'string-score' declare module 'babel-plugin-relay/macro' { export {graphql as default} from 'react-relay' } -declare module 'unicode-substring' declare module 'react-textarea-autosize' declare module 'react-copy-to-clipboard' declare module 'tayden-clusterfck' diff --git a/packages/client/utils/draftjs/addContentTag.ts b/packages/client/utils/draftjs/addContentTag.ts deleted file mode 100644 index 24d113c8823..00000000000 --- a/packages/client/utils/draftjs/addContentTag.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {ContentState, convertToRaw} from 'draft-js' -import {AreaEnum} from '~/__generated__/UpdateTaskMutation.graphql' -import Atmosphere from '../../Atmosphere' -import UpdateTaskMutation from '../../mutations/UpdateTaskMutation' -import addTagToTask from './addTagToTask' - -const addContentTag = ( - tag: string, - atmosphere: Atmosphere, - taskId: string, - contentState: ContentState, - area: AreaEnum -) => { - const newContent = addTagToTask(contentState, tag) - const rawContentStr = JSON.stringify(convertToRaw(newContent)) - const updatedTask = { - id: taskId, - content: rawContentStr - } - UpdateTaskMutation(atmosphere, {updatedTask, area}, {}) -} - -export default addContentTag diff --git a/packages/client/utils/draftjs/addTagToTask.ts b/packages/client/utils/draftjs/addTagToTask.ts deleted file mode 100644 index 0ed854b265d..00000000000 --- a/packages/client/utils/draftjs/addTagToTask.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {ContentState, Modifier, SelectionState} from 'draft-js' - -const addTagToTask = (contentState: ContentState, tag: string) => { - const value = tag.slice(1) - const lastBlock = contentState.getLastBlock() - const selectionState = new SelectionState({ - anchorKey: lastBlock.getKey(), - anchorOffset: lastBlock.getLength(), - focusKey: lastBlock.getKey(), - focusOffset: lastBlock.getLength(), - isBackward: false, - hasFocus: false - }) - const contentStateWithNewBlock = Modifier.splitBlock(contentState, selectionState) - const newBlock = contentStateWithNewBlock.getLastBlock() - const lastSelection = selectionState.merge({ - anchorKey: newBlock.getKey(), - focusKey: newBlock.getKey(), - anchorOffset: 0, - focusOffset: 0 - }) - - const contentStateWithEntity = contentStateWithNewBlock.createEntity('TAG', 'IMMUTABLE', {value}) - const entityKey = contentStateWithEntity.getLastCreatedEntityKey() - - return Modifier.replaceText(contentStateWithEntity, lastSelection, tag, undefined, entityKey) -} - -export default addTagToTask diff --git a/packages/client/utils/draftjs/getTagsFromEntityMap.ts b/packages/client/utils/draftjs/getTagsFromEntityMap.ts deleted file mode 100644 index 7afcb1fd4e7..00000000000 --- a/packages/client/utils/draftjs/getTagsFromEntityMap.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {RawDraftContentState} from 'draft-js' - -const getTagsFromEntityMap = (entityMap: RawDraftContentState['entityMap']) => { - const tags = new Set() - Object.values(entityMap).forEach((entity) => { - if (entity.type === 'TAG') { - tags.add(entity.data.value) - } - }) - return Array.from(tags) -} - -export default getTagsFromEntityMap diff --git a/packages/client/utils/draftjs/removeContentTag.ts b/packages/client/utils/draftjs/removeContentTag.ts deleted file mode 100644 index 6856dda0214..00000000000 --- a/packages/client/utils/draftjs/removeContentTag.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {AreaEnum} from '~/__generated__/UpdateTaskMutation.graphql' -import Atmosphere from '../../Atmosphere' -import UpdateTaskMutation from '../../mutations/UpdateTaskMutation' -import removeRangesForEntity from './removeRangesForEntity' - -const removeContentTag = ( - tagValue: string, - atmosphere: Atmosphere, - taskId: string, - content: string, - area: AreaEnum -) => { - const eqFn = (data: {value: any}) => data.value === tagValue - const nextContent = removeRangesForEntity(content, 'TAG', eqFn) - if (!nextContent) return - const updatedTask = { - id: taskId, - content: nextContent - } - UpdateTaskMutation(atmosphere, {updatedTask, area}, {}) -} - -export default removeContentTag diff --git a/packages/client/utils/draftjs/removeEntityKeepText.ts b/packages/client/utils/draftjs/removeEntityKeepText.ts deleted file mode 100644 index d9c7a0ea0fe..00000000000 --- a/packages/client/utils/draftjs/removeEntityKeepText.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - RawDraftContentBlock, - RawDraftContentState, - RawDraftEntity, - RawDraftEntityRange -} from 'draft-js' - -const updateBlockEntityRanges = (blocks: RawDraftContentBlock[], updatedKeyMap: any) => { - const nextBlocks = [] as RawDraftContentBlock[] - blocks.forEach((block) => { - const {entityRanges} = block - const nextEntityRanges = [] as RawDraftEntityRange[] - entityRanges.forEach((entityRange) => { - const nextKey = updatedKeyMap[entityRange.key] - if (nextKey !== null) { - nextEntityRanges.push({...entityRange, key: nextKey}) - } - }) - nextBlocks.push({...block, entityRanges: nextEntityRanges}) - }) - return nextBlocks -} - -/* - * Removes the underlying entity but keeps the text in place - * Useful for e.g. removing a mention but keeping the name - */ -const removeEntityKeepText = (rawContent: RawDraftContentState, eqFn: (entity: any) => boolean) => { - const {blocks, entityMap} = rawContent - const nextEntityMap = {} as RawDraftContentState['entityMap'] - // oldKey: newKey. null is a remove sentinel - const updatedKeyMap = {} as {[key: string]: string | null} - const removedEntities = [] as RawDraftEntity[] - // I'm not really sure how draft-js assigns keys, so I just reuse what they give me FIFO - const releasedKeys = [] as string[] - - for (const [key, entity] of Object.entries(entityMap)) { - if (eqFn(entity)) { - removedEntities.push(entity) - updatedKeyMap[key] = null - releasedKeys.push(key) - } else { - const nextKey = releasedKeys.length ? releasedKeys.shift()! : key - nextEntityMap[nextKey] = entity - updatedKeyMap[key] = nextKey - } - } - - return { - rawContent: - removedEntities.length === 0 - ? rawContent - : { - blocks: updateBlockEntityRanges(blocks, updatedKeyMap), - entityMap: nextEntityMap - }, - removedEntities - } -} - -export default removeEntityKeepText diff --git a/packages/client/utils/draftjs/removeRangesForEntity.ts b/packages/client/utils/draftjs/removeRangesForEntity.ts deleted file mode 100644 index 2e04966a8da..00000000000 --- a/packages/client/utils/draftjs/removeRangesForEntity.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - ContentState, - convertFromRaw, - convertToRaw, - EditorState, - Entity, - Modifier, - RawDraftContentState, - RawDraftEntityRange, - SelectionState -} from 'draft-js' -import unicodeSubstring from 'unicode-substring' - -const getUTF16Range = (text: string, range: RawDraftEntityRange) => { - const offset = unicodeSubstring(text, 0, range.offset).length - return { - key: range.key, - offset, - length: offset + unicodeSubstring(text, offset, range.length).length - } -} - -const getEntities = ( - entityMap: RawDraftContentState['entityMap'], - entityType: string, - eqFn: (entityData: any) => boolean -) => { - const entities = [] as string[] - for (const [key, entity] of Object.entries(entityMap)) { - if (entity.type === entityType && eqFn(entity.data)) { - entities.push(key) - } - } - return entities -} - -const getRemovalRanges = ( - entities: Entity[], - entityRanges: RawDraftEntityRange[], - text: string -) => { - const removalRanges = [] as {start: any; end: any}[] - entityRanges.forEach((utf8Range) => { - const entityKey = String(utf8Range.key) - if (entities.includes(entityKey)) { - const {offset, length} = getUTF16Range(text, utf8Range) - const entityEnd = offset + length - const end = offset === 0 && text[entityEnd] === ' ' ? entityEnd + 1 : entityEnd - const start = text[offset - 1] === ' ' ? offset - 1 : offset - removalRanges.push({start, end}) - } - }) - removalRanges.sort((a, b) => (a.end < b.end ? 1 : -1)) - return removalRanges -} - -const removeRangesForEntity = (content: string, entityType: string, eqFn: any) => { - const rawContent = JSON.parse(content) - const {blocks, entityMap} = rawContent - const entities = getEntities(entityMap, entityType, eqFn) - // it's an arduous task to update the next entities after removing 1, so use the removeRange helper - const editorState = EditorState.createWithContent(convertFromRaw(rawContent)) - let contentState = editorState.getCurrentContent() - const selectionState = editorState.getSelection() - for (let i = blocks.length - 1; i >= 0; i--) { - const block = blocks[i] - const {entityRanges, key: blockKey, text} = block - const removalRanges = getRemovalRanges(entities, entityRanges, text) - removalRanges.forEach((range) => { - const selectionToRemove = selectionState.merge({ - anchorKey: blockKey, - focusKey: blockKey, - anchorOffset: range.start, - focusOffset: range.end - }) as SelectionState - contentState = Modifier.removeRange(contentState, selectionToRemove, 'backward') - }) - if (contentState.getBlockForKey(blockKey).getText() === '') { - contentState = contentState.merge({ - blockMap: contentState.getBlockMap().delete(blockKey) - }) as ContentState - } - } - return contentState === editorState.getCurrentContent() - ? null - : JSON.stringify(convertToRaw(contentState)) -} - -export default removeRangesForEntity diff --git a/packages/client/utils/relay/isTempId.ts b/packages/client/utils/relay/isTempId.ts index d5d6ae3279d..cee8566ca73 100644 --- a/packages/client/utils/relay/isTempId.ts +++ b/packages/client/utils/relay/isTempId.ts @@ -1,3 +1,3 @@ -const isTempId = (id: any) => (id ? id.endsWith('-tmp') : false) +const isTempId = (id: string | null | undefined) => id?.endsWith('-tmp') ?? false export default isTempId diff --git a/packages/client/utils/tiptapMentionConfig.ts b/packages/client/utils/tiptapMentionConfig.ts index bb807477792..74c79d424a7 100644 --- a/packages/client/utils/tiptapMentionConfig.ts +++ b/packages/client/utils/tiptapMentionConfig.ts @@ -6,6 +6,7 @@ import tippy, {Instance, Props} from 'tippy.js' import {tiptapMentionConfigQuery} from '../__generated__/tiptapMentionConfigQuery.graphql' import Atmosphere from '../Atmosphere' import MentionDropdown from '../components/MentionDropdown' +import {mentionConfig} from '../shared/tiptap/serverTipTapExtensions' const queryNode = graphql` query tiptapMentionConfigQuery($teamId: ID!) { @@ -13,6 +14,7 @@ const queryNode = graphql` team(teamId: $teamId) { teamMembers { id + userId picture preferredName } @@ -25,10 +27,7 @@ export const tiptapMentionConfig = ( atmosphere: Atmosphere, teamId: string ): Partial> => ({ - // renderText does not fire, bug in TipTap? Fallback to using more verbose renderHTML - renderHTML({options, node}) { - return ['span', options.HTMLAttributes, `${node.attrs.label ?? node.attrs.id}`] - }, + ...mentionConfig, suggestion: { // some users have first & last name allowSpaces: true, diff --git a/packages/integration-tests/tests/retrospective-demo/step1-reflect.test.ts b/packages/integration-tests/tests/retrospective-demo/step1-reflect.test.ts index 9e03b3d8e92..92c0687bcd4 100644 --- a/packages/integration-tests/tests/retrospective-demo/step1-reflect.test.ts +++ b/packages/integration-tests/tests/retrospective-demo/step1-reflect.test.ts @@ -48,7 +48,7 @@ test.describe('retrospective-demo / reflect page', () => { const continueTextbox = '[data-cy=reflection-column-Continue] [role=textbox]' await page.click(continueTextbox) - await page.type(continueTextbox, 'Continue doing this') + await page.fill(continueTextbox, 'Continue doing this') await page.press(continueTextbox, 'Enter') await expect( @@ -61,7 +61,7 @@ test.describe('retrospective-demo / reflect page', () => { const startTextbox = '[data-cy=reflection-column-Start] [role=textbox]' await page.click(startTextbox) - await page.type(startTextbox, 'Start doing this') + await page.fill(startTextbox, 'Start doing this') await page.press(startTextbox, 'Enter') await expect( @@ -233,9 +233,7 @@ test.describe('retrospective-demo / reflect page', () => { ) ).toBeVisible() - await expect( - page.locator('button :text("1 / 2 Ready")') - ).toBeVisible() + await expect(page.locator('button :text("1 / 2 Ready")')).toBeVisible() await expect( page.locator( diff --git a/packages/integration-tests/tests/retrospective-demo/step2-group.test.ts b/packages/integration-tests/tests/retrospective-demo/step2-group.test.ts index 401cd3277d3..1a7f6523d60 100644 --- a/packages/integration-tests/tests/retrospective-demo/step2-group.test.ts +++ b/packages/integration-tests/tests/retrospective-demo/step2-group.test.ts @@ -93,7 +93,7 @@ test.describe('retrospective-demo / group page', () => { const startTextbox = '[data-cy=reflection-column-Start] [role=textbox]' await page.click(startTextbox) - await page.type(startTextbox, 'Documenting things in Notion') + await page.fill(startTextbox, 'Documenting things in Notion') await page.press(startTextbox, 'Enter') await expect( page.locator('[data-cy="reflection-column-Start"] :text("Documenting things in Notion")') @@ -101,7 +101,7 @@ test.describe('retrospective-demo / group page', () => { const stopTextbox = '[data-cy=reflection-column-Stop] [role=textbox]' await page.click(stopTextbox) - await page.type(stopTextbox, 'Making decisions in one-on-one meetings') + await page.fill(stopTextbox, 'Making decisions in one-on-one meetings') await page.press(stopTextbox, 'Enter') await expect( page.locator( diff --git a/packages/integration-tests/tests/retrospective-demo/step4-discuss.test.ts b/packages/integration-tests/tests/retrospective-demo/step4-discuss.test.ts index 2b8ef42d5f2..c6fdc078694 100644 --- a/packages/integration-tests/tests/retrospective-demo/step4-discuss.test.ts +++ b/packages/integration-tests/tests/retrospective-demo/step4-discuss.test.ts @@ -142,7 +142,7 @@ test.describe('retrospective-demo / discuss page', () => { } for await (const task of tasks || []) { - await expect(page.locator(`[data-cy=task-wrapper] :text('${task}')`)).toBeVisible({ + await expect(page.locator(`:text('${task}')`)).toBeVisible({ timeout: 30_000 }) } diff --git a/packages/server/graphql/mutations/changeTaskTeam.ts b/packages/server/graphql/mutations/changeTaskTeam.ts index 0fc63d0302a..b6491536ab9 100644 --- a/packages/server/graphql/mutations/changeTaskTeam.ts +++ b/packages/server/graphql/mutations/changeTaskTeam.ts @@ -1,12 +1,13 @@ import {GraphQLID, GraphQLNonNull} from 'graphql' import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import removeEntityKeepText from 'parabol-client/utils/draftjs/removeEntityKeepText' +import {removeMentionKeepText} from '../../../client/shared/tiptap/removeMentionKeepText' import getKysely from '../../postgres/getKysely' import {AtlassianAuth} from '../../postgres/queries/getAtlassianAuthByUserIdTeamId' import {GitHubAuth} from '../../postgres/queries/getGitHubAuthByUserIdTeamId' import upsertAtlassianAuths from '../../postgres/queries/upsertAtlassianAuths' import upsertGitHubAuth from '../../postgres/queries/upsertGitHubAuth' import {getUserId, isTeamMember} from '../../utils/authorization' +import {convertToTipTap} from '../../utils/convertToTipTap' import publish from '../../utils/publish' import standardError from '../../utils/standardError' import {GQLContext} from '../graphql' @@ -126,11 +127,9 @@ export default { const userIdsOnlyOnOldTeam = oldTeamUserIds.filter((oldTeamUserId) => { return !newTeamUserIds.find((newTeamUserId) => newTeamUserId === oldTeamUserId) }) - const rawContent = JSON.parse(content) - const eqFn = (entity: {type: string; data: {userId?: string}}) => - entity.type === 'MENTION' && - Boolean(userIdsOnlyOnOldTeam.find((userId) => userId === entity.data.userId)) - const {rawContent: nextRawContent} = removeEntityKeepText(rawContent, eqFn) + const rawContent = convertToTipTap(content) + const eqFn = (userId: string) => userIdsOnlyOnOldTeam.includes(userId) + const {rawContent: nextRawContent} = removeMentionKeepText(rawContent, eqFn) // If there is a task with the same integration hash in the new team, then delete it first. // This is done so there are no duplicates and also solves the issue of the conflicting task being diff --git a/packages/server/graphql/mutations/createTask.ts b/packages/server/graphql/mutations/createTask.ts index 0299b2075ea..ae62f285935 100644 --- a/packages/server/graphql/mutations/createTask.ts +++ b/packages/server/graphql/mutations/createTask.ts @@ -1,21 +1,22 @@ +import {generateText} from '@tiptap/core' import {GraphQLNonNull, GraphQLObjectType, GraphQLResolveInfo} from 'graphql' import {Insertable} from 'kysely' import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import getTypeFromEntityMap from 'parabol-client/utils/draftjs/getTypeFromEntityMap' import toTeamMemberId from 'parabol-client/utils/relay/toTeamMemberId' import MeetingMemberId from '../../../client/shared/gqlIds/MeetingMemberId' +import {getAllNodesAttributesByType} from '../../../client/shared/tiptap/getAllNodesAttributesByType' +import {getTagsFromTipTapTask} from '../../../client/shared/tiptap/getTagsFromTipTapTask' +import {serverTipTapExtensions} from '../../../client/shared/tiptap/serverTipTapExtensions' import dndNoise from '../../../client/utils/dndNoise' -import extractTextFromDraftString from '../../../client/utils/draftjs/extractTextFromDraftString' -import getTagsFromEntityMap from '../../../client/utils/draftjs/getTagsFromEntityMap' -import normalizeRawDraftJS from '../../../client/validation/normalizeRawDraftJS' import generateUID from '../../generateUID' import updatePrevUsedRepoIntegrationsCache from '../../integrations/updatePrevUsedRepoIntegrationsCache' import getKysely from '../../postgres/getKysely' -import {Task, TaskTag} from '../../postgres/types/index.d' +import {Task} from '../../postgres/types/index.d' import {Notification} from '../../postgres/types/pg' import {TaskServiceEnum} from '../../postgres/types/TaskIntegration' import {analytics} from '../../utils/analytics/analytics' import {getUserId, isTeamMember} from '../../utils/authorization' +import {convertToTipTap} from '../../utils/convertToTipTap' import publish, {SubOptions} from '../../utils/publish' import standardError from '../../utils/standardError' import {DataLoaderWorker, GQLContext} from '../graphql' @@ -88,16 +89,17 @@ const handleAddTaskNotifications = async ( }) } - const {entityMap} = JSON.parse(content) - getTypeFromEntityMap('MENTION', entityMap) + const jsonContent = JSON.parse(content) + getAllNodesAttributesByType<{id: string; label: string}>(jsonContent, 'mention') .filter( - (mention) => mention !== viewerId && mention !== userId && !usersIdsToIgnore.includes(mention) + (mention) => + mention.id !== viewerId && mention.id !== userId && !usersIdsToIgnore.includes(mention.id) ) - .forEach((mentioneeUserId) => { + .forEach((mentionee) => { notificationsToAdd.push({ id: generateUID(), type: 'TASK_INVOLVES' as const, - userId: mentioneeUserId, + userId: mentionee.id, involvement: 'MENTIONEE', taskId, changeAuthorId, @@ -187,15 +189,13 @@ export default { return standardError(new Error(firstError), {userId: viewerId}) } - const content = normalizeRawDraftJS(newTask.content) - // const content = convertToTipTap(newTask.content) - // const plaintextContent = generateText(content, serverTipTapExtensions) + const content = convertToTipTap(newTask.content) + const plaintextContent = generateText(content, serverTipTapExtensions) // see if the task already exists const integrationRes = await createTaskInService( newTask.integration, - // TODO: FIX ME - content as any, + content, viewerId, teamId, context, @@ -213,8 +213,8 @@ export default { } const task = { id: generateUID(), - content, - plaintextContent: extractTextFromDraftString(content), + content: JSON.stringify(content), + plaintextContent, createdBy: viewerId, meetingId, sortOrder: sortOrder || dndNoise(), @@ -226,14 +226,13 @@ export default { threadSortOrder, threadParentId, userId: userId || null, - // FIXME - tags: getTagsFromEntityMap(JSON.parse(content as any).entityMap) + tags: getTagsFromTipTapTask(content) } const {id: taskId} = task const teamMembers = await dataLoader.get('teamMembersByTeamId').load(teamId) await pg.insertInto('Task').values(task).execute() // FIXME - handleAddTaskNotifications(teamMembers, task as any, viewerId, teamId, { + handleAddTaskNotifications(teamMembers, task, viewerId, teamId, { operationId, mutatorId }).catch() diff --git a/packages/server/graphql/mutations/createTaskIntegration.ts b/packages/server/graphql/mutations/createTaskIntegration.ts index 108ab8ba6d2..ed9d1d2fa30 100644 --- a/packages/server/graphql/mutations/createTaskIntegration.ts +++ b/packages/server/graphql/mutations/createTaskIntegration.ts @@ -54,7 +54,7 @@ export default { if (!task) { return standardError(new Error('Task not found'), {userId: viewerId}) } - const {content: rawContentStr, teamId, userId} = task + const {content: rawContentJSON, teamId, userId} = task if (!isTeamMember(authToken, teamId)) { return standardError(new Error('Team not found'), {userId: viewerId}) } @@ -124,7 +124,7 @@ export default { const teamDashboardUrl = makeAppURL(appOrigin, `team/${teamId}`) const createTaskResponse = await taskIntegrationManager.createTask({ - rawContentStr, + rawContentJSON: JSON.parse(rawContentJSON), integrationRepoId }) diff --git a/packages/server/graphql/mutations/helpers/addSeedTasks.ts b/packages/server/graphql/mutations/helpers/addSeedTasks.ts index 25ade85b187..bac1034ae9f 100644 --- a/packages/server/graphql/mutations/helpers/addSeedTasks.ts +++ b/packages/server/graphql/mutations/helpers/addSeedTasks.ts @@ -1,13 +1,13 @@ -import convertToTaskContent from 'parabol-client/utils/draftjs/convertToTaskContent' -import getTagsFromEntityMap from 'parabol-client/utils/draftjs/getTagsFromEntityMap' +import {generateJSON} from '@tiptap/html' import makeAppURL from 'parabol-client/utils/makeAppURL' +import {getTagsFromTipTapTask} from '../../../../client/shared/tiptap/getTagsFromTipTapTask' +import {serverTipTapExtensions} from '../../../../client/shared/tiptap/serverTipTapExtensions' import appOrigin from '../../../appOrigin' import generateUID from '../../../generateUID' import getKysely from '../../../postgres/getKysely' -import {convertHtmlToTaskContent} from '../../../utils/draftjs/convertHtmlToTaskContent' const NORMAL_TASK_STRING = `This is a task card. They can be created here, in a meeting, or via an integration` -const INTEGRATIONS_TASK_STRING = `Parabol supports integrations for Jira, GitHub, GitLab, Slack and Mattermost. Connect your tools on Settings > Integrations.` +const INTEGRATIONS_TASK_STRING = `Parabol supports integrations for Jira, GitHub, GitLab, Slack and Mattermost. Connect your tools in Settings > Integrations.` function getSeedTasks(teamId: string) { const searchParams = { @@ -17,19 +17,22 @@ function getSeedTasks(teamId: string) { } const options = {searchParams} const integrationURL = makeAppURL(appOrigin, `team/${teamId}/integrations`, options) - const integrationTaskHTML = `Parabol supports integrations for Jira, GitHub, GitLab, Slack and Mattermost. Connect your tools in Integrations.` - return [ { status: 'active' as const, sortOrder: 1, - content: convertToTaskContent(NORMAL_TASK_STRING), + content: JSON.stringify(generateJSON(`

${NORMAL_TASK_STRING}

`, serverTipTapExtensions)), plaintextContent: NORMAL_TASK_STRING }, { status: 'active' as const, sortOrder: 0, - content: convertHtmlToTaskContent(integrationTaskHTML), + content: JSON.stringify( + generateJSON( + `

Parabol supports integrations for Jira, GitHub, GitLab, Slack and Mattermost. Connect your tools in Integrations.

`, + serverTipTapExtensions + ) + ), plaintextContent: INTEGRATIONS_TASK_STRING } ] @@ -44,7 +47,7 @@ export default async (userId: string, teamId: string) => { id: `${teamId}::${generateUID()}`, createdAt: now, createdBy: userId, - tags: getTagsFromEntityMap(JSON.parse(proj.content).entityMap), + tags: getTagsFromTipTapTask(JSON.parse(proj.content)), teamId, userId, updatedAt: now diff --git a/packages/server/graphql/mutations/helpers/createAzureTask.ts b/packages/server/graphql/mutations/helpers/createAzureTask.ts index a8e8c8e06df..8046f7c0789 100644 --- a/packages/server/graphql/mutations/helpers/createAzureTask.ts +++ b/packages/server/graphql/mutations/helpers/createAzureTask.ts @@ -1,10 +1,11 @@ +import {JSONContent} from '@tiptap/core' import {DataLoaderWorker} from '../../../graphql/graphql' import {IntegrationProviderAzureDevOps} from '../../../postgres/queries/getIntegrationProvidersByIds' import {TeamMemberIntegrationAuth} from '../../../postgres/types' import AzureDevOpsServerManager from '../../../utils/AzureDevOpsServerManager' const createAzureTask = async ( - rawContentStr: string, + rawContentJSON: JSONContent, serviceProjectHash: string, azureAuth: TeamMemberIntegrationAuth, dataLoader: DataLoaderWorker @@ -15,7 +16,7 @@ const createAzureTask = async ( provider as IntegrationProviderAzureDevOps ) - return manager.createTask({rawContentStr, integrationRepoId: serviceProjectHash}) + return manager.createTask({rawContentJSON, integrationRepoId: serviceProjectHash}) } export default createAzureTask diff --git a/packages/server/graphql/mutations/helpers/createGitHubTask.ts b/packages/server/graphql/mutations/helpers/createGitHubTask.ts index e9a59535e77..9ebb7a33d98 100644 --- a/packages/server/graphql/mutations/helpers/createGitHubTask.ts +++ b/packages/server/graphql/mutations/helpers/createGitHubTask.ts @@ -1,6 +1,6 @@ -import {stateToMarkdown} from 'draft-js-export-markdown' +import {JSONContent} from '@tiptap/core' import {GraphQLResolveInfo} from 'graphql' -import splitDraftContent from '../../../../client/utils/draftjs/splitDraftContent' +import {splitTipTapContent} from 'parabol-client/shared/tiptap/splitTipTapContent' import {GitHubAuth} from '../../../postgres/queries/getGitHubAuthByUserIdTeamId' import { CreateIssueMutation, @@ -8,13 +8,14 @@ import { GetRepoInfoQuery, GetRepoInfoQueryVariables } from '../../../types/githubTypes' +import {convertTipTapToMarkdown} from '../../../utils/convertTipTapToMarkdown' import getGitHubRequest from '../../../utils/getGitHubRequest' import createIssueMutation from '../../../utils/githubQueries/createIssue.graphql' import getRepoInfo from '../../../utils/githubQueries/getRepoInfo.graphql' import {GQLContext} from '../../graphql' const createGitHubTask = async ( - rawContent: string, + rawContent: JSONContent, repoOwner: string, repoName: string, githubAuth: GitHubAuth, @@ -22,8 +23,8 @@ const createGitHubTask = async ( info: GraphQLResolveInfo ) => { const {accessToken, login} = githubAuth - const {title, contentState} = splitDraftContent(rawContent) - const body = stateToMarkdown(contentState) as string + const {title, bodyContent} = splitTipTapContent(rawContent) + const body = convertTipTapToMarkdown(bodyContent) const githubRequest = getGitHubRequest(info, context, { accessToken }) diff --git a/packages/server/graphql/mutations/helpers/createGitLabTask.ts b/packages/server/graphql/mutations/helpers/createGitLabTask.ts index 69c5ce4d93c..40a7837577e 100644 --- a/packages/server/graphql/mutations/helpers/createGitLabTask.ts +++ b/packages/server/graphql/mutations/helpers/createGitLabTask.ts @@ -1,12 +1,13 @@ -import {stateToMarkdown} from 'draft-js-export-markdown' +import {JSONContent} from '@tiptap/core' import {GraphQLResolveInfo} from 'graphql' -import splitDraftContent from 'parabol-client/utils/draftjs/splitDraftContent' +import {splitTipTapContent} from '../../../../client/shared/tiptap/splitTipTapContent' import GitLabServerManager from '../../../integrations/gitlab/GitLabServerManager' import {TeamMemberIntegrationAuth} from '../../../postgres/types' +import {convertTipTapToMarkdown} from '../../../utils/convertTipTapToMarkdown' import {DataLoaderWorker, GQLContext} from '../../graphql' const createGitLabTask = async ( - rawContent: string, + rawContent: JSONContent, fullPath: string, gitlabAuth: TeamMemberIntegrationAuth, context: GQLContext, @@ -15,8 +16,8 @@ const createGitLabTask = async ( ) => { const {accessToken, providerId} = gitlabAuth if (!accessToken) return {error: new Error('Invalid GitLab auth')} - const {title, contentState} = splitDraftContent(rawContent) - const body = stateToMarkdown(contentState) + const {title, bodyContent} = splitTipTapContent(rawContent) + const body = convertTipTapToMarkdown(bodyContent) const provider = await dataLoader.get('integrationProviders').load(providerId) const manager = new GitLabServerManager(gitlabAuth, context, info, provider!.serverBaseUrl!) const [createIssueData, createIssueError] = await manager.createIssue({ diff --git a/packages/server/graphql/mutations/helpers/createJiraTask.ts b/packages/server/graphql/mutations/helpers/createJiraTask.ts index ffcf6800add..aa75145f9fa 100644 --- a/packages/server/graphql/mutations/helpers/createJiraTask.ts +++ b/packages/server/graphql/mutations/helpers/createJiraTask.ts @@ -1,17 +1,18 @@ +import {JSONContent} from '@tiptap/core' +import {splitTipTapContent} from 'parabol-client/shared/tiptap/splitTipTapContent' import {RateLimitError} from 'parabol-client/utils/AtlassianManager' -import splitDraftContent from 'parabol-client/utils/draftjs/splitDraftContent' import {AtlassianAuth} from '../../../postgres/queries/getAtlassianAuthByUserIdTeamId' import AtlassianServerManager from '../../../utils/AtlassianServerManager' -import convertContentStateToADF from '../../../utils/convertContentStateToADF' +import {convertTipTapToADF} from '../../../utils/convertTipTapToADF' const createJiraTask = async ( - rawContent: string, + rawContent: JSONContent, cloudId: string, projectKey: string, atlassianAuth: AtlassianAuth ) => { - const {title: summary, contentState} = splitDraftContent(rawContent) - const description = convertContentStateToADF(contentState) + const {title: summary, bodyContent} = splitTipTapContent(rawContent) + const description = convertTipTapToADF(bodyContent) const {accessToken, accountId} = atlassianAuth const manager = new AtlassianServerManager(accessToken) diff --git a/packages/server/graphql/mutations/helpers/createTaskInService.ts b/packages/server/graphql/mutations/helpers/createTaskInService.ts index e972d7fb08e..e7dffd86f66 100644 --- a/packages/server/graphql/mutations/helpers/createTaskInService.ts +++ b/packages/server/graphql/mutations/helpers/createTaskInService.ts @@ -1,3 +1,4 @@ +import {JSONContent} from '@tiptap/core' import {GraphQLResolveInfo} from 'graphql' import AzureDevOpsIssueId from 'parabol-client/shared/gqlIds/AzureDevOpsIssueId' import GitLabIssueId from 'parabol-client/shared/gqlIds/GitLabIssueId' @@ -8,7 +9,7 @@ import IntegrationRepoId from '../../../../client/shared/gqlIds/IntegrationRepoI import JiraIssueId from '../../../../client/shared/gqlIds/JiraIssueId' import JiraProjectId from '../../../../client/shared/gqlIds/JiraProjectId' import JiraProjectKeyId from '../../../../client/shared/gqlIds/JiraProjectKeyId' -import removeRangesForEntity from '../../../../client/utils/draftjs/removeRangesForEntity' +import {removeNodeByType} from '../../../../client/shared/tiptap/removeNodeByType' import {GQLContext} from '../../graphql' import {CreateTaskIntegrationInput} from '../createTask' import createAzureTask from './createAzureTask' @@ -18,7 +19,7 @@ import createJiraTask from './createJiraTask' const createTaskInService = async ( integrationInput: CreateTaskIntegrationInput | null | undefined, - rawContent: string, + rawContent: JSONContent, accessUserId: string, teamId: string, context: GQLContext, @@ -27,8 +28,7 @@ const createTaskInService = async ( if (!integrationInput) return {integrationHash: undefined, integration: undefined} const {dataLoader} = context const {service, serviceProjectHash} = integrationInput - const eqFn = (data: {value: string}) => ['archived', 'private'].includes(data.value) - const taglessContentJSON = removeRangesForEntity(rawContent, 'TAG', eqFn) || rawContent + const taglessContentJSON = removeNodeByType(rawContent, 'taskTag') if (service === 'jira') { const atlassianAuth = await dataLoader .get('freshAtlassianAuth') diff --git a/packages/server/graphql/mutations/helpers/importTasksForPoker.ts b/packages/server/graphql/mutations/helpers/importTasksForPoker.ts index 951dbf52354..bb0bf03dab2 100644 --- a/packages/server/graphql/mutations/helpers/importTasksForPoker.ts +++ b/packages/server/graphql/mutations/helpers/importTasksForPoker.ts @@ -1,8 +1,8 @@ import IntegrationHash from 'parabol-client/shared/gqlIds/IntegrationHash' import {isNotNull} from 'parabol-client/utils/predicates' +import {convertTipTapTaskContent} from '../../../../client/shared/tiptap/convertTipTapTaskContent' +import {getTagsFromTipTapTask} from '../../../../client/shared/tiptap/getTagsFromTipTapTask' import dndNoise from '../../../../client/utils/dndNoise' -import convertToTaskContent from '../../../../client/utils/draftjs/convertToTaskContent' -import getTagsFromEntityMap from '../../../../client/utils/draftjs/getTagsFromEntityMap' import generateUID from '../../../generateUID' import getKysely from '../../../postgres/getKysely' import {selectTasks} from '../../../postgres/select' @@ -45,7 +45,7 @@ const importTasksForPoker = async ( } const integrationHash = IntegrationHash.join(integration) const plaintextContent = `Task imported from ${integration.service} #archived` - const content = convertToTaskContent(plaintextContent) + const content = convertTipTapTaskContent(plaintextContent) return { id: generateUID(), content, @@ -57,7 +57,7 @@ const importTasksForPoker = async ( integrationHash, integration: JSON.stringify(integration), meetingId, - tags: getTagsFromEntityMap(JSON.parse(content).entityMap) + tags: getTagsFromTipTapTask(JSON.parse(content)) } }) .filter(isNotNull) diff --git a/packages/server/graphql/mutations/helpers/publishChangeNotifications.ts b/packages/server/graphql/mutations/helpers/publishChangeNotifications.ts index eb52b52f02a..20d0876ae93 100644 --- a/packages/server/graphql/mutations/helpers/publishChangeNotifications.ts +++ b/packages/server/graphql/mutations/helpers/publishChangeNotifications.ts @@ -1,4 +1,4 @@ -import getTypeFromEntityMap from 'parabol-client/utils/draftjs/getTypeFromEntityMap' +import {getAllNodesAttributesByType} from '../../../../client/shared/tiptap/getAllNodesAttributesByType' import generateUID from '../../../generateUID' import getKysely from '../../../postgres/getKysely' import {Task} from '../../../postgres/types' @@ -13,12 +13,16 @@ const publishChangeNotifications = async ( ) => { const pg = getKysely() const changeAuthorId = `${changeUser.id}::${task.teamId}` - const {entityMap: oldEntityMap} = JSON.parse(oldTask.content) - const {entityMap} = JSON.parse(task.content) + const oldContentJSON = JSON.parse(oldTask.content) + const contentJSON = JSON.parse(task.content) const wasPrivate = oldTask.tags.includes('private') const isPrivate = task.tags.includes('private') - const oldMentions = wasPrivate ? [] : getTypeFromEntityMap('MENTION', oldEntityMap) - const mentions = isPrivate ? [] : getTypeFromEntityMap('MENTION', entityMap) + const oldMentions = wasPrivate + ? [] + : getAllNodesAttributesByType<{id: string}>(oldContentJSON, 'mention').map(({id}) => id) + const mentions = isPrivate + ? [] + : getAllNodesAttributesByType<{id: string}>(contentJSON, 'mention').map(({id}) => id) // intersect the mentions to get the ones to add and remove const userIdsToRemove = oldMentions.filter((userId) => !mentions.includes(userId)) const notificationsToAdd = mentions diff --git a/packages/server/graphql/mutations/updateTask.ts b/packages/server/graphql/mutations/updateTask.ts index b9ac62a1415..db4a0aad1f4 100644 --- a/packages/server/graphql/mutations/updateTask.ts +++ b/packages/server/graphql/mutations/updateTask.ts @@ -1,11 +1,12 @@ +import {generateText} from '@tiptap/core' import {GraphQLNonNull, GraphQLObjectType} from 'graphql' import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractTextFromDraftString' -import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' -import getTagsFromEntityMap from '../../../client/utils/draftjs/getTagsFromEntityMap' +import {getTagsFromTipTapTask} from '../../../client/shared/tiptap/getTagsFromTipTapTask' +import {serverTipTapExtensions} from '../../../client/shared/tiptap/serverTipTapExtensions' import getKysely from '../../postgres/getKysely' import {Task} from '../../postgres/types/index' import {getUserId, isTeamMember} from '../../utils/authorization' +import {convertToTipTap} from '../../utils/convertToTipTap' import publish from '../../utils/publish' import standardError from '../../utils/standardError' import {GQLContext} from '../graphql' @@ -54,7 +55,12 @@ export default { // VALIDATION const {id: taskId, userId: inputUserId, status, sortOrder, content} = updatedTask - const validContent = normalizeRawDraftJS(content) + + const validContent = convertToTipTap(content) + const plaintextContent = content + ? generateText(validContent, serverTipTapExtensions) + : undefined + const [task, viewer] = await Promise.all([ dataLoader.get('tasks').load(taskId), dataLoader.get('users').loadNonNull(viewerId) @@ -79,11 +85,11 @@ export default { .updateTable('Task') .set({ content: content ? validContent : undefined, - plaintextContent: content ? extractTextFromDraftString(validContent) : undefined, + plaintextContent, sortOrder: sortOrder || undefined, status: status || undefined, userId: inputUserId || undefined, - tags: content ? getTagsFromEntityMap(JSON.parse(validContent).entityMap) : undefined + tags: content ? getTagsFromTipTapTask(validContent) : undefined }) .where('id', '=', taskId) .executeTakeFirst() diff --git a/packages/server/graphql/public/mutations/upsertTeamPromptResponse.ts b/packages/server/graphql/public/mutations/upsertTeamPromptResponse.ts index cd5d32de2a0..b3b95c58e03 100644 --- a/packages/server/graphql/public/mutations/upsertTeamPromptResponse.ts +++ b/packages/server/graphql/public/mutations/upsertTeamPromptResponse.ts @@ -1,12 +1,12 @@ import {generateText, JSONContent} from '@tiptap/core' import TeamPromptResponseId from 'parabol-client/shared/gqlIds/TeamPromptResponseId' import {SubscriptionChannel} from 'parabol-client/types/constEnums' +import {serverTipTapExtensions} from '../../../../client/shared/tiptap/serverTipTapExtensions' import {upsertTeamPromptResponse as upsertTeamPromptResponseQuery} from '../../../postgres/queries/upsertTeamPromptResponses' import {TeamPromptResponse} from '../../../postgres/types' import {analytics} from '../../../utils/analytics/analytics' import {getUserId, isTeamMember} from '../../../utils/authorization' import publish from '../../../utils/publish' -import {serverTipTapExtensions} from '../../../utils/serverTipTapExtensions' import standardError from '../../../utils/standardError' import {IntegrationNotifier} from '../../mutations/helpers/notifications/IntegrationNotifier' import {MutationResolvers} from '../resolverTypes' diff --git a/packages/server/graphql/public/types/Task.ts b/packages/server/graphql/public/types/Task.ts index 645ea20187b..e8868952397 100644 --- a/packages/server/graphql/public/types/Task.ts +++ b/packages/server/graphql/public/types/Task.ts @@ -1,12 +1,14 @@ import AzureDevOpsIssueId from 'parabol-client/shared/gqlIds/AzureDevOpsIssueId' import JiraServerIssueId from '~/shared/gqlIds/JiraServerIssueId' import GitHubRepoId from '../../../../client/shared/gqlIds/GitHubRepoId' +import {isDraftJSContent} from '../../../../client/shared/tiptap/isDraftJSContent' import GitLabServerManager from '../../../integrations/gitlab/GitLabServerManager' import {IGetLatestTaskEstimatesQueryResult} from '../../../postgres/queries/generated/getLatestTaskEstimatesQuery' import getSimilarTaskEstimate from '../../../postgres/queries/getSimilarTaskEstimate' import insertTaskEstimate from '../../../postgres/queries/insertTaskEstimate' import {GetIssueLabelsQuery, GetIssueLabelsQueryVariables} from '../../../types/githubTypes' import {getUserId} from '../../../utils/authorization' +import {convertKnownDraftToTipTap} from '../../../utils/convertToTipTap' import getGitHubRequest from '../../../utils/getGitHubRequest' import getIssueLabels from '../../../utils/githubQueries/getIssueLabels.graphql' import sendToSentry from '../../../utils/sendToSentry' @@ -28,6 +30,30 @@ const Task: Omit, 'replies'> = { return integration?.service ?? null }, + content: async ({content}) => { + // cheaply check to see if it might be draft-js content + if (!content.includes('entityMap')) return content + + // actually check if it's draft-js content + const contentJSON = JSON.parse(content) + if (!isDraftJSContent(contentJSON)) return content + + // this is Draft-JS Content. convert it, save it, send it down + const tipTapContent = convertKnownDraftToTipTap(contentJSON) + const contentStr = JSON.stringify(tipTapContent) + + // HACK we shouldn't be writing to the DB in a query, + // but we're doing it here just until we can migrate all tasks over to TipTap + // const pg = getKysely() + // await pg + // .updateTable('Task') + // .set({ + // content: contentStr + // }) + // .where('id', '=', taskId) + // .execute() + return contentStr + }, createdByUser: ({createdBy}, _args, {dataLoader}) => { return dataLoader.get('users').loadNonNull(createdBy) }, diff --git a/packages/server/integrations/TaskIntegrationManagerFactory.ts b/packages/server/integrations/TaskIntegrationManagerFactory.ts index 44f67180886..a535c686931 100644 --- a/packages/server/integrations/TaskIntegrationManagerFactory.ts +++ b/packages/server/integrations/TaskIntegrationManagerFactory.ts @@ -1,3 +1,4 @@ +import {JSONContent} from '@tiptap/core' import {GraphQLResolveInfo} from 'graphql' import {DataLoaderWorker, GQLContext} from '../graphql/graphql' import {IntegrationProviderServiceEnumType} from '../graphql/types/IntegrationProviderServiceEnum' @@ -26,7 +27,7 @@ export interface TaskIntegrationManager { title: string createTask(params: { - rawContentStr: string + rawContentJSON: JSONContent integrationRepoId: string context?: GQLContext info?: GraphQLResolveInfo diff --git a/packages/server/integrations/github/GitHubServerManager.ts b/packages/server/integrations/github/GitHubServerManager.ts index 19a165492fd..edb19e07a11 100644 --- a/packages/server/integrations/github/GitHubServerManager.ts +++ b/packages/server/integrations/github/GitHubServerManager.ts @@ -1,3 +1,4 @@ +import {JSONContent} from '@tiptap/core' import {GraphQLResolveInfo} from 'graphql' import GitHubIssueId from '../../../client/shared/gqlIds/GitHubIssueId' import GitHubRepoId from '../../../client/shared/gqlIds/GitHubRepoId' @@ -60,16 +61,16 @@ export default class GitHubServerManager implements TaskIntegrationManager { } async createTask({ - rawContentStr, + rawContentJSON, integrationRepoId }: { - rawContentStr: string + rawContentJSON: JSONContent integrationRepoId: string }): Promise { const {repoOwner, repoName} = GitHubRepoId.split(integrationRepoId) const res = await createGitHubTask( - rawContentStr, + rawContentJSON, repoOwner, repoName, this.auth, diff --git a/packages/server/integrations/gitlab/GitLabServerManager.ts b/packages/server/integrations/gitlab/GitLabServerManager.ts index 5a1aa1ca578..b8a9a4dbe86 100644 --- a/packages/server/integrations/gitlab/GitLabServerManager.ts +++ b/packages/server/integrations/gitlab/GitLabServerManager.ts @@ -1,7 +1,7 @@ -import {stateToMarkdown} from 'draft-js-export-markdown' +import {JSONContent} from '@tiptap/core' import {GraphQLResolveInfo} from 'graphql' import GitLabIssueId from 'parabol-client/shared/gqlIds/GitLabIssueId' -import splitDraftContent from 'parabol-client/utils/draftjs/splitDraftContent' +import {splitTipTapContent} from 'parabol-client/shared/tiptap/splitTipTapContent' import {GQLContext} from '../../graphql/graphql' import createIssueMutation from '../../graphql/nestedSchema/GitLab/mutations/createIssue.graphql' import createNote from '../../graphql/nestedSchema/GitLab/mutations/createNote.graphql' @@ -20,6 +20,7 @@ import { GetProjectIssuesQueryVariables, GetProjectsQuery } from '../../types/gitlabTypes' +import {convertTipTapToMarkdown} from '../../utils/convertTipTapToMarkdown' import makeCreateGitLabTaskComment from '../../utils/makeCreateGitLabTaskComment' import {CreateTaskResponse, TaskIntegrationManager} from '../TaskIntegrationManagerFactory' @@ -84,14 +85,14 @@ class GitLabServerManager implements TaskIntegrationManager { } async createTask({ - rawContentStr, + rawContentJSON, integrationRepoId }: { - rawContentStr: string + rawContentJSON: JSONContent integrationRepoId: string }): Promise { - const {title, contentState} = splitDraftContent(rawContentStr) - const description = stateToMarkdown(contentState) as string + const {title, bodyContent} = splitTipTapContent(rawContentJSON) + const description = convertTipTapToMarkdown(bodyContent) const [createIssueData, createIssueError] = await this.createIssue({ title, description, diff --git a/packages/server/integrations/jira/JiraIntegrationManager.ts b/packages/server/integrations/jira/JiraIntegrationManager.ts index 60f3c5f0d06..4f06df1fb46 100644 --- a/packages/server/integrations/jira/JiraIntegrationManager.ts +++ b/packages/server/integrations/jira/JiraIntegrationManager.ts @@ -1,3 +1,4 @@ +import {JSONContent} from '@tiptap/core' import JiraIssueId from 'parabol-client/shared/gqlIds/JiraIssueId' import JiraProjectId from 'parabol-client/shared/gqlIds/JiraProjectId' import createJiraTask from '../../graphql/mutations/helpers/createJiraTask' @@ -35,15 +36,15 @@ export default class JiraIntegrationManager } async createTask({ - rawContentStr, + rawContentJSON, integrationRepoId }: { - rawContentStr: string + rawContentJSON: JSONContent integrationRepoId: string }): Promise { const {cloudId, projectKey} = JiraProjectId.split(integrationRepoId) - const res = await createJiraTask(rawContentStr, cloudId, projectKey, this.auth) + const res = await createJiraTask(rawContentJSON, cloudId, projectKey, this.auth) if (res.error) return res.error diff --git a/packages/server/integrations/jiraServer/JiraServerRestManager.ts b/packages/server/integrations/jiraServer/JiraServerRestManager.ts index daae367d1ed..40c1ed9cbb2 100644 --- a/packages/server/integrations/jiraServer/JiraServerRestManager.ts +++ b/packages/server/integrations/jiraServer/JiraServerRestManager.ts @@ -1,10 +1,12 @@ +import {generateText, JSONContent} from '@tiptap/core' import crypto from 'crypto' import OAuth from 'oauth-1.0a' +import {serverTipTapExtensions} from 'parabol-client/shared/tiptap/serverTipTapExtensions' +import {splitTipTapContent} from 'parabol-client/shared/tiptap/splitTipTapContent' import IntegrationRepoId from '~/shared/gqlIds/IntegrationRepoId' import JiraServerIssueId from '~/shared/gqlIds/JiraServerIssueId' import {ExternalLinks} from '~/types/constEnums' import composeJQL from '~/utils/composeJQL' -import splitDraftContent from '~/utils/draftjs/splitDraftContent' import {IntegrationProviderJiraServer} from '../../postgres/queries/getIntegrationProvidersByIds' import {TeamMemberIntegrationAuth} from '../../postgres/types' import {CreateTaskResponse, TaskIntegrationManager} from '../TaskIntegrationManagerFactory' @@ -285,15 +287,16 @@ export default class JiraServerRestManager implements TaskIntegrationManager { } async createTask({ - rawContentStr, + rawContentJSON, integrationRepoId }: { - rawContentStr: string + rawContentJSON: JSONContent integrationRepoId: string }): Promise { - const {title: summary, contentState} = splitDraftContent(rawContentStr) + const {title: summary, bodyContent} = splitTipTapContent(rawContentJSON) + // TODO: implement stateToJiraServerFormat - const description = contentState.getPlainText() + const description = generateText(bodyContent, serverTipTapExtensions) const {repositoryId} = IntegrationRepoId.split(integrationRepoId) diff --git a/packages/server/package.json b/packages/server/package.json index a2728a15478..3c5ca289a2a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -121,10 +121,12 @@ "mailcomposer": "^4.0.1", "mailgun.js": "^9.3.0", "markdown-draft-js": "^2.4.0", + "md-to-adf": "^0.6.4", "mime-types": "^2.1.16", "ms": "^2.0.0", "nest-graphql-endpoint": "0.8.1", "node-env-flag": "0.1.0", + "node-html-markdown": "^1.3.0", "nodemailer": "^6.9.9", "oauth-1.0a": "^2.2.6", "openai": "^4.53.0", diff --git a/packages/server/safeMutations/archiveTasksForDB.ts b/packages/server/safeMutations/archiveTasksForDB.ts index 18b005cdf88..29bc5831980 100644 --- a/packages/server/safeMutations/archiveTasksForDB.ts +++ b/packages/server/safeMutations/archiveTasksForDB.ts @@ -1,18 +1,17 @@ -import {convertFromRaw, convertToRaw} from 'draft-js' -import addTagToTask from 'parabol-client/utils/draftjs/addTagToTask' -import getTagsFromEntityMap from 'parabol-client/utils/draftjs/getTagsFromEntityMap' +import addTagToTask from '../../client/shared/tiptap/addTagToTask' +import {getTagsFromTipTapTask} from '../../client/shared/tiptap/getTagsFromTipTapTask' import getKysely from '../postgres/getKysely' import {Task} from '../postgres/types/index.d' +import {convertToTipTap} from '../utils/convertToTipTap' const archiveTasksForDB = async (tasks: Task[], doneMeetingId?: string) => { if (!tasks || tasks.length === 0) return [] const pg = getKysely() const tasksToArchive = tasks.map((task) => { - const contentState = convertFromRaw(JSON.parse(task.content)) - const nextContentState = addTagToTask(contentState, '#archived') - const raw = convertToRaw(nextContentState) - const nextTags = getTagsFromEntityMap(raw.entityMap) - const nextContentStr = JSON.stringify(raw) + const content = convertToTipTap(task.content) + const nextContent = addTagToTask(content, 'archived') + const nextTags = getTagsFromTipTapTask(nextContent) + const nextContentStr = JSON.stringify(nextContent) // update cache task.content = nextContentStr diff --git a/packages/server/types/modules.d.ts b/packages/server/types/modules.d.ts index 9b16b604830..c7f7fb31c96 100644 --- a/packages/server/types/modules.d.ts +++ b/packages/server/types/modules.d.ts @@ -20,11 +20,11 @@ declare module '@authenio/samlify-node-xmllint' declare module 'node-env-flag' declare module '*getProjectRoot' declare module 'tayden-clusterfck' -declare module 'unicode-substring' declare module 'jest-extended' declare module 'json2csv/lib/JSON2CSVParser' declare module 'object-hash' declare module 'string-score' +declare module 'md-to-adf' declare const __APP_VERSION__: string declare const __PRODUCTION__: boolean diff --git a/packages/server/utils/AzureDevOpsServerManager.ts b/packages/server/utils/AzureDevOpsServerManager.ts index 0436ff3b676..dace2f5440f 100644 --- a/packages/server/utils/AzureDevOpsServerManager.ts +++ b/packages/server/utils/AzureDevOpsServerManager.ts @@ -1,6 +1,7 @@ +import {JSONContent} from '@tiptap/core' import AzureDevOpsIssueId from 'parabol-client/shared/gqlIds/AzureDevOpsIssueId' import IntegrationHash from 'parabol-client/shared/gqlIds/IntegrationHash' -import splitDraftContent from 'parabol-client/utils/draftjs/splitDraftContent' +import {splitTipTapContent} from 'parabol-client/shared/tiptap/splitTipTapContent' import makeAppURL from 'parabol-client/utils/makeAppURL' import {isError} from 'util' import {ExternalLinks} from '~/types/constEnums' @@ -357,13 +358,13 @@ class AzureDevOpsServerManager implements TaskIntegrationManager { } async createTask({ - rawContentStr, + rawContentJSON, integrationRepoId }: { - rawContentStr: string + rawContentJSON: JSONContent integrationRepoId: string }): Promise { - const {title} = splitDraftContent(rawContentStr) + const {title} = splitTipTapContent(rawContentJSON) const {instanceId, projectId} = AzureDevOpsProjectId.split(integrationRepoId) const issueRes = await this.createIssue({title, instanceId, projectId}) if (issueRes instanceof Error) return issueRes diff --git a/packages/server/utils/convertContentStateToADF.ts b/packages/server/utils/convertContentStateToADF.ts deleted file mode 100644 index 90c4e988a93..00000000000 --- a/packages/server/utils/convertContentStateToADF.ts +++ /dev/null @@ -1,258 +0,0 @@ -import {ContentBlock, ContentState} from 'draft-js' -import {Constants, getEntityRanges} from 'draft-js-utils' -import {isNotNull} from '../../client/utils/predicates' - -const {INLINE_STYLE, BLOCK_TYPE, ENTITY_TYPE} = Constants - -interface Mark { - type: string - attrs?: Record -} - -interface Text { - type: 'text' - text: string - marks?: Mark[] -} - -type InlineNode = - | { - type: 'emoji' | 'hardBreak' | 'inlineCard' | 'mention' - } - | Text - -type Content = (Node | InlineNode)[] - -interface Node { - type: string - attrs?: Record - content?: Content -} - -export interface Doc { - version: 1 - type: 'doc' - content: Content -} - -class ContentStateToADFConverter { - private contentState: ContentState - - private renderText = (text: string, marks?: Mark[]): Text => ({ - type: 'text', - text, - ...(marks?.length ? {marks} : {}) - }) - - private renderContent = (block: ContentBlock): Node[] => { - const text = block.getText() - const charMetaList = block.getCharacterList() - const entityPieces = getEntityRanges(text, charMetaList) as [ - [entityKey: string, stylePieces: [text: string, style: Set][]] - ] - const content = entityPieces.flatMap(([entityKey, stylePieces]) => { - const contentPieces: Text[] = stylePieces - .map(([text, style]) => { - if (!text) { - return null - } - - if (style.has(INLINE_STYLE.CODE)) { - return { - type: 'text' as const, - text, - marks: [ - { - type: 'code' - } - ] - } - } - const marks: Mark[] = [] - if (style.has(INLINE_STYLE.BOLD)) { - marks.push({ - type: 'strong' - }) - } - if (style.has(INLINE_STYLE.UNDERLINE)) { - marks.push({ - type: 'underline' - }) - } - if (style.has(INLINE_STYLE.ITALIC)) { - marks.push({ - type: 'em' - }) - } - if (style.has(INLINE_STYLE.STRIKETHROUGH)) { - marks.push({ - type: 'strike' - }) - } - return this.renderText(text, marks) - }) - .filter(isNotNull) - - const entity = entityKey ? this.contentState.getEntity(entityKey) : null - if (entity && entity.getType() === ENTITY_TYPE.LINK) { - const data = entity.getData() - const mark = { - type: 'link', - attrs: { - href: data.href || data.url || '', - ...(data.title ? {title: data.title} : {}) - } - } - return contentPieces.map((piece) => ({ - ...piece, - marks: [mark, ...(piece.marks ?? [])] - })) - } - /* - TODO: In order to show images or other embedded types, we need to upload those separately to Jira and then refer to them by id - else if (entity?.getType() === ENTITY_TYPE.IMAGE) { - const data = entity.getData(); - const src = data.src || ''; - const alt = data.alt ? `${escapeTitle(data.alt)}` : ''; - return ... - } else if (entity?.getType() === ENTITY_TYPE.EMBED) { - const data = entity.getData() - const url = data.url || content; - return ... - } - */ - - return contentPieces - }) - return content - } - - private renderHeader = (block: ContentBlock, level: number): Node => ({ - type: 'heading', - attrs: { - level - }, - content: this.renderContent(block) - }) - - constructor(contentState: ContentState) { - this.contentState = contentState - } - - generate = (): Doc => { - const blocks = this.contentState.getBlocksAsArray() - - // list items need to be nested, so store them and add them to the list once a non-list item is found - let currentListItems: { - type: 'listItem' - content: any[] - }[] = [] - let currentListType: string | null = null - const flushList = () => { - const type = currentListType === BLOCK_TYPE.UNORDERED_LIST_ITEM ? 'bulletList' : 'orderedList' - const content = currentListItems - currentListItems = [] - currentListType = null - return { - type, - content - } - } - - const content = blocks - .map((block) => { - const blockType = block.getType() - const text = block.getText() - if (!text) { - return null - } - - if (blockType === currentListType) { - currentListItems.push({ - type: 'listItem', - // Jira wants a top level element nested in the listItem - content: [ - { - type: 'paragraph', - content: this.renderContent(block) - } - ] - }) - // filter out later - return null - } - if (currentListType) { - return flushList() - } - if ( - blockType === BLOCK_TYPE.UNORDERED_LIST_ITEM || - blockType === BLOCK_TYPE.ORDERED_LIST_ITEM - ) { - currentListType = blockType - currentListItems = [ - { - type: 'listItem', - // Jira wants a top level element nested in the listItem - content: [ - { - type: 'paragraph', - content: this.renderContent(block) - } - ] - } - ] - // filter out later - return null - } - - switch (blockType) { - case BLOCK_TYPE.HEADER_ONE: - return this.renderHeader(block, 1) - case BLOCK_TYPE.HEADER_TWO: - return this.renderHeader(block, 2) - case BLOCK_TYPE.HEADER_THREE: - return this.renderHeader(block, 3) - case BLOCK_TYPE.HEADER_FOUR: - return this.renderHeader(block, 4) - case BLOCK_TYPE.HEADER_FIVE: - return this.renderHeader(block, 5) - case BLOCK_TYPE.HEADER_SIX: - return this.renderHeader(block, 6) - case BLOCK_TYPE.BLOCKQUOTE: - return { - type: 'blockquote', - content: this.renderContent(block) - } - case BLOCK_TYPE.CODE: - return { - type: 'codeBlock', - content: this.renderContent(block) - } - case BLOCK_TYPE.ATOMIC: - case BLOCK_TYPE.UNSTYLED: - default: - return { - type: 'paragraph', - content: this.renderContent(block) - } - } - }) - .filter((contentBlock) => contentBlock !== null) as Node[] - // flush the list if the document stopped with one - if (currentListType) { - content.push(flushList()) - } - - return { - version: 1, - type: 'doc', - content - } - } -} - -const convertContentStateToADF = (contentState: ContentState) => { - return new ContentStateToADFConverter(contentState).generate() -} - -export default convertContentStateToADF diff --git a/packages/server/utils/convertTipTapToADF.ts b/packages/server/utils/convertTipTapToADF.ts new file mode 100644 index 00000000000..8bf5e6d29ed --- /dev/null +++ b/packages/server/utils/convertTipTapToADF.ts @@ -0,0 +1,8 @@ +import {JSONContent} from '@tiptap/core' +import fnTranslate from 'md-to-adf' +import {convertTipTapToMarkdown} from './convertTipTapToMarkdown' +export const convertTipTapToADF = (content: JSONContent) => { + const markdown = convertTipTapToMarkdown(content) + const adf = fnTranslate(markdown) + return adf +} diff --git a/packages/server/utils/convertTipTapToMarkdown.ts b/packages/server/utils/convertTipTapToMarkdown.ts new file mode 100644 index 00000000000..4a5d4617971 --- /dev/null +++ b/packages/server/utils/convertTipTapToMarkdown.ts @@ -0,0 +1,9 @@ +import {JSONContent} from '@tiptap/core' +import {generateHTML} from '@tiptap/html' +import {NodeHtmlMarkdown} from 'node-html-markdown' +import {serverTipTapExtensions} from 'parabol-client/shared/tiptap/serverTipTapExtensions' +export const convertTipTapToMarkdown = (content: JSONContent) => { + const html = generateHTML(content, serverTipTapExtensions) + const markdown = NodeHtmlMarkdown.translate(html) + return markdown +} diff --git a/packages/server/utils/convertToTipTap.ts b/packages/server/utils/convertToTipTap.ts index ef106a01d5b..5b772cdb015 100644 --- a/packages/server/utils/convertToTipTap.ts +++ b/packages/server/utils/convertToTipTap.ts @@ -5,30 +5,76 @@ import {Paragraph} from '@tiptap/extension-paragraph' import {Text} from '@tiptap/extension-text' import {generateJSON} from '@tiptap/html' import {convertFromRaw, RawDraftContentState} from 'draft-js' -import {stateToHTML} from 'draft-js-export-html' +import {Options, stateToHTML} from 'draft-js-export-html' +import {isDraftJSContent} from '../../client/shared/tiptap/isDraftJSContent' +import {serverTipTapExtensions} from '../../client/shared/tiptap/serverTipTapExtensions' +import {Logger} from './Logger' -const isDraftJSContent = ( - content: JSONContent | RawDraftContentState -): content is RawDraftContentState => { - return 'blocks' in content +const getNameFromEntity = (content: RawDraftContentState, userId: string) => { + const {blocks, entityMap} = content + const entityKey = Number( + Object.keys(entityMap).find((key) => entityMap[key]!.data?.userId === userId) + ) + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]! + const {entityRanges, text} = block + const entityRange = entityRanges.find((range) => range.key === entityKey) + if (!entityRange) continue + const {length, offset} = entityRange + return text.slice(offset, offset + length) + } + Logger.log('found unknown for', userId, JSON.stringify(content)) + return 'Unknown User' } +export const convertKnownDraftToTipTap = (content: RawDraftContentState) => { + const contentState = convertFromRaw(content) + // TODO: blockRenderers! convert entity map to match what tiptap expects + const options: Options = { + entityStyleFn: (entity) => { + const entityType = entity.getType().toLowerCase() + const data = entity.getData() + if (entityType === 'tag') { + return { + element: 'span', + attributes: { + 'data-id': data.value, + 'data-type': 'taskTag' + } + } + } + // TODO FIX ME WHEN WE DO THE CONVERSION + if (entityType === 'mention') { + const label = getNameFromEntity(content, data.userId) + return { + element: 'span', + attributes: { + 'data-id': data.userId, + 'data-label': label, + 'data-type': 'mention' + } + } + } + return + } + } + const html = stateToHTML(contentState, options) + const json = generateJSON(html, serverTipTapExtensions) as JSONContent + return json +} export const convertToTipTap = (contentStr: string | null | undefined) => { // TODO: add extensions to handle tags and mentions const tipTapExtensions = [Document, Paragraph, Text, Bold] if (!contentStr) { // return an empty str - return generateJSON(`

`, tipTapExtensions) + return generateJSON(`

`, tipTapExtensions) as JSONContent } let parsedContent: JSONContent | RawDraftContentState try { parsedContent = JSON.parse(contentStr) } catch (e) { - return generateJSON(`

`, tipTapExtensions) + return generateJSON(`

`, tipTapExtensions) as JSONContent } if (!isDraftJSContent(parsedContent)) return parsedContent - const contentState = convertFromRaw(parsedContent) - // TODO: blockRenderers! convert entity map to match what tiptap expects - const html = stateToHTML(contentState) - return generateJSON(html, tipTapExtensions) as JSONContent + return convertKnownDraftToTipTap(parsedContent) } diff --git a/packages/server/utils/makeCreateJiraTaskComment.ts b/packages/server/utils/makeCreateJiraTaskComment.ts index 76ca9e670d9..2d2e61184d0 100644 --- a/packages/server/utils/makeCreateJiraTaskComment.ts +++ b/packages/server/utils/makeCreateJiraTaskComment.ts @@ -1,5 +1,35 @@ import {ExternalLinks} from '../../client/types/constEnums' -import {Doc} from './convertContentStateToADF' + +interface Mark { + type: string + attrs?: Record +} + +interface Text { + type: 'text' + text: string + marks?: Mark[] +} + +type InlineNode = + | { + type: 'emoji' | 'hardBreak' | 'inlineCard' | 'mention' + } + | Text + +type Content = (Node | InlineNode)[] + +interface Node { + type: string + attrs?: Record + content?: Content +} + +export interface Doc { + version: 1 + type: 'doc' + content: Content +} const makeCreateJiraTaskComment = ( creator: string, diff --git a/packages/server/utils/makeScoreJiraComment.ts b/packages/server/utils/makeScoreJiraComment.ts index 69c88ce86ad..494c9734443 100644 --- a/packages/server/utils/makeScoreJiraComment.ts +++ b/packages/server/utils/makeScoreJiraComment.ts @@ -1,5 +1,5 @@ import {ExternalLinks} from '../../client/types/constEnums' -import {Doc} from './convertContentStateToADF' +import {Doc} from './makeCreateJiraTaskComment' const makeScoreJiraComment = ( dimensionName: string, diff --git a/packages/server/utils/serverTipTapExtensions.ts b/packages/server/utils/serverTipTapExtensions.ts deleted file mode 100644 index 8b57092a503..00000000000 --- a/packages/server/utils/serverTipTapExtensions.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {mergeAttributes} from '@tiptap/core' -import BaseLink from '@tiptap/extension-link' -import Mention from '@tiptap/extension-mention' -import StarterKit from '@tiptap/starter-kit' -import {LoomExtension} from '../../client/components/promptResponse/loomExtension' - -export const serverTipTapExtensions = [ - StarterKit, - LoomExtension, - Mention.configure({ - renderText({node}) { - return node.attrs.label - } - }), - BaseLink.extend({ - parseHTML() { - return [{tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])'}] - }, - - renderHTML({HTMLAttributes}) { - return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {class: 'link'}), 0] - } - }) -] diff --git a/scripts/webpack/dev.clientdll.config.js b/scripts/webpack/dev.clientdll.config.js index ecb3715e706..b63ba59b9bb 100644 --- a/scripts/webpack/dev.clientdll.config.js +++ b/scripts/webpack/dev.clientdll.config.js @@ -49,8 +49,7 @@ module.exports = { 'string-score', 'tayden-clusterfck', 'tlds', - 'tslib', - 'unicode-substring' + 'tslib' ] }, resolve: { diff --git a/yarn.lock b/yarn.lock index 6ee701b2afa..10c58f7e7bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8690,6 +8690,11 @@ addressparser@1.0.1: resolved "https://registry.yarnpkg.com/addressparser/-/addressparser-1.0.1.tgz#47afbe1a2a9262191db6838e4fd1d39b40821746" integrity sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg== +adf-builder@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/adf-builder/-/adf-builder-3.3.0.tgz#23d69266486b403727f130ae2217a262f7be78a5" + integrity sha512-zl5Sk2HtJk/E+gBnD0g51w5wIEEujaAbzsBhSsteO8Fnef7v+7pvas0FeQuLCAgOxrwy945HWAB1gQ4yjPI3/g== + agent-base@6, agent-base@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -13621,7 +13626,7 @@ hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: dependencies: function-bind "^1.1.2" -he@^1.2.0: +he@1.2.0, he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== @@ -16389,6 +16394,11 @@ markdown-it@^14.0.0, markdown-it@^14.1.0: punycode.js "^2.3.1" uc.micro "^2.1.0" +marked@^0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/marked/-/marked-0.8.2.tgz#4faad28d26ede351a7a1aaa5fec67915c869e355" + integrity sha512-EGwzEeCcLniFX51DhTpmTom+dSA/MG/OBUDjnWtHbEnjAH180VzUeAw+oE4+Zv+CoYBWyRlYOTR0N8SO9R1PVw== + marked@^13.0.3: version "13.0.3" resolved "https://registry.yarnpkg.com/marked/-/marked-13.0.3.tgz#5c5b4a5d0198060c7c9bc6ef9420a7fed30f822d" @@ -16399,6 +16409,14 @@ marked@^4.3.0: resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3" integrity sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A== +md-to-adf@^0.6.4: + version "0.6.4" + resolved "https://registry.yarnpkg.com/md-to-adf/-/md-to-adf-0.6.4.tgz#c402d9a1ed570189a39d5ad200c1c88ecbdbd5e4" + integrity sha512-MynWm2w+adyzeaCJGXoOjgHAAhql0I9jR2Bq55hM7GQ05u6cGX7jThdWN6dgVSGnBmuAGmZGy2rDGBP+hJRckA== + dependencies: + adf-builder "^3.3.0" + marked "^0.8.2" + md5@^2.2.1: version "2.3.0" resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f" @@ -17045,6 +17063,21 @@ node-gyp@^8.4.1, node-gyp@^9.0.0: tar "^6.1.2" which "^2.0.2" +node-html-markdown@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/node-html-markdown/-/node-html-markdown-1.3.0.tgz#ef0b19a3bbfc0f1a880abb9ff2a0c9aa6bbff2a9" + integrity sha512-OeFi3QwC/cPjvVKZ114tzzu+YoR+v9UXW5RwSXGUqGb0qCl0DvP406tzdL7SFn8pZrMyzXoisfG2zcuF9+zw4g== + dependencies: + node-html-parser "^6.1.1" + +node-html-parser@^6.1.1: + version "6.1.13" + resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-6.1.13.tgz#a1df799b83df5c6743fcd92740ba14682083b7e4" + integrity sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg== + dependencies: + css-select "^5.1.0" + he "1.2.0" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -22179,11 +22212,6 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== -unicode-substring@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unicode-substring/-/unicode-substring-1.0.0.tgz#659fb839078e7bee84b86c27210ac4db215bf885" - integrity sha512-2acGIOTaqS/GWocwKdyL1Vk9MHglCss1mR0CL2o/YJTwKrAt6JbTrw4X187VkSDmFcpJ8n2i3/+gJSYEdvXJMg== - unique-filename@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230"