This commit is contained in:
Harsh Patel 2026-06-17 21:20:33 +02:00 committed by GitHub
commit 998e33c267
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 325 additions and 9 deletions

View File

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

View File

@ -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",

View File

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

View File

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

View File

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

View File

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