diff --git a/backend/controller/comments.controller.js b/backend/controller/comments.controller.js new file mode 100644 index 0000000..c707b3a --- /dev/null +++ b/backend/controller/comments.controller.js @@ -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, +} diff --git a/backend/models/comment.model.js b/backend/models/comment.model.js index 5ee409f..a08431d 100644 --- a/backend/models/comment.model.js +++ b/backend/models/comment.model.js @@ -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, diff --git a/backend/routes/comments.routes.js b/backend/routes/comments.routes.js new file mode 100644 index 0000000..e2c1bbd --- /dev/null +++ b/backend/routes/comments.routes.js @@ -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 diff --git a/backend/server.js b/backend/server.js index fe56817..67c4b78 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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) @@ -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}`) diff --git a/backend/service/comments.service.js b/backend/service/comments.service.js new file mode 100644 index 0000000..f1e5d75 --- /dev/null +++ b/backend/service/comments.service.js @@ -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, +} diff --git a/frontend/src/components/CommentInput.js b/frontend/src/components/CommentInput.js new file mode 100644 index 0000000..744a816 --- /dev/null +++ b/frontend/src/components/CommentInput.js @@ -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) => , []) + const renderLeaf = useCallback((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 ( + { + setVal([...editor.children]) + }} + > + { + for (const hotkey in HOTKEYS) { + if (isHotkey(hotkey, event)) { + event.preventDefault() + const mark = HOTKEYS[hotkey] + toggleMark(editor, mark) + } + } + }} + /> + + + + + + + + + + + {reply && ( + + Cancel + + )} + { + handleSubmit(code, ticket) + }} + > + {reply ? 'Reply' : 'Comment'} + + + + + ) +} + +//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 diff --git a/frontend/src/components/CommentSection.js b/frontend/src/components/CommentSection.js index 4ad4600..9d6aa35 100644 --- a/frontend/src/components/CommentSection.js +++ b/frontend/src/components/CommentSection.js @@ -1,114 +1,89 @@ // modified version of https://github.com/ianstormtaylor/slate/blob/main/site/examples/richtext.tsx -import React, { useCallback, useMemo } from 'react' -import isHotkey from 'is-hotkey' -import { Editable, withReact, Slate } from 'slate-react' -import { createEditor } from 'slate' -import { withHistory } from 'slate-history' +import React, { useState, useEffect } from 'react' +import { Box, Spinner } from '@chakra-ui/react' +import { axiosPreset } from '../axiosConfig' +import CommentInput from './CommentInput' +import CommentView from './CommentView' -import { - BlockButton, - Element, - Leaf, - MarkButton, - Toolbar, - toggleMark, -} from './SlateComponents' -import { Box, Heading } from '@chakra-ui/react' +const CommentSection = ({ ticket, allUsers }) => { + const [comments, setComments] = useState([]) + const [refreshKey, setRefreshKey] = useState(0) + const [loading, setLoading] = useState(false) -const HOTKEYS = { - 'mod+b': 'bold', - 'mod+i': 'italic', - 'mod+u': 'underline', - 'mod+`': 'code', -} + const forceRefresh = () => { + setRefreshKey((oldKey) => oldKey + 1) + } + + const getComments = async (ref) => { + setLoading(true) + axiosPreset + .get('/comments/' + ref) + .then((data) => { + setComments([...data.data]) + forceRefresh() + console.log(data) + }) + .then(() => { + setLoading(false) + }) + .catch(() => { + setLoading(false) + }) + } -const CommentSection = () => { - const renderElement = useCallback((props) => , []) - const renderLeaf = useCallback((props) => , []) - const editor = useMemo(() => withHistory(withReact(createEditor())), []) + useEffect(() => { + getComments(ticket) + }, [ticket]) return ( - - Comment Section - - - - - - - - - - - - - - - - - { - for (const hotkey in HOTKEYS) { - if (isHotkey(hotkey, event)) { - event.preventDefault() - const mark = HOTKEYS[hotkey] - toggleMark(editor, mark) - } - } - }} + + + Comments + + + - + + {loading && ( + + )} + + {comments.map((content, index) => { + return ( + + ) + })} + + + ) } -// example taken from https://github.com/ianstormtaylor/slate/blob/main/site/components.tsx -// remove once dynamically fetched from backend -const initialValue = [ - { - type: 'paragraph', - children: [ - { text: 'This is editable ' }, - { text: 'rich', bold: true }, - { text: ' text, ' }, - { text: 'much', italic: true }, - { text: ' better than a ' }, - { text: '', code: true }, - { text: '!' }, - ], - }, - { - type: 'paragraph', - children: [ - { - text: "Since it's rich text, you can do things like turn a selection of text ", - }, - { text: 'bold', bold: true }, - { - text: ', or add a semantically rendered block quote in the middle of the page, like this:', - }, - ], - }, - { - type: 'block-quote', - children: [{ text: 'A wise quote.' }], - }, - { - type: 'paragraph', - align: 'center', - children: [{ text: 'Try it out for yourself!' }], - }, -] - export default CommentSection + +/** + * To do: + * seperate the comment editor from comment section + * make component for actual comments + * make component for replies (view replies, hide replies?) + * make component for replying to comment vs new comment in general + */ diff --git a/frontend/src/components/CommentView.js b/frontend/src/components/CommentView.js new file mode 100644 index 0000000..7db147b --- /dev/null +++ b/frontend/src/components/CommentView.js @@ -0,0 +1,246 @@ +import React, { useState } from 'react' +import { Slate, Editable, withReact } from 'slate-react' +import { createEditor } from 'slate' +import { withHistory } from 'slate-history' +import { + Text, + Image, + Box, + Popover, + PopoverTrigger, + PopoverContent, + HStack, +} from '@chakra-ui/react' +import { timeAgo } from '../utils/utils' +import { Element, Leaf } from './SlateComponents' +import CommentInput from './CommentInput' + +const UserInfoPopUp = ({ author, img }) => { + return ( + + + + {!img && ( + + {author.displayName} + + )} + {img && ( + + )} + + + + + + + + + {author.displayName} + + + + {author.email} + + + + + + + ) +} + +const ReplyView = ({ comment, allUsers, getComments, parent }) => { + const editor = React.useMemo( + () => withReact(withHistory(createEditor())), + [] + ) + const author = allUsers.users.find((user) => user.uid === comment.author_id) + const renderElement = React.useCallback( + (props) => , + [] + ) + const renderLeaf = React.useCallback((props) => , []) + const [showInput, setShowInput] = useState(false) + + return ( + + + + + + + {timeAgo(comment.createdAt)} + + + + + + setShowInput(!showInput)} + > + reply + + {showInput && ( + setShowInput(false)} + ticket={parent.reference_code} + code={parent._id} + getComments={getComments} + /> + )} + + + ) +} + +const CommentView = ({ comment, allUsers, getComments }) => { + const editor = React.useMemo( + () => withReact(withHistory(createEditor())), + [] + ) + const author = allUsers.users.find((user) => user.uid === comment.author_id) + const renderElement = React.useCallback( + (props) => , + [] + ) + const renderLeaf = React.useCallback((props) => , []) + const [showInput, setShowInput] = useState(false) + + return ( + + + + + + + {timeAgo(comment.createdAt)} + + + + + + setShowInput(!showInput)} + > + reply + + {showInput && ( + setShowInput(false)} + ticket={comment.reference_code} + code={comment._id} + getComments={getComments} + /> + )} + {comment.replies.map((reply) => { + return ( + + ) + })} + + + ) +} + +export default CommentView diff --git a/frontend/src/components/SlateComponents.js b/frontend/src/components/SlateComponents.js index 5d87d43..084f067 100644 --- a/frontend/src/components/SlateComponents.js +++ b/frontend/src/components/SlateComponents.js @@ -1,5 +1,3 @@ -// from https://github.com/ianstormtaylor/slate/blob/main/site/components.tsx -// probably best not to touch. import React from 'react' import { Editor, Transforms, Element as SlateElement } from 'slate' import ReactDOM from 'react-dom' @@ -114,7 +112,15 @@ export const Element = ({ attributes, children, element }) => { switch (element.type) { case 'block-quote': return ( - + {children} ) @@ -191,8 +197,8 @@ export const Button = React.forwardRef( ? 'white' : '#aaa' : active - ? 'black' - : '#ccc'}; + ? 'black' + : '#ccc'}; ` )} /> @@ -311,10 +317,8 @@ export const Toolbar = React.forwardRef(({ className, ...props }, ref) => ( className, css` position: relative; - padding: 1px 18px 17px; + padding: 1px 18px 0px; margin: 0 -20px; - border-bottom: 2px solid #eee; - margin-bottom: 20px; ` )} /> diff --git a/frontend/src/pages/Dashboard.js b/frontend/src/pages/Dashboard.js index 7501702..eb1734e 100644 --- a/frontend/src/pages/Dashboard.js +++ b/frontend/src/pages/Dashboard.js @@ -177,7 +177,10 @@ const Dashboard = () => { <> {auth.isAdmin && } - + > ) case TICKET_TYPES.FI: @@ -185,7 +188,10 @@ const Dashboard = () => { <> {auth.isAdmin && } - + > ) @@ -200,7 +206,10 @@ const Dashboard = () => { /> )} - + > ) case TICKET_TYPES.UPR: @@ -208,7 +217,10 @@ const Dashboard = () => { <> {auth.isAdmin && } - + > ) default: diff --git a/frontend/src/utils/utils.js b/frontend/src/utils/utils.js index 1a8e8bd..4bdfd45 100644 --- a/frontend/src/utils/utils.js +++ b/frontend/src/utils/utils.js @@ -5,6 +5,35 @@ export const getStandardizedDate = (dateStr) => { return date.format('YYYY-MM-DD') } +export const timeAgo = (dateString) => { + const date = new Date(dateString) + const now = new Date() + const seconds = Math.round((now - date) / 1000) + const minutes = Math.round(seconds / 60) + const hours = Math.round(minutes / 60) + const days = Math.round(hours / 24) + const weeks = Math.round(days / 7) + const months = Math.round(days / 30.44) // Average days per month + const years = Math.round(days / 365) + + if (seconds < 60) { + return 'Just now' + } else if (minutes < 60) { + return `${minutes} minute${minutes > 1 ? 's' : ''} ago` + } else if (hours < 24) { + return `${hours} hour${hours > 1 ? 's' : ''} ago` + } else if (days < 7) { + return `${days} day${days > 1 ? 's' : ''} ago` + } else if (days < 30) { + // Approximate weeks in a month + return `${weeks} week${weeks > 1 ? 's' : ''} ago` + } else if (months < 12) { + return `${months} month${months > 1 ? 's' : ''} ago` + } else { + return `${years} year${years > 1 ? 's' : ''} ago` + } +} + export const getFormattedCurrency = (currencyStr) => { const currencyFormatter = new Intl.NumberFormat('en-CA', { style: 'currency',
+ {children} ) @@ -191,8 +197,8 @@ export const Button = React.forwardRef( ? 'white' : '#aaa' : active - ? 'black' - : '#ccc'}; + ? 'black' + : '#ccc'}; ` )} /> @@ -311,10 +317,8 @@ export const Toolbar = React.forwardRef(({ className, ...props }, ref) => ( className, css` position: relative; - padding: 1px 18px 17px; + padding: 1px 18px 0px; margin: 0 -20px; - border-bottom: 2px solid #eee; - margin-bottom: 20px; ` )} /> diff --git a/frontend/src/pages/Dashboard.js b/frontend/src/pages/Dashboard.js index 7501702..eb1734e 100644 --- a/frontend/src/pages/Dashboard.js +++ b/frontend/src/pages/Dashboard.js @@ -177,7 +177,10 @@ const Dashboard = () => { <> {auth.isAdmin && } - + > ) case TICKET_TYPES.FI: @@ -185,7 +188,10 @@ const Dashboard = () => { <> {auth.isAdmin && } - + > ) @@ -200,7 +206,10 @@ const Dashboard = () => { /> )} - + > ) case TICKET_TYPES.UPR: @@ -208,7 +217,10 @@ const Dashboard = () => { <> {auth.isAdmin && } - + > ) default: diff --git a/frontend/src/utils/utils.js b/frontend/src/utils/utils.js index 1a8e8bd..4bdfd45 100644 --- a/frontend/src/utils/utils.js +++ b/frontend/src/utils/utils.js @@ -5,6 +5,35 @@ export const getStandardizedDate = (dateStr) => { return date.format('YYYY-MM-DD') } +export const timeAgo = (dateString) => { + const date = new Date(dateString) + const now = new Date() + const seconds = Math.round((now - date) / 1000) + const minutes = Math.round(seconds / 60) + const hours = Math.round(minutes / 60) + const days = Math.round(hours / 24) + const weeks = Math.round(days / 7) + const months = Math.round(days / 30.44) // Average days per month + const years = Math.round(days / 365) + + if (seconds < 60) { + return 'Just now' + } else if (minutes < 60) { + return `${minutes} minute${minutes > 1 ? 's' : ''} ago` + } else if (hours < 24) { + return `${hours} hour${hours > 1 ? 's' : ''} ago` + } else if (days < 7) { + return `${days} day${days > 1 ? 's' : ''} ago` + } else if (days < 30) { + // Approximate weeks in a month + return `${weeks} week${weeks > 1 ? 's' : ''} ago` + } else if (months < 12) { + return `${months} month${months > 1 ? 's' : ''} ago` + } else { + return `${years} year${years > 1 ? 's' : ''} ago` + } +} + export const getFormattedCurrency = (currencyStr) => { const currencyFormatter = new Intl.NumberFormat('en-CA', { style: 'currency',
{children}