Merge 5c6995872b into cf456fb223
This commit is contained in:
commit
998e33c267
|
|
@ -13,12 +13,12 @@
|
|||
@keyup.enter="openTaskDetail"
|
||||
>
|
||||
<span
|
||||
v-tooltip="!canMarkAsDone ? $t('task.readOnlyCheckbox') : ''"
|
||||
v-tooltip="!canMarkAsDone ? $t('task.readOnlyCheckbox') : isBlockedByIncomplete ? $t('task.blockedCheckbox') : ''"
|
||||
class="is-inline-flex is-align-items-center"
|
||||
>
|
||||
<FancyCheckbox
|
||||
v-model="task.done"
|
||||
:disabled="isArchived || disabled || !canMarkAsDone"
|
||||
:disabled="isArchived || disabled || !canMarkAsDone || isBlockedByIncomplete"
|
||||
:aria-label="$t('task.detail.markAsDone', {task: task.title})"
|
||||
@update:modelValue="markAsDone"
|
||||
@click.stop
|
||||
|
|
@ -219,7 +219,8 @@ import Popup from '@/components/misc/Popup.vue'
|
|||
import TaskService from '@/services/task'
|
||||
|
||||
import {formatDisplayDate, formatISO, formatDateLong} from '@/helpers/time/formatDate'
|
||||
import {success} from '@/message'
|
||||
import {success, error} from '@/message'
|
||||
import {RELATION_KIND} from '@/types/IRelationKind'
|
||||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
|
@ -323,13 +324,23 @@ const isOverdue = computed(() => (
|
|||
task.value.dueDate.getTime() <= now.value.getTime()
|
||||
))
|
||||
|
||||
const isBlockedByIncomplete = computed(() =>
|
||||
task.value.relatedTasks?.[RELATION_KIND.BLOCKED]?.some(t => !t.done) ?? false
|
||||
)
|
||||
|
||||
let oldTask
|
||||
|
||||
async function markAsDone(checked: boolean, wasReverted: boolean = false) {
|
||||
const updateFunc = async () => {
|
||||
oldTask = {...task.value}
|
||||
const newTask = await taskStore.update(task.value)
|
||||
task.value = newTask
|
||||
try {
|
||||
const newTask = await taskStore.update(task.value)
|
||||
task.value = newTask
|
||||
} catch (e) {
|
||||
task.value.done = !checked
|
||||
error(e)
|
||||
return
|
||||
}
|
||||
|
||||
updateDueDate()
|
||||
|
||||
|
|
@ -340,7 +351,7 @@ async function markAsDone(checked: boolean, wasReverted: boolean = false) {
|
|||
if (checked) {
|
||||
playPopSound()
|
||||
}
|
||||
emit('taskUpdated', newTask)
|
||||
emit('taskUpdated', task.value)
|
||||
|
||||
let message = t('task.doneSuccess')
|
||||
if (!task.value.done && !isRepeating.value) {
|
||||
|
|
|
|||
|
|
@ -939,6 +939,7 @@
|
|||
"doneSuccess": "The task was successfully marked as done.",
|
||||
"undoneSuccess": "The task was successfully un-marked as done.",
|
||||
"readOnlyCheckbox": "You only have read access to this task and cannot mark it as done.",
|
||||
"blockedCheckbox": "This task is blocked by incomplete tasks and cannot be marked as done.",
|
||||
"movedToProject": "The task was moved to {project}.",
|
||||
"undo": "Undo",
|
||||
"checklistTotal": "{checked} of {total} tasks",
|
||||
|
|
@ -1457,7 +1458,8 @@
|
|||
"13002": "The provided link share password is invalid.",
|
||||
"13003": "The provided link share token is invalid.",
|
||||
"14001": "The provided api token is invalid.",
|
||||
"14002": "The permission {permission} of group {group} is invalid."
|
||||
"14002": "The permission {permission} of group {group} is invalid.",
|
||||
"20001": "This task is blocked by incomplete tasks and cannot be marked as done."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
|
|
|
|||
|
|
@ -441,7 +441,9 @@
|
|||
<template v-if="canWrite">
|
||||
<XButton
|
||||
v-shortcut="'KeyT'"
|
||||
v-tooltip="!task.done && isBlockedByIncomplete ? $t('task.blockedCheckbox') : ''"
|
||||
:class="{'is-pending': !task.done}"
|
||||
:disabled="!task.done && isBlockedByIncomplete"
|
||||
class="button--mark-done"
|
||||
icon="check-double"
|
||||
variant="secondary"
|
||||
|
|
@ -708,8 +710,9 @@ import {useConfigStore} from '@/stores/config'
|
|||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useTaskDetailShortcuts} from '@/composables/useTaskDetailShortcuts'
|
||||
|
||||
import {success} from '@/message'
|
||||
import {success, error} from '@/message'
|
||||
import type {Action as MessageAction} from '@/message'
|
||||
import {RELATION_KIND} from '@/types/IRelationKind'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: ITask['id'],
|
||||
|
|
@ -836,6 +839,10 @@ const color = computed(() => {
|
|||
|
||||
const isModal = computed(() => Boolean(props.backdropView))
|
||||
|
||||
const isBlockedByIncomplete = computed(() =>
|
||||
task.value.relatedTasks?.[RELATION_KIND.BLOCKED]?.some(t => !t.done) ?? false
|
||||
)
|
||||
|
||||
async function attachmentUpload(file: File, onSuccess?: (url: string) => void) {
|
||||
const uploaded = await uploadFile(props.taskId, file, onSuccess)
|
||||
if (uploaded.length > 0) {
|
||||
|
|
@ -1106,7 +1113,14 @@ async function saveTask(
|
|||
currentTask.endDate = currentTask.dueDate
|
||||
}
|
||||
|
||||
const updatedTask = await taskStore.update(currentTask) // TODO: markraw ?
|
||||
let updatedTask: ITask
|
||||
try {
|
||||
updatedTask = await taskStore.update(currentTask) // TODO: markraw ?
|
||||
} catch (e) {
|
||||
task.value.done = !currentTask.done
|
||||
error(e)
|
||||
return
|
||||
}
|
||||
Object.assign(task.value, updatedTask)
|
||||
setActiveFields()
|
||||
|
||||
|
|
|
|||
|
|
@ -2624,3 +2624,39 @@ func (err ErrTimeEntryEndBeforeStart) HTTPError() web.HTTPError {
|
|||
Message: "A time entry's end time cannot be before its start time.",
|
||||
}
|
||||
}
|
||||
|
||||
// Task Blocking Errors
|
||||
|
||||
// ErrTaskIsBlocked represents an error where a user tries to mark a blocked task as complete
|
||||
// when the blocking task(s) are not yet complete.
|
||||
type ErrTaskIsBlocked struct {
|
||||
TaskID int64
|
||||
BlockingTaskIDs []int64
|
||||
BlockingTaskInfo []string // Human-readable info about blockers
|
||||
}
|
||||
|
||||
// IsErrTaskIsBlocked checks if an error is ErrTaskIsBlocked.
|
||||
func IsErrTaskIsBlocked(err error) bool {
|
||||
_, ok := err.(ErrTaskIsBlocked)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrTaskIsBlocked) Error() string {
|
||||
if len(err.BlockingTaskInfo) == 1 {
|
||||
return fmt.Sprintf("Cannot mark task as complete - blocked by: %s", err.BlockingTaskInfo[0])
|
||||
}
|
||||
return fmt.Sprintf("Cannot mark task as complete - blocked by: %s", strings.Join(err.BlockingTaskInfo, ", "))
|
||||
}
|
||||
|
||||
// ErrCodeTaskIsBlocked holds the unique world-error code of this error
|
||||
const ErrCodeTaskIsBlocked = 20001
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrTaskIsBlocked) HTTPError() web.HTTPError {
|
||||
message := err.Error()
|
||||
return web.HTTPError{
|
||||
HTTPCode: http.StatusConflict,
|
||||
Code: ErrCodeTaskIsBlocked,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -420,3 +420,201 @@ func TestTaskRelation_CanCreate(t *testing.T) {
|
|||
assert.False(t, can)
|
||||
})
|
||||
}
|
||||
|
||||
// TestTaskBlockingEnforcement tests that blocked tasks cannot be completed if their blockers are not done
|
||||
func TestTaskBlockingEnforcement(t *testing.T) {
|
||||
t.Run("Task with no blockers can be marked complete", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
task := Task{ID: 1, Done: false}
|
||||
err := task.Update(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
task.Done = true
|
||||
err = task.Update(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := Task{ID: 1}
|
||||
err = updated.ReadOne(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, updated.Done)
|
||||
})
|
||||
|
||||
t.Run("Task with incomplete blocker cannot be marked complete", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Create blocking relation: task 3 blocks task 1
|
||||
rel := TaskRelation{
|
||||
TaskID: 1,
|
||||
OtherTaskID: 3,
|
||||
RelationKind: RelationKindBlocked,
|
||||
}
|
||||
err := rel.Create(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to mark task 1 as complete while task 3 is not done
|
||||
task := Task{ID: 1, Done: true}
|
||||
err = task.Update(s, &user.User{ID: 1})
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsErrTaskIsBlocked(err))
|
||||
assert.Contains(t, err.Error(), "Cannot mark task as complete")
|
||||
})
|
||||
|
||||
t.Run("Task with complete blocker can be marked complete", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Create blocking relation: task 3 blocks task 1
|
||||
rel := TaskRelation{
|
||||
TaskID: 1,
|
||||
OtherTaskID: 3,
|
||||
RelationKind: RelationKindBlocked,
|
||||
}
|
||||
err := rel.Create(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mark task 3 as complete (the blocker)
|
||||
blocker := Task{ID: 3}
|
||||
err = blocker.ReadOne(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
blocker.Done = true
|
||||
err = blocker.Update(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Now task 1 should be able to be marked complete
|
||||
task := Task{ID: 1}
|
||||
err = task.ReadOne(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
task.Done = true
|
||||
err = task.Update(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := Task{ID: 1}
|
||||
err = updated.ReadOne(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, updated.Done)
|
||||
})
|
||||
|
||||
t.Run("Multiple blockers - can only complete when all are done", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Create two blocking relations: task 3 and task 4 both block task 1
|
||||
rel1 := TaskRelation{
|
||||
TaskID: 1,
|
||||
OtherTaskID: 3,
|
||||
RelationKind: RelationKindBlocked,
|
||||
}
|
||||
err := rel1.Create(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
rel2 := TaskRelation{
|
||||
TaskID: 1,
|
||||
OtherTaskID: 4,
|
||||
RelationKind: RelationKindBlocked,
|
||||
}
|
||||
err = rel2.Create(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mark task 3 as complete (one blocker done)
|
||||
blocker1 := Task{ID: 3}
|
||||
err = blocker1.ReadOne(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
blocker1.Done = true
|
||||
err = blocker1.Update(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to mark task 1 as complete - should still fail because task 4 is not done
|
||||
task := Task{ID: 1, Done: true}
|
||||
err = task.Update(s, &user.User{ID: 1})
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsErrTaskIsBlocked(err))
|
||||
|
||||
// Mark task 4 as complete (second blocker done)
|
||||
blocker2 := Task{ID: 4}
|
||||
err = blocker2.ReadOne(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
blocker2.Done = true
|
||||
err = blocker2.Update(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Now task 1 should be able to be marked complete
|
||||
task = Task{ID: 1}
|
||||
err = task.ReadOne(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
task.Done = true
|
||||
err = task.Update(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := Task{ID: 1}
|
||||
err = updated.ReadOne(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, updated.Done)
|
||||
})
|
||||
|
||||
t.Run("Error includes blocking task information", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Create blocking relation
|
||||
rel := TaskRelation{
|
||||
TaskID: 1,
|
||||
OtherTaskID: 3,
|
||||
RelationKind: RelationKindBlocked,
|
||||
}
|
||||
err := rel.Create(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to mark task 1 as complete
|
||||
task := Task{ID: 1, Done: true}
|
||||
err = task.Update(s, &user.User{ID: 1})
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsErrTaskIsBlocked(err))
|
||||
|
||||
// Error should contain task info
|
||||
taskErr := err.(ErrTaskIsBlocked)
|
||||
assert.Len(t, taskErr.BlockingTaskIDs, 1)
|
||||
assert.Equal(t, int64(3), taskErr.BlockingTaskIDs[0])
|
||||
assert.NotEmpty(t, taskErr.BlockingTaskInfo)
|
||||
assert.Contains(t, err.Error(), "ID: 3")
|
||||
})
|
||||
|
||||
t.Run("Unmarking as done when blocked does not fail", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Create blocking relation: task 3 blocks task 1
|
||||
rel := TaskRelation{
|
||||
TaskID: 1,
|
||||
OtherTaskID: 3,
|
||||
RelationKind: RelationKindBlocked,
|
||||
}
|
||||
err := rel.Create(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Task 1 is not done, try to unmark - should succeed
|
||||
task := Task{ID: 1}
|
||||
err = task.ReadOne(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, task.Done)
|
||||
|
||||
task.Done = false
|
||||
err = task.Update(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify still not done
|
||||
updated := Task{ID: 1}
|
||||
err = updated.ReadOne(s, &user.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, updated.Done)
|
||||
})
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
|
|
@ -1254,6 +1255,29 @@ func (t *Task) updateSingleTask(s *xorm.Session, a web.Auth, fields []string) (e
|
|||
}
|
||||
}
|
||||
|
||||
// Check if task is being marked as complete (Done: false -> true)
|
||||
// If so, verify there are no active blocking relations
|
||||
if !ot.Done && t.Done {
|
||||
blockingTasks, err := getActiveBlockingRelations(s, t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(blockingTasks) > 0 {
|
||||
// Build informative error message with blocker details
|
||||
blockingInfo := make([]string, 0, len(blockingTasks))
|
||||
blockingIDs := make([]int64, 0, len(blockingTasks))
|
||||
for _, blocker := range blockingTasks {
|
||||
blockingInfo = append(blockingInfo, fmt.Sprintf("%s (ID: %d)", blocker.Title, blocker.ID))
|
||||
blockingIDs = append(blockingIDs, blocker.ID)
|
||||
}
|
||||
return ErrTaskIsBlocked{
|
||||
TaskID: t.ID,
|
||||
BlockingTaskIDs: blockingIDs,
|
||||
BlockingTaskInfo: blockingInfo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When a task was moved between projects, ensure it is in the correct bucket
|
||||
if t.ProjectID != ot.ProjectID {
|
||||
_, err = s.Where("task_id = ?", t.ID).Delete(&TaskBucket{})
|
||||
|
|
@ -2032,3 +2056,34 @@ func triggerTaskUpdatedEventForTaskID(s *xorm.Session, auth web.Auth, taskID int
|
|||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// getActiveBlockingRelations returns all tasks that are blocking the given task and are not yet complete.
|
||||
// A task is actively blocking if it has a 'blocked' relation to an incomplete task.
|
||||
func getActiveBlockingRelations(s *xorm.Session, taskID int64) ([]*Task, error) {
|
||||
// Find all 'blocked' relations where this task is the blocked one
|
||||
// i.e., this task -> other_task with relation_kind = 'blocked'
|
||||
relations := []*TaskRelation{}
|
||||
err := s.Where("task_id = ? AND relation_kind = ?", taskID, RelationKindBlocked).Find(&relations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(relations) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Extract IDs of blocking tasks
|
||||
blockingTaskIDs := make([]int64, 0, len(relations))
|
||||
for _, rel := range relations {
|
||||
blockingTaskIDs = append(blockingTaskIDs, rel.OtherTaskID)
|
||||
}
|
||||
|
||||
// Get all blocking tasks that are NOT complete
|
||||
blockingTasks := []*Task{}
|
||||
err = s.In("id", blockingTaskIDs).Where("done = ?", false).Find(&blockingTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return blockingTasks, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue