mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-03-11 17:48:44 -05:00
feat: add comment count to tasks (#1771)
This commit is contained in:
@@ -149,8 +149,8 @@ const {
|
||||
() => props.viewId,
|
||||
{position: 'asc'},
|
||||
() => projectId.value === -1
|
||||
? null
|
||||
: 'subtasks',
|
||||
? 'comment_count'
|
||||
: ['subtasks', 'comment_count'],
|
||||
)
|
||||
|
||||
const taskPositionService = ref(new TaskPositionService())
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
<FancyCheckbox v-model="activeColumns.assignees">
|
||||
{{ $t('task.attributes.assignees') }}
|
||||
</FancyCheckbox>
|
||||
<FancyCheckbox v-model="activeColumns.commentCount">
|
||||
{{ $t('task.attributes.commentCount') }}
|
||||
</FancyCheckbox>
|
||||
<FancyCheckbox v-model="activeColumns.dueDate">
|
||||
{{ $t('task.attributes.dueDate') }}
|
||||
</FancyCheckbox>
|
||||
@@ -132,6 +135,9 @@
|
||||
@click="sort('due_date', $event)"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="activeColumns.commentCount">
|
||||
{{ $t('task.attributes.commentCount') }}
|
||||
</th>
|
||||
<th v-if="activeColumns.startDate">
|
||||
{{ $t('task.attributes.startDate') }}
|
||||
<Sort
|
||||
@@ -228,6 +234,15 @@
|
||||
v-if="activeColumns.dueDate"
|
||||
:date="t.dueDate"
|
||||
/>
|
||||
<td v-if="activeColumns.commentCount">
|
||||
<span
|
||||
v-if="t.commentCount && t.commentCount > 0"
|
||||
class="comment-badge"
|
||||
>
|
||||
<Icon icon="comment" />
|
||||
{{ t.commentCount }}
|
||||
</span>
|
||||
</td>
|
||||
<DateTableCell
|
||||
v-if="activeColumns.startDate"
|
||||
:date="t.startDate"
|
||||
@@ -320,6 +335,7 @@ const ACTIVE_COLUMNS_DEFAULT = {
|
||||
updated: false,
|
||||
createdBy: false,
|
||||
doneAt: false,
|
||||
commentCount: false,
|
||||
}
|
||||
|
||||
const SORT_BY_DEFAULT: SortBy = {
|
||||
@@ -329,7 +345,12 @@ const SORT_BY_DEFAULT: SortBy = {
|
||||
const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT})
|
||||
const sortBy = useStorage<SortBy>('tableViewSortBy', {...SORT_BY_DEFAULT})
|
||||
|
||||
const taskList = useTaskList(() => props.projectId, () => props.viewId, sortBy.value)
|
||||
const taskList = useTaskList(
|
||||
() => props.projectId,
|
||||
() => props.viewId,
|
||||
sortBy.value,
|
||||
() => 'comment_count',
|
||||
)
|
||||
|
||||
const {
|
||||
loading,
|
||||
|
||||
@@ -86,6 +86,14 @@
|
||||
>
|
||||
<Icon icon="paperclip" />
|
||||
</span>
|
||||
<span
|
||||
v-if="task.commentCount && task.commentCount > 0"
|
||||
v-tooltip="$t('task.attributes.comment', task.commentCount)"
|
||||
class="project-task-icon comment-count-icon"
|
||||
>
|
||||
<Icon :icon="['far', 'comments']" />
|
||||
<span class="comment-count-badge">{{ task.commentCount }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="!isEditorContentEmpty(task.description)"
|
||||
class="icon"
|
||||
@@ -396,4 +404,22 @@ $task-background: var(--white);
|
||||
inline-size: 100%;
|
||||
block-size: 0.5rem;
|
||||
}
|
||||
|
||||
.comment-count-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--grey-500);
|
||||
background: var(--grey-100);
|
||||
border-radius: $radius;
|
||||
padding: 0.25rem;
|
||||
margin-inline-end: .25rem;
|
||||
|
||||
.comment-count-badge {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -120,6 +120,14 @@
|
||||
>
|
||||
<Icon icon="history" />
|
||||
</span>
|
||||
<span
|
||||
v-if="task.commentCount && task.commentCount > 0"
|
||||
class="project-task-icon comment-count-icon"
|
||||
:title="`${task.commentCount} ${task.commentCount === 1 ? 'comment' : 'comments'}`"
|
||||
>
|
||||
<Icon :icon="['far', 'comments']" />
|
||||
<span class="comment-count-badge">{{ task.commentCount }}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<ChecklistSummary :task="task" />
|
||||
@@ -565,4 +573,22 @@ defineExpose({
|
||||
border: 1px solid var(--grey-200);
|
||||
}
|
||||
}
|
||||
|
||||
.comment-count-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--grey-500);
|
||||
|
||||
.comment-count-badge {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -862,6 +862,8 @@
|
||||
"relatedTasks": "Related Tasks",
|
||||
"reminders": "Reminders",
|
||||
"repeat": "Repeat",
|
||||
"comment": "{count} comment | {count} comments",
|
||||
"commentCount": "Number of comments",
|
||||
"startDate": "Start Date",
|
||||
"title": "Title",
|
||||
"updated": "Updated",
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface ITask extends IAbstract {
|
||||
|
||||
reactions: IReactionPerEntity
|
||||
comments: ITaskComment[]
|
||||
commentCount?: number
|
||||
|
||||
createdBy: IUser
|
||||
created: Date
|
||||
|
||||
@@ -4,7 +4,7 @@ import TaskModel from '@/models/task'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import BucketModel from '@/models/bucket'
|
||||
|
||||
export type ExpandTaskFilterParam = 'subtasks' | 'buckets' | 'reactions' | null
|
||||
export type ExpandTaskFilterParam = 'subtasks' | 'buckets' | 'reactions' | 'comment_count' | null
|
||||
|
||||
export interface TaskFilterParams {
|
||||
sort_by: ('start_date' | 'end_date' | 'due_date' | 'done' | 'id' | 'position' | 'title')[],
|
||||
|
||||
@@ -262,6 +262,7 @@ export const useKanbanStore = defineStore('kanban', () => {
|
||||
try {
|
||||
const newBuckets = await taskCollectionService.getAll({projectId, viewId}, {
|
||||
...params,
|
||||
expand: 'comment_count',
|
||||
per_page: TASKS_PER_BUCKET,
|
||||
})
|
||||
setBuckets(newBuckets)
|
||||
@@ -300,6 +301,7 @@ export const useKanbanStore = defineStore('kanban', () => {
|
||||
params.filter = `${params.filter === '' ? '' : params.filter + ' && '}bucket_id = ${bucketId}`
|
||||
params.filter_timezone = authStore.settings.timezone
|
||||
params.per_page = TASKS_PER_BUCKET
|
||||
params.expand = 'comment_count'
|
||||
|
||||
const taskService = new TaskCollectionService()
|
||||
try {
|
||||
|
||||
@@ -192,6 +192,7 @@ async function loadPendingTasks(from: Date|string, to: Date|string) {
|
||||
filter: 'done = false',
|
||||
filter_include_nulls: props.showNulls,
|
||||
s: '',
|
||||
expand: 'comment_count',
|
||||
}
|
||||
|
||||
if (!showAll.value) {
|
||||
|
||||
36
pkg/migration/20251108154913.go
Normal file
36
pkg/migration/20251108154913.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package migration
|
||||
|
||||
import (
|
||||
"src.techknowlogick.com/xormigrate"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
migrations = append(migrations, &xormigrate.Migration{
|
||||
ID: "20251108154913",
|
||||
Description: "Add index on task_comments.task_id for better query performance",
|
||||
Migrate: func(tx *xorm.Engine) error {
|
||||
_, err := tx.Exec("CREATE INDEX IF NOT EXISTS IDX_task_comments_task_id ON task_comments (task_id)")
|
||||
return err
|
||||
},
|
||||
Rollback: func(tx *xorm.Engine) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -70,6 +70,7 @@ const TaskCollectionExpandSubtasks TaskCollectionExpandable = `subtasks`
|
||||
const TaskCollectionExpandBuckets TaskCollectionExpandable = `buckets`
|
||||
const TaskCollectionExpandReactions TaskCollectionExpandable = `reactions`
|
||||
const TaskCollectionExpandComments TaskCollectionExpandable = `comments`
|
||||
const TaskCollectionExpandCommentCount TaskCollectionExpandable = `comment_count`
|
||||
|
||||
// Validate validates if the TaskCollectionExpandable value is valid.
|
||||
func (t TaskCollectionExpandable) Validate() error {
|
||||
@@ -82,9 +83,11 @@ func (t TaskCollectionExpandable) Validate() error {
|
||||
return nil
|
||||
case TaskCollectionExpandComments:
|
||||
return nil
|
||||
case TaskCollectionExpandCommentCount:
|
||||
return nil
|
||||
}
|
||||
|
||||
return InvalidFieldErrorWithMessage([]string{"expand"}, "Expand must be one of the following values: subtasks, buckets, reactions")
|
||||
return InvalidFieldErrorWithMessage([]string{"expand"}, "Expand must be one of the following values: subtasks, buckets, reactions, comments, comment_count")
|
||||
}
|
||||
|
||||
func validateTaskField(fieldName string) error {
|
||||
@@ -286,6 +289,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
|
||||
tc.ProjectViewID = tf.ProjectViewID
|
||||
tc.ProjectID = tf.ProjectID
|
||||
tc.isSavedFilter = true
|
||||
tc.Expand = tf.Expand
|
||||
|
||||
if tf.Filter != "" {
|
||||
if tc.Filter != "" {
|
||||
|
||||
@@ -34,7 +34,7 @@ type TaskComment struct {
|
||||
Comment string `xorm:"text not null" json:"comment" valid:"dbtext,required"`
|
||||
AuthorID int64 `xorm:"not null" json:"-"`
|
||||
Author *user.User `xorm:"-" json:"author"`
|
||||
TaskID int64 `xorm:"not null" json:"-" param:"task"`
|
||||
TaskID int64 `xorm:"index not null" json:"-" param:"task"`
|
||||
|
||||
Reactions ReactionMap `xorm:"-" json:"reactions"`
|
||||
|
||||
@@ -274,6 +274,43 @@ func addCommentsToTasks(s *xorm.Session, taskIDs []int64, taskMap map[int64]*Tas
|
||||
return nil
|
||||
}
|
||||
|
||||
func addCommentCountToTasks(s *xorm.Session, taskIDs []int64, taskMap map[int64]*Task) error {
|
||||
if len(taskIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
zero := int64(0)
|
||||
for _, taskID := range taskIDs {
|
||||
if task, ok := taskMap[taskID]; ok {
|
||||
task.CommentCount = &zero
|
||||
}
|
||||
}
|
||||
|
||||
type CommentCount struct {
|
||||
TaskID int64 `xorm:"task_id"`
|
||||
Count int64 `xorm:"count"`
|
||||
}
|
||||
|
||||
counts := []CommentCount{}
|
||||
|
||||
if err := s.
|
||||
Select("task_id, COUNT(*) as count").
|
||||
Where(builder.In("task_id", taskIDs)).
|
||||
GroupBy("task_id").
|
||||
Table("task_comments").
|
||||
Find(&counts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, c := range counts {
|
||||
if task, ok := taskMap[c.TaskID]; ok {
|
||||
task.CommentCount = &c.Count
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAllCommentsForTasksWithoutPermissionCheck(s *xorm.Session, taskIDs []int64, search string, page int, perPage int) (result []*TaskComment, resultCount int, numberOfTotalItems int64, err error) {
|
||||
// Because we can't extend the type in general, we need to do this here.
|
||||
// Not a good solution, but saves performance.
|
||||
|
||||
@@ -125,6 +125,9 @@ type Task struct {
|
||||
// All comments of this task. Only present when fetching tasks with the `expand` parameter set to `comments`.
|
||||
Comments []*TaskComment `xorm:"-" json:"comments,omitempty"`
|
||||
|
||||
// Comment count of this task. Only present when fetching tasks with the `expand` parameter set to `comment_count`.
|
||||
CommentCount *int64 `xorm:"-" json:"comment_count,omitempty"`
|
||||
|
||||
// Behaves exactly the same as with the TaskCollection.Expand parameter
|
||||
Expand []TaskCollectionExpandable `xorm:"-" json:"-" query:"expand"`
|
||||
|
||||
@@ -587,6 +590,8 @@ func addBucketsToTasks(s *xorm.Session, a web.Auth, taskIDs []int64, taskMap map
|
||||
|
||||
// This function takes a map with pointers and returns a slice with pointers to tasks
|
||||
// It adds more stuff like assignees/labels/etc to a bunch of tasks
|
||||
//
|
||||
//nolint:gocyclo
|
||||
func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth, view *ProjectView, expand []TaskCollectionExpandable) (err error) {
|
||||
|
||||
// No need to iterate over users and stuff if the project doesn't have tasks
|
||||
@@ -679,6 +684,11 @@ func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth, vi
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case TaskCollectionExpandCommentCount:
|
||||
err = addCommentCountToTasks(s, taskIDs, taskMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
expanded[expandable] = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user