Task Comments (#66)
Better edit/remove buttons Spacing More loading Add loading Better dates formatting Add editing comments Move closing delete modal to finally Add delete comments Add keycode modifier Comment styling Comment form Add basic task comments functionality Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/66
This commit is contained in:
parent
683012f468
commit
57f78ee0d4
9 changed files with 261 additions and 1 deletions
|
@ -190,6 +190,9 @@
|
|||
ref="relatedTasks"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<comments :task-i-d="taskID"/>
|
||||
</div>
|
||||
<div class="column is-one-fifth action-buttons">
|
||||
<a class="button is-outlined noshadow has-no-border" :class="{'is-success': !task.done}" @click="toggleTaskDone()">
|
||||
|
@ -288,6 +291,7 @@
|
|||
import RelatedTasks from './reusable/relatedTasks'
|
||||
import RepeatAfter from './reusable/repeatAfter'
|
||||
import Reminders from './reusable/reminders'
|
||||
import Comments from './reusable/comments'
|
||||
import router from '../../router'
|
||||
|
||||
export default {
|
||||
|
@ -301,6 +305,7 @@
|
|||
EditLabels,
|
||||
PercentDoneSelect,
|
||||
PrioritySelect,
|
||||
Comments,
|
||||
flatPickr,
|
||||
},
|
||||
data() {
|
||||
|
|
177
src/components/tasks/reusable/comments.vue
Normal file
177
src/components/tasks/reusable/comments.vue
Normal file
|
@ -0,0 +1,177 @@
|
|||
<template>
|
||||
<div class="content details has-top-border">
|
||||
<h1>
|
||||
<span class="icon is-grey">
|
||||
<icon :icon="['far', 'comments']"/>
|
||||
</span>
|
||||
Comments
|
||||
</h1>
|
||||
<div class="comments">
|
||||
<progress class="progress is-small is-info" max="100" v-if="taskCommentService.loading">Loading comments...</progress>
|
||||
<div class="media comment" v-for="c in comments" :key="c.id">
|
||||
<figure class="media-left">
|
||||
<img class="image is-avatar" :src="c.author.getAvatarUrl(48)" alt="">
|
||||
</figure>
|
||||
<div class="media-content">
|
||||
<div class="form" v-if="isCommentEdit && commentEdit.id === c.id">
|
||||
<div class="field">
|
||||
<textarea class="textarea" :class="{'is-loading': taskCommentService.loading}" placeholder="Add your comment..." v-model="commentEdit.comment" @keyup.ctrl.enter="editComment()"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button class="button is-primary" :class="{'is-loading': taskCommentService.loading}" @click="editComment()" :disabled="commentEdit.comment === ''">Comment</button>
|
||||
<a @click="() => isCommentEdit = false">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content" v-else>
|
||||
<strong>{{ c.author.username }}</strong>
|
||||
<small v-tooltip="formatDate(c.created)">{{ formatDateSince(c.created) }}</small>
|
||||
<small v-if="+new Date(c.created) !== +new Date(c.updated)" v-tooltip="formatDate(c.updated)"> · edited {{ formatDateSince(c.updated) }}</small>
|
||||
<br/>
|
||||
<p>
|
||||
{{c.comment}}
|
||||
</p>
|
||||
<div class="comment-actions">
|
||||
<a @click="toggleEdit(c)">Edit</a> ·
|
||||
<a @click="toggleDelete(c.id)">Remove</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media comment">
|
||||
<figure class="media-left">
|
||||
<img class="image is-avatar" :src="user.infos.getAvatarUrl(48)" alt="">
|
||||
</figure>
|
||||
<div class="media-content">
|
||||
<div class="form">
|
||||
<div class="field">
|
||||
<textarea class="textarea" :class="{'is-loading': taskCommentService.loading && !isCommentEdit}" placeholder="Add your comment..." v-model="newComment.comment" @keyup.ctrl.enter="addComment()"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button class="button is-primary" :class="{'is-loading': taskCommentService.loading && !isCommentEdit}" @click="addComment()" :disabled="newComment.comment === ''">Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<modal
|
||||
v-if="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="deleteComment()">
|
||||
<span slot="header">Delete this comment</span>
|
||||
<p slot="text">Are you sure you want to delete this comment?
|
||||
<br/>This <b>CANNOT BE UNDONE!</b></p>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TaskCommentService from '../../../services/taskComment'
|
||||
import TaskCommentModel from '../../../models/taskComment'
|
||||
import auth from '../../../auth'
|
||||
|
||||
export default {
|
||||
name: 'comments',
|
||||
props: {
|
||||
taskID: {
|
||||
type: Number,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
comments: [],
|
||||
user: auth.user,
|
||||
|
||||
showDeleteModal: false,
|
||||
commentToDelete: TaskCommentModel,
|
||||
|
||||
isCommentEdit: false,
|
||||
commentEdit: TaskCommentModel,
|
||||
|
||||
taskCommentService: TaskCommentService,
|
||||
newComment: TaskCommentModel,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.taskCommentService = new TaskCommentService()
|
||||
this.newComment = new TaskCommentModel({task_id: this.taskID})
|
||||
this.commentEdit = new TaskCommentModel({task_id: this.taskID})
|
||||
this.commentToDelete = new TaskCommentModel({task_id: this.taskID})
|
||||
this.comments = []
|
||||
},
|
||||
mounted() {
|
||||
this.loadComments()
|
||||
},
|
||||
methods: {
|
||||
loadComments() {
|
||||
this.taskCommentService.getAll({task_id: this.taskID})
|
||||
.then(r => {
|
||||
this.$set(this, 'comments', r)
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
})
|
||||
},
|
||||
addComment() {
|
||||
if (this.newComment.comment === '') {
|
||||
return
|
||||
}
|
||||
this.taskCommentService.create(this.newComment)
|
||||
.then(r => {
|
||||
this.comments.push(r)
|
||||
this.success({message: 'The comment was sucessfully added.'}, this)
|
||||
this.newComment.comment = ''
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
})
|
||||
},
|
||||
toggleEdit(comment) {
|
||||
this.isCommentEdit = !this.isCommentEdit
|
||||
this.commentEdit = comment
|
||||
},
|
||||
toggleDelete(commentId) {
|
||||
this.showDeleteModal = !this.showDeleteModal
|
||||
this.commentToDelete.id = commentId
|
||||
},
|
||||
editComment() {
|
||||
if (this.commentEdit.comment === '') {
|
||||
return
|
||||
}
|
||||
this.commentEdit.task_id = this.taskID
|
||||
this.taskCommentService.update(this.commentEdit)
|
||||
.then(r => {
|
||||
for (const c in this.comments) {
|
||||
if (this.comments[c].id === this.commentEdit.id) {
|
||||
this.$set(this.comments, c, r)
|
||||
}
|
||||
}
|
||||
this.success({message: 'The comment was successfully updated.'}, this)
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isCommentEdit = false
|
||||
})
|
||||
},
|
||||
deleteComment() {
|
||||
this.taskCommentService.delete(this.commentToDelete)
|
||||
.then(r => {
|
||||
for (const a in this.comments) {
|
||||
if (this.comments[a].id === this.commentToDelete.id) {
|
||||
this.comments.splice(a, 1)
|
||||
}
|
||||
}
|
||||
this.success(r, this)
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
})
|
||||
.finally(() => {
|
||||
this.showDeleteModal = false
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -64,6 +64,7 @@ import { faClock } from '@fortawesome/free-regular-svg-icons'
|
|||
import { faHistory } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faCheckDouble } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faComments } from '@fortawesome/free-regular-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
library.add(faSignOutAlt)
|
||||
|
@ -102,6 +103,7 @@ library.add(faClock)
|
|||
library.add(faHistory)
|
||||
library.add(faSearch)
|
||||
library.add(faCheckDouble)
|
||||
library.add(faComments)
|
||||
|
||||
Vue.component('icon', FontAwesomeIcon)
|
||||
|
||||
|
|
22
src/models/taskComment.js
Normal file
22
src/models/taskComment.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import AbstractModel from './abstractModel'
|
||||
import UserModel from './user'
|
||||
|
||||
export default class TaskCommentModel extends AbstractModel {
|
||||
constructor(data) {
|
||||
super(data)
|
||||
this.author = new UserModel(this.author)
|
||||
this.created = new Date(this.created)
|
||||
this.updated = new Date(this.updated)
|
||||
}
|
||||
|
||||
defaults() {
|
||||
return {
|
||||
id: 0,
|
||||
task_id: 0,
|
||||
comment: '',
|
||||
author: UserModel,
|
||||
created: null,
|
||||
update: null,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -19,6 +19,7 @@ export default class UserModel extends AbstractModel {
|
|||
}
|
||||
|
||||
getAvatarUrl(size = 50) {
|
||||
return `https://www.gravatar.com/avatar/${this.avatar}?s=${size}&d=mp`
|
||||
const avatarUrl = this.avatar !== '' ? this.avatar : this.avatarUrl
|
||||
return `https://www.gravatar.com/avatar/${avatarUrl}?s=${size}&d=mp`
|
||||
}
|
||||
}
|
25
src/services/taskComment.js
Normal file
25
src/services/taskComment.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
import AbstractService from './abstractService'
|
||||
import TaskCommentModel from '../models/taskComment'
|
||||
import moment from 'moment'
|
||||
|
||||
export default class TaskCommentService extends AbstractService {
|
||||
constructor() {
|
||||
super({
|
||||
create: '/tasks/{task_id}/comments',
|
||||
getAll: '/tasks/{task_id}/comments',
|
||||
get: '/tasks/{task_id}/comments/{id}',
|
||||
update: '/tasks/{task_id}/comments/{id}',
|
||||
delete: '/tasks/{task_id}/comments/{id}',
|
||||
})
|
||||
}
|
||||
|
||||
processModel(model) {
|
||||
model.created = moment(model.created).toISOString()
|
||||
model.updated = moment(model.updated).toISOString()
|
||||
return model
|
||||
}
|
||||
|
||||
modelFactory(data) {
|
||||
return new TaskCommentModel(data)
|
||||
}
|
||||
}
|
|
@ -12,3 +12,4 @@
|
|||
@import 'tasks';
|
||||
@import 'teams';
|
||||
@import 'migrator';
|
||||
@import 'comments';
|
||||
|
|
19
src/styles/components/comments.scss
Normal file
19
src/styles/components/comments.scss
Normal file
|
@ -0,0 +1,19 @@
|
|||
.media.comment{
|
||||
align-items: center;
|
||||
|
||||
.media-left {
|
||||
margin: 0 1em;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
font-size: .8em;
|
||||
|
||||
&, a {
|
||||
color: $grey;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -30,3 +30,11 @@ h1,h2,h3,h4,h5,h6{
|
|||
.has-no-border{
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.has-rounded-corners {
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
.image.is-avatar{
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue