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:
konrad 2020-02-25 20:11:36 +00:00
parent 683012f468
commit 57f78ee0d4
9 changed files with 261 additions and 1 deletions

View file

@ -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() {

View 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>&nbsp;
<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>&nbsp;·&nbsp;
<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>

View file

@ -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
View 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,
}
}
}

View file

@ -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`
}
}

View 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)
}
}

View file

@ -12,3 +12,4 @@
@import 'tasks';
@import 'teams';
@import 'migrator';
@import 'comments';

View 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;
}
}
}

View file

@ -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%;
}