Skip to content

Commit

Permalink
Comments fully implemented (#186)
Browse files Browse the repository at this point in the history
Used slate-react to input and show comments
Added new route called '/comments'
 - post, creates new comment (no form validation on backend)
 - get, gets all comments + replies with ticket id
   - Aggregates replies in an array in the response
 - No edits and deletion yet

Every comment is stored in comments (replies too)
 - Reference code is either _id of parent comment or ticket_id

No error toasts yet

Had a few issues with getting the comments to update with slate and
emptying the input, so whenever function getComments in frontend is
called, it forcefully rerenders the whole comment section

Also had issues with placeholder text, since it wouldn't follow the
padding I gave to the text area
 - decided to remove placeholder altogether

resolves #65

---------

Co-authored-by: William Li <[email protected]>
  • Loading branch information
willi-li-am and willi-li-am authored Jan 29, 2024
1 parent 2b46ae6 commit 3aed4f8
Show file tree
Hide file tree
Showing 11 changed files with 644 additions and 117 deletions.
28 changes: 28 additions & 0 deletions backend/controller/comments.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const { createComment, getAllComments } = require('../service/comments.service')

const getAllCommentsController = (req, res) => {
const ticketID = req.params.id
getAllComments(ticketID)
.then((data) => res.send(data))
.catch((err) => {
res.status(500).send(err)
console.log(err)
})
}

const createCommentController = (req, res) => {
createComment(req.body)
.then((data) => {
console.log(data)
res.send(data)
})
.catch((error) => {
console.log(error)
res.status(500).send(error)
})
}

module.exports = {
createCommentController,
getAllCommentsController,
}
9 changes: 6 additions & 3 deletions backend/models/comment.model.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
const mongoose = require('mongoose')
const { Schema } = mongoose

const Mixed = Schema.Types.Mixed

const CommentSchema = new Schema(
{
// reference_code would be parent ticket here
reference_code: { type: String, index: true },
commment: { type: String },
// reference_code would be parent ticket/parent comment here
reference_code: { type: String, index: true, required: true },
comment: { type: [Mixed], required: true },
author_id: { type: String, required: true },
},
{
timestamps: true,
Expand Down
14 changes: 14 additions & 0 deletions backend/routes/comments.routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const router = require('express').Router()
const { authenticateUser } = require('../auth/middleware')
const {
createCommentController,
getAllCommentsController,
} = require('../controller/comments.controller')

router.route('/:id').get(authenticateUser, getAllCommentsController)

router.route('/').post(authenticateUser, createCommentController)

//TODO: delete & edit

module.exports = router
2 changes: 2 additions & 0 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const UWFinancePurchaseRouter = require('./routes/uwfinancepurchases.routes')
const usersRouter = require('./routes/users.routes')
const groupRouter = require('./routes/googlegroup.routes')
const filesRouter = require('./routes/files.routes')
const commentRouter = require('./routes/comments.routes')

app.use(express.json())
app.use('/fundingitems', fundingItemsRouter)
Expand All @@ -40,6 +41,7 @@ app.use('/uwfinancepurchases', UWFinancePurchaseRouter)
app.use('/users', usersRouter)
app.use('/googlegroups', groupRouter)
app.use('/files', filesRouter)
app.use('/comments', commentRouter)

app.listen(port, async () => {
console.log(`Server is running on port: ${port}`)
Expand Down
47 changes: 47 additions & 0 deletions backend/service/comments.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const Comment = require('../models/comment.model')

const createComment = async (body) => {
const comment = new Comment(body)
const newComment = await comment.save()
return newComment
}

const getAllComments = async (code) => {
//add reply aggregation, sortby
const res = await Comment.aggregate([
{
$match: {
reference_code: code,
},
},
{
$lookup: {
from: 'comments',
let: { idStr: { $toString: '$_id' } }, // Define variable for use in the pipeline
pipeline: [
{
$match: {
$expr: { $eq: ['$reference_code', '$$idStr'] }, // Use the variable to match documents
},
},
{ $sort: { createdAt: 1 } }, // Sort matching documents in ascending order
],
as: 'replies',
},
},
{
$sort: { createdAt: -1 },
},
{
$set: {
replies: '$replies',
},
},
])
return res
}

module.exports = {
createComment,
getAllComments,
}
167 changes: 167 additions & 0 deletions frontend/src/components/CommentInput.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { useCallback, useMemo, useState } from 'react'
import isHotkey from 'is-hotkey'
import { Editable, withReact, Slate } from 'slate-react'
import { createEditor } from 'slate'
import { withHistory } from 'slate-history'
import { useAuth } from '../contexts/AuthContext'
import { axiosPreset } from '../axiosConfig'
import { Box, Button } from '@chakra-ui/react'

import {
BlockButton,
Element,
Leaf,
MarkButton,
Toolbar,
toggleMark,
} from './SlateComponents'

const HOTKEYS = {
'mod+b': 'bold',
'mod+i': 'italic',
'mod+u': 'underline',
}

//Cleans leading and ending white space
const cleanInput = (val) => {
const textList = val.map((item) => item['children'][0]['text'])

const firstIndex = textList.findIndex((text) => text != '')
if (firstIndex == -1) {
return []
}

const lastIndex = textList.findLastIndex((text) => text != '')

return val.slice(firstIndex, lastIndex + 1)
}

//Disables the "send" button if input isn't valid
const invalidInput = (val) => {
for (let i = 0; i < val.length; i++) {
if (val[i]['children'][0]['text'] != '') {
return false
}
}
return true
}

const CommentInput = ({ code, getComments, reply, onClose, ticket }) => {
const renderElement = useCallback((props) => <Element {...props} />, [])
const renderLeaf = useCallback((props) => <Leaf {...props} />, [])
const editor = useMemo(() => withHistory(withReact(createEditor())), [])
const [loading, setLoading] = useState(false)
const auth = useAuth()
const [val, setVal] = useState(editor.children)
const handleSubmit = (ref, ticket) => {
setLoading(true)
const comment = cleanInput(val)
if (comment.length === 0) {
return
}
const payload = {
author_id: auth.currentUser.uid,
comment: comment,
reference_code: ref,
}
axiosPreset
.post('/comments', payload)
.then(() => getComments(ticket).then(() => setLoading(false)))

.catch(() => setLoading(false))
}

return (
<Slate
editor={editor}
initialValue={initialValue}
onChange={() => {
setVal([...editor.children])
}}
>
<Editable
style={{
marginTop: '10px',
padding: '20px',
paddingLeft: '30px',
minHeight: '150px',
border: '2px #f0f0f0 solid',
borderRadius: '10px',
}}
renderElement={renderElement}
renderLeaf={renderLeaf}
spellCheck
autoFocus={reply}
onKeyDown={(event) => {
for (const hotkey in HOTKEYS) {
if (isHotkey(hotkey, event)) {
event.preventDefault()
const mark = HOTKEYS[hotkey]
toggleMark(editor, mark)
}
}
}}
/>
<Box
display="flex"
width="100%"
justifyContent="space-between"
paddingLeft="5px"
>
<Toolbar>
<MarkButton format="bold" icon="format_bold" />
<MarkButton format="italic" icon="format_italic" />
<MarkButton format="underline" icon="format_underlined" />
<BlockButton format="block-quote" icon="format_quote" />
<BlockButton
format="numbered-list"
icon="format_list_numbered"
/>
<BlockButton
format="bulleted-list"
icon="format_list_bulleted"
/>
</Toolbar>
<Box
display="flex"
gap="10px"
marginTop="5px"
paddingRight="5px"
>
{reply && (
<Button
onClick={onClose}
padding="10px"
height="32px"
colorScheme="red"
>
Cancel
</Button>
)}
<Button
isLoading={loading}
disabled={val.length === 0 || invalidInput(val)}
padding="10px"
height="32px"
colorScheme="blue"
onClick={() => {
handleSubmit(code, ticket)
}}
>
{reply ? 'Reply' : 'Comment'}
</Button>
</Box>
</Box>
</Slate>
)
}

//TODO: If replying another reply, make the initial value quote the comment above (makes it easier to keep track who's replying to who)
const initialValue = [
{
type: 'paragraph',
children: [{ text: '' }],
},
]

export default CommentInput
Loading

0 comments on commit 3aed4f8

Please sign in to comment.