Skip to content

Commit

Permalink
change UI to use TipTap for checkinquestion
Browse files Browse the repository at this point in the history
Signed-off-by: Matt Krick <[email protected]>
  • Loading branch information
mattkrick committed Nov 27, 2024
1 parent e8bedff commit de2d34b
Show file tree
Hide file tree
Showing 6 changed files with 100 additions and 108 deletions.
42 changes: 42 additions & 0 deletions packages/client/hooks/useTipTapIcebreakerEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Mention from '@tiptap/extension-mention'
import Placeholder from '@tiptap/extension-placeholder'
import {Extension, generateText, useEditor} from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import {serverTipTapExtensions} from '../shared/tiptap/serverTipTapExtensions'
import {tiptapEmojiConfig} from '../utils/tiptapEmojiConfig'
import {useTipTapEditorContent} from './useTipTapEditorContent'

export const useTipTapIcebreakerEditor = (content: string, options: {readOnly?: boolean}) => {
const {readOnly} = options
const [contentJSON, editorRef] = useTipTapEditorContent(content)
editorRef.current = useEditor(
{
content: contentJSON,
extensions: [
StarterKit,
Placeholder.configure({
showOnlyWhenEditable: false,
placeholder: 'e.g. How are you?'
}),
Mention.extend({name: 'emojiMention'}).configure(tiptapEmojiConfig),
Extension.create({
name: 'blurOnSubmit',
addKeyboardShortcuts(this) {
const submit = () => {
this.editor.commands.blur()
return true
}
return {
Enter: submit,
Tab: submit
}
}
})
],
editable: !readOnly,
autofocus: generateText(contentJSON, serverTipTapExtensions).length === 0
},
[contentJSON, readOnly]
)
return {editor: editorRef.current}
}
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
import styled from '@emotion/styled'
import {Create as CreateIcon, Refresh as RefreshIcon} from '@mui/icons-material'
import {EditorContent} from '@tiptap/react'
import graphql from 'babel-plugin-relay/macro'
import {ContentState, EditorState, SelectionState, convertToRaw} from 'draft-js'
import {useRef, useState} from 'react'
import {useState} from 'react'
import {useFragment} from 'react-relay'
import {NewCheckInQuestion_meeting$key} from '~/__generated__/NewCheckInQuestion_meeting.graphql'
import {
ModifyType,
useModifyCheckInQuestionMutation$data as TModifyCheckInQuestion$data
} from '../../../../__generated__/useModifyCheckInQuestionMutation.graphql'
import EditorInputWrapper from '../../../../components/EditorInputWrapper'
import PlainButton from '../../../../components/PlainButton/PlainButton'
import '../../../../components/TaskEditor/Draft.css'
import useAtmosphere from '../../../../hooks/useAtmosphere'
import {MenuPosition} from '../../../../hooks/useCoords'
import useEditorState from '../../../../hooks/useEditorState'
import useMutationProps from '../../../../hooks/useMutationProps'
import useTooltip from '../../../../hooks/useTooltip'
import {useTipTapIcebreakerEditor} from '../../../../hooks/useTipTapIcebreakerEditor'
import UpdateNewCheckInQuestionMutation from '../../../../mutations/UpdateNewCheckInQuestionMutation'
import {useModifyCheckInQuestionMutation} from '../../../../mutations/useModifyCheckInQuestionMutation'
import {convertTipTapTaskContent} from '../../../../shared/tiptap/convertTipTapTaskContent'
import {PALETTE} from '../../../../styles/paletteV3'
import {Button} from '../../../../ui/Button/Button'
import convertToTaskContent from '../../../../utils/draftjs/convertToTaskContent'
import {Tooltip} from '../../../../ui/Tooltip/Tooltip'
import {TooltipContent} from '../../../../ui/Tooltip/TooltipContent'
import {TooltipTrigger} from '../../../../ui/Tooltip/TooltipTrigger'

