Skip to content

Commit

Permalink
feat(Mattermost Plugin): TipTap Editor for Task and Reflection (#10796)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dschoordsch authored Feb 5, 2025
1 parent 9c1df05 commit 9e4a14e
Show file tree
Hide file tree
Showing 20 changed files with 361 additions and 126 deletions.
2 changes: 1 addition & 1 deletion packages/client/shared/tiptap/addTagToTask.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {JSONContent} from '@tiptap/core'
import {TaskTag} from 'parabol-server/postgres/types'
import {TaskTag} from '../types'

const addTagToTask = (content: JSONContent, tag: TaskTag) => {
content.content!.push({
Expand Down
2 changes: 1 addition & 1 deletion packages/client/shared/tiptap/convertTipTapTaskContent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {generateJSON} from '@tiptap/html'
import {TaskTag} from 'parabol-server/postgres/types'
import {serverTipTapExtensions} from '~/shared/tiptap/serverTipTapExtensions'
import {TaskTag} from '../types'

export const convertTipTapTaskContent = (text: string, tags?: TaskTag[]) => {
let body = `<p>${text}</p>`
Expand Down
1 change: 1 addition & 0 deletions packages/client/shared/types/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type TaskTag = 'private' | 'archived'
33 changes: 18 additions & 15 deletions packages/mattermost-plugin/Atmosphere.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ const fetchGraphQL = (state: State) => (params: RequestParameters, variables: Va
)
}

const login = (state: State) => async () => {
const {serverUrl, store} = state
const response = await fetch(
serverUrl + '/login',
Client4.getOptions({
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
)
const body = await response.json()
store.dispatch(onLogin(body.authToken))
}

const relayFieldLogger: RelayFieldLogger = (event) => {
if (event.kind === 'relay_resolver.error') {
console.warn(`Resolver error encountered in ${event.owner}.${event.fieldPath}`)
Expand All @@ -67,6 +82,7 @@ export type ResolverContext = {

export class Atmosphere extends Environment {
state: State
login: () => Promise<void>

constructor(serverUrl: string, reduxStore: Store<GlobalState, AnyAction>) {
const state = {
Expand All @@ -88,21 +104,8 @@ export class Atmosphere extends Environment {
relayFieldLogger
})
this.state = state
}

async login() {
const {serverUrl, store} = this.state
const response = await fetch(
serverUrl + '/login',
Client4.getOptions({
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
)
const body = await response.json()
store.dispatch(onLogin(body.authToken))
// bind it here to avoid this == undefined errors
this.login = login(state)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import {closeCreateTaskModal} from '../../reducers'
import Select from '../Select'
import SimpleSelect from '../SimpleSelect'

import {TipTapEditor} from 'parabol-client/components/promptResponse/TipTapEditor'
import useEventCallback from 'parabol-client/hooks/useEventCallback'
import {convertTipTapTaskContent} from 'parabol-client/shared/tiptap/convertTipTapTaskContent'
import type {TaskStatusEnum} from '../../__generated__/CreateTaskModalMutation.graphql'
import {CreateTaskModalMutation} from '../../__generated__/CreateTaskModalMutation.graphql'
import {CreateTaskModalQuery} from '../../__generated__/CreateTaskModalQuery.graphql'
import {useTipTapTaskEditor} from '../../hooks/useTipTapTaskEditor'
import LoadingSpinner from '../LoadingSpinner'
import Modal from '../Modal'

Expand All @@ -21,6 +25,7 @@ const CreateTaskModal = () => {
graphql`
query CreateTaskModalQuery {
viewer {
id
teams {
id
name
Expand All @@ -37,7 +42,7 @@ const CreateTaskModal = () => {
)

const {viewer} = data
const {teams} = viewer
const {id: userId, teams} = viewer

const [createTask, createTaskLoading] = useMutation<CreateTaskModalMutation>(graphql`
mutation CreateTaskModalMutation($newTask: CreateTaskInput!) {
Expand All @@ -52,7 +57,6 @@ const CreateTaskModal = () => {
}
`)

const [description, setDescription] = useState('')
const [selectedTeam, setSelectedTeam] = useState<NonNullable<typeof teams>[number]>()
const [selectedStatus, setSelectedStatus] = useState<TaskStatusEnum>('active')

Expand All @@ -61,68 +65,60 @@ const CreateTaskModal = () => {
setSelectedTeam(teams[0])
}
}, [teams, selectedTeam])
const teamId = selectedTeam?.id

const dispatch = useDispatch()
const handleClose = () => {
dispatch(closeCreateTaskModal())
}

const handleStart = async () => {
if (!selectedTeam || !description || !selectedStatus) {
const handleSubmit = useEventCallback(async () => {
if (!teamId || !selectedStatus || !editor || editor.isEmpty) {
return
}
if (createTaskLoading) {
return
}

// TODO: let's cheat our way to new task content for now
const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
text: description,
type: 'text'
}
]
}
]
}
const content = editor.getJSON()

createTask({
variables: {
newTask: {
content: JSON.stringify(content),
status: selectedStatus,
teamId: selectedTeam.id
userId,
teamId
}
}
})

handleClose()
})

const {editor} = useTipTapTaskEditor(convertTipTapTaskContent(''))
if (!editor) {
return null
}

return (
<Modal
title='Add a Task'
commitButtonLabel='Add Task'
handleClose={handleClose}
handleCommit={handleStart}
handleCommit={handleSubmit}
>
<div className='absolute top-0 left-0 z-10 z-1050' />
<div className='form-group'>
<label className='control-label' htmlFor='description'>
Description<span className='error-text'> *</span>
</label>
<textarea
style={{
width: '100%'
}}
{/* className='channel-switch-modal' is a hack to not lose focus on key press, see
https://github.com/mattermost/mattermost/blob/dc06bb21558aca05dbe330f25459528b39247c32/webapp/channels/src/components/advanced_text_editor/use_textbox_focus.tsx#L63 */}
<TipTapEditor
id='description'
className='form-control'
value={description}
onChange={(e) => setDescription(e.target.value)}
className='channel-switch-modal form-control h-auto min-h-32 p-2'
editor={editor}
placeholder='Description'
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import {generateJSON, mergeAttributes} from '@tiptap/core'
import BaseLink from '@tiptap/extension-link'
import StarterKit from '@tiptap/starter-kit'
import graphql from 'babel-plugin-relay/macro'
import {marked} from 'marked'
import {getPost} from 'mattermost-redux/selectors/entities/posts'
import {GlobalState} from 'mattermost-redux/types/store'
import {TipTapEditor} from 'parabol-client/components/promptResponse/TipTapEditor'
import React, {useEffect, useMemo} from 'react'
import {useDispatch, useSelector} from 'react-redux'
import {useLazyLoadQuery, useMutation} from 'react-relay'
import {PushReflectionModalMutation} from '../../__generated__/PushReflectionModalMutation.graphql'
import {PushReflectionModalQuery} from '../../__generated__/PushReflectionModalQuery.graphql'
import {useTipTapTaskEditor} from '../../hooks/useTipTapTaskEditor'
import {closePushPostAsReflection} from '../../reducers'
import {getPostURL, pushPostAsReflection} from '../../selectors'
import Modal from '../Modal'
Expand Down Expand Up @@ -70,18 +71,40 @@ const PushReflectionModal = () => {
description: string
}>()

const [comment, setComment] = React.useState('')
const formattedPost = useMemo(() => {
const htmlPost = useMemo(() => {
if (!post) {
return null
return ''
}
const quotedMessage = post.message
.split('\n')
.map((line) => `> ${line}`)
.join('\n')
return `${quotedMessage}\n\n[See comment in Mattermost](${postUrl})`
const quote = PostUtils.formatText(post.message)
return `
<p />
<blockquote>
${quote}
</blockquote>
<a href=${postUrl}>See comment in Mattermost</a>
`
}, [post])

const tipTapJson = useMemo(() => {
const json = generateJSON(htmlPost, [
StarterKit,
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
]
}
})
])
return JSON.stringify(json)
}, [htmlPost])

const [createReflection] = useMutation<PushReflectionModalMutation>(graphql`
mutation PushReflectionModalMutation($input: CreateReflectionInput!) {
createReflection(input: $input) {
Expand All @@ -90,10 +113,6 @@ const PushReflectionModal = () => {
}
`)

useEffect(() => {
setComment('')
}, [postId])

useEffect(() => {
if (!selectedMeeting && retroMeetings && retroMeetings.length > 0) {
setSelectedMeeting(retroMeetings[0])
Expand All @@ -116,30 +135,12 @@ const PushReflectionModal = () => {
}

const handlePush = async () => {
if (!selectedMeeting || !selectedPrompt || (!comment && !post.message)) {
console.log('missing data', selectedPrompt, selectedMeeting, comment, post.message)
if (!selectedMeeting || !selectedPrompt || !editor || editor.isEmpty) {
console.log('missing data', selectedPrompt, selectedMeeting, post.message)
return
}

const markdown = `${comment}\n\n${formattedPost}`
const html = await marked.parse(markdown)
const rawObject = generateJSON(html, [
StarterKit,
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
]
}
})
])
const content = JSON.stringify(rawObject)
const content = JSON.stringify(editor.getJSON())

createReflection({
variables: {
Expand All @@ -155,6 +156,11 @@ const PushReflectionModal = () => {
handleClose()
}

const {editor} = useTipTapTaskEditor(tipTapJson)
if (!editor) {
return null
}

if (!postId) {
return null
}
Expand All @@ -177,28 +183,12 @@ const PushReflectionModal = () => {
<label className='control-label' htmlFor='comment'>
Add a Comment<span className='error-text'> *</span>
</label>
<div
className='form-control'
style={{
resize: 'none',
height: 'auto'
}}
>
<textarea
style={{
border: 'none',
width: '100%'
}}
id='comment'
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder='Add your comment for the retro...'
/>
<blockquote>
{PostUtils.messageHtmlToComponent(PostUtils.formatText(post.message))}
</blockquote>
<a>See comment in Mattermost</a>
</div>
<TipTapEditor
id='comment'
className='channel-switch-modal form-control h-auto min-h-32 p-2'
editor={editor}
placeholder='TT Add your comment for the retro...'
/>
</div>
)}
{data && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {TeamRow_team$key} from '../../__generated__/TeamRow_team.graphql'
import {useConfig} from '../../hooks/useConfig'
import MoreMenu from '../Menu'

import plural from '../../../client/utils/plural'
import plural from 'parabol-client/utils/plural'
import {useUnlinkTeam} from '../../hooks/useUnlinkTeam'

const Card = styled.div!`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ const StartActivityModal = () => {
>
<div>
<p>
To see the full details for any activity, visit
To see the full details for any activity, visit{' '}
<a href={`${config?.parabolUrl}/activity-library/`} target='_blank' rel='noreferrer'>
{"Parabol's Activity Library"}
</a>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import graphql from 'babel-plugin-relay/macro'
import {useLazyLoadQuery, useMutation} from 'react-relay'

import {Threshold} from 'parabol-client/types/constEnums'
import {useCallback} from 'react'
import {Threshold} from '../../client/types/constEnums'
import {useMassInvitationTokenMutation} from '../__generated__/useMassInvitationTokenMutation.graphql'
import {useMassInvitationTokenQuery} from '../__generated__/useMassInvitationTokenQuery.graphql'
import {mutationResult} from '../utils/mutationResult'
Expand Down
Loading

0 comments on commit 9e4a14e

Please sign in to comment.