const CogIcon = styled('div')({
color: PALETTE.SLATE_700,
Expand Down Expand Up @@ -66,7 +66,6 @@ interface Props {
}

const NewCheckInQuestion = (props: Props) => {
const editorRef = useRef<HTMLTextAreaElement>()
const atmosphere = useAtmosphere()
const {meeting: meetingRef} = props
const meeting = useFragment(
Expand Down Expand Up @@ -94,7 +93,6 @@ const NewCheckInQuestion = (props: Props) => {
`,
meetingRef
)
const [isEditing, setIsEditing] = useState(false)
const [aiUpdatedIcebreaker, setAiUpdatedIcebreaker] = useState('')
const {
id: meetingId,
Expand All @@ -108,19 +106,16 @@ const NewCheckInQuestion = (props: Props) => {
const {viewerId} = atmosphere
const isFacilitating = facilitatorUserId === viewerId

const [editorState, setEditorState] = useEditorState(checkInQuestion)
const {editor} = useTipTapIcebreakerEditor(checkInQuestion || convertTipTapTaskContent(''), {
readOnly: !isFacilitating
})
const {submitting, submitMutation, onCompleted, onError} = useMutationProps()

const updateQuestion = (nextEditorState: EditorState) => {
const wasFocused = editorState.getSelection().getHasFocus()
const isFocused = nextEditorState.getSelection().getHasFocus()
setIsEditing(isFocused)
if (wasFocused && !isFocused) {
const nextContent = nextEditorState.getCurrentContent()
const nextCheckInQuestion = nextContent.hasText()
? JSON.stringify(convertToRaw(nextContent))
: ''

const updateQuestion = () => {
if (!editor) return
const {isFocused} = editor
if (!isFocused) {
const nextCheckInQuestion = JSON.stringify(editor.getJSON())
if (nextCheckInQuestion === checkInQuestion) return
UpdateNewCheckInQuestionMutation(
atmosphere,
Expand All @@ -131,52 +126,12 @@ const NewCheckInQuestion = (props: Props) => {
{onCompleted, onError}
)
}
setEditorState(nextEditorState)
}

// Handles question update for android devices.
const updateQuestionAndroidFallback = () => {
const currentText = editorRef.current?.value
const nextCheckInQuestion = convertToTaskContent(currentText || '')
if (nextCheckInQuestion === checkInQuestion) return
UpdateNewCheckInQuestionMutation(
atmosphere,
{
meetingId,
checkInQuestion: nextCheckInQuestion
},
{onCompleted, onError}
)
}

const {
tooltipPortal: editIcebreakerTooltipPortal,
openTooltip: openEditIcebreakerTooltip,
closeTooltip: closeEditIcebreakerTooltip,
originRef: editIcebreakerOriginRef
} = useTooltip<HTMLButtonElement>(MenuPosition.UPPER_CENTER, {
disabled: isEditing || !isFacilitating
})
const {
tooltipPortal: refreshIcebreakerTooltipPortal,
openTooltip: openRefreshIcebreakerTooltip,
closeTooltip: closeRefreshIcebreakerTooltip,
originRef: refreshIcebreakerOriginRef
} = useTooltip<HTMLButtonElement>(MenuPosition.UPPER_CENTER, {
disabled: !isFacilitating
})

const focusQuestion = () => {
closeEditIcebreakerTooltip()
editorRef.current && editorRef.current.focus()
const selection = editorState.getSelection()
const contentState = editorState.getCurrentContent()
const jumpToEnd = (selection as any).merge({
anchorOffset: contentState.getLastBlock().getLength(),
focusOffset: contentState.getLastBlock().getLength()
}) as SelectionState
const nextEditorState = EditorState.forceSelection(editorState, jumpToEnd)
setEditorState(nextEditorState)
if (!editor) return
editor?.commands.focus('all')
editor?.commands.selectAll()
}

const refresh = () => {
Expand All @@ -197,9 +152,7 @@ const NewCheckInQuestion = (props: Props) => {
atmosphere,
{
meetingId,
checkInQuestion: JSON.stringify(
convertToRaw(ContentState.createFromText(aiUpdatedIcebreaker))
)
checkInQuestion: convertTipTapTaskContent(aiUpdatedIcebreaker)
},
{onCompleted, onError}
)
Expand Down Expand Up @@ -232,39 +185,29 @@ const NewCheckInQuestion = (props: Props) => {
<>
<QuestionBlock id='test'>
{/* cannot set min width because iPhone 5 has a width of 320*/}
<EditorInputWrapper
ariaLabel={'Edit the icebreaker'}
editorState={editorState}
setEditorState={updateQuestion}
readOnly={!isFacilitating}
placeholder='e.g. How are you?'
editorRef={editorRef}
setEditorStateFallback={updateQuestionAndroidFallback}
/>
<EditorContent editor={editor} onBlur={updateQuestion} />
{isFacilitating && (
<div className='flex gap-x-2'>
<PlainButton
aria-label={'Edit icebreaker'}
onClick={focusQuestion}
onMouseEnter={openEditIcebreakerTooltip}
onMouseLeave={closeEditIcebreakerTooltip}
ref={editIcebreakerOriginRef}
>
<CogIcon>
<CreateIcon />
</CogIcon>
</PlainButton>
<PlainButton
aria-label={'Refresh icebreaker'}
onClick={refresh}
onMouseEnter={openRefreshIcebreakerTooltip}
onMouseLeave={closeRefreshIcebreakerTooltip}
ref={refreshIcebreakerOriginRef}
>
<CogIcon>
<RefreshIcon />
</CogIcon>
</PlainButton>
<Tooltip open={isFacilitating ? undefined : false}>
<TooltipTrigger asChild>
<PlainButton aria-label={'Edit icebreaker'} onClick={focusQuestion}>
<CogIcon>
<CreateIcon />
</CogIcon>
</PlainButton>
</TooltipTrigger>
<TooltipContent side={'bottom'}>Edit icebreaker</TooltipContent>
</Tooltip>
<Tooltip open={isFacilitating ? undefined : false}>
<TooltipTrigger asChild>
<PlainButton aria-label={'Refresh icebreaker'} onClick={refresh}>
<CogIcon>
<RefreshIcon />
</CogIcon>
</PlainButton>
</TooltipTrigger>
<TooltipContent side={'bottom'}>Refresh icebreaker</TooltipContent>
</Tooltip>
</div>
)}
</QuestionBlock>
Expand Down Expand Up @@ -322,8 +265,6 @@ const NewCheckInQuestion = (props: Props) => {
</div>
</div>
)}
{editIcebreakerTooltipPortal(<>Edit icebreaker</>)}
{refreshIcebreakerTooltipPortal(<>Refresh icebreaker</>)}
</>
)
}
Expand Down
4 changes: 2 additions & 2 deletions packages/server/database/types/CheckInPhase.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import convertToTaskContent from 'parabol-client/utils/draftjs/convertToTaskContent'
import {makeCheckinGreeting, makeCheckinQuestion} from 'parabol-client/utils/makeCheckinGreeting'
import {convertTipTapTaskContent} from '../../../client/shared/tiptap/convertTipTapTaskContent'
import CheckInStage from './CheckInStage'
import GenericMeetingPhase from './GenericMeetingPhase'

Expand All @@ -18,7 +18,7 @@ export default class CheckInPhase extends GenericMeetingPhase {
super('checkin')
const {teamId, meetingCount, stages} = input
this.checkInGreeting = makeCheckinGreeting(meetingCount, teamId)
this.checkInQuestion = convertToTaskContent(makeCheckinQuestion(meetingCount, teamId))
this.checkInQuestion = convertTipTapTaskContent(makeCheckinQuestion(meetingCount, teamId))
this.stages = stages
}
}
9 changes: 4 additions & 5 deletions packages/server/graphql/mutations/updateNewCheckInQuestion.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import {GraphQLID, GraphQLNonNull, GraphQLString} from 'graphql'
import {SubscriptionChannel} from 'parabol-client/types/constEnums'
import convertToTaskContent from 'parabol-client/utils/draftjs/convertToTaskContent'
import {makeCheckinQuestion} from 'parabol-client/utils/makeCheckinGreeting'
import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS'
import {convertTipTapTaskContent} from '../../../client/shared/tiptap/convertTipTapTaskContent'
import getKysely from '../../postgres/getKysely'
import {getUserId, isTeamMember} from '../../utils/authorization'
import getPhase from '../../utils/getPhase'
Expand Down Expand Up @@ -46,9 +45,9 @@ export default {
return {error: {message: 'Meeting has already ended'}}
}
// VALIDATION
const normalizedCheckInQuestion = checkInQuestion
? normalizeRawDraftJS(checkInQuestion)
: convertToTaskContent(makeCheckinQuestion(Math.floor(Math.random() * 1000), teamId))
const normalizedCheckInQuestion =
checkInQuestion ||
convertTipTapTaskContent(makeCheckinQuestion(Math.floor(Math.random() * 1000), teamId))

// RESOLUTION
const checkInPhase = getPhase(phases, 'checkin')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type CheckInPhase implements NewMeetingPhase {
checkInGreeting: MeetingGreeting!

"""
The checkIn question of the week (draft-js format)
The checkIn question of the week (Stringified TipTap JSON format)
"""
checkInQuestion: String!
}
12 changes: 11 additions & 1 deletion packages/server/graphql/public/types/CheckInPhase.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import {isDraftJSContent} from '../../../../client/shared/tiptap/isDraftJSContent'
import {convertKnownDraftToTipTap} from '../../../utils/convertToTipTap'
import {CheckInPhaseResolvers} from '../resolverTypes'

const CheckInPhase: CheckInPhaseResolvers = {
__isTypeOf: ({phaseType}) => phaseType === 'checkin'
__isTypeOf: ({phaseType}) => phaseType === 'checkin',
checkInQuestion: async ({checkInQuestion}) => {
const contentJSON = JSON.parse(checkInQuestion)
if (!isDraftJSContent(contentJSON)) return checkInQuestion
// this is Draft-JS Content. convert it and send it down
// We can get rid of this resolver once we migrate legacy draft-js content to TipTap
const tipTapContent = convertKnownDraftToTipTap(contentJSON)
return JSON.stringify(tipTapContent)
}
}

export default CheckInPhase

0 comments on commit de2d34b

Please sign in to comment.