Reorder tasks, lists and kanban buckets (#923)
Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/api/pulls/923 Co-authored-by: konrad <konrad@kola-entertainments.de> Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
@ -41,6 +41,9 @@ type Bucket struct {
|
||||
// If this bucket is the "done bucket". All tasks moved into this bucket will automatically marked as done. All tasks marked as done from elsewhere will be moved into this bucket.
|
||||
IsDoneBucket bool `xorm:"BOOL" json:"is_done_bucket"`
|
||||
|
||||
// The position this bucket has when querying all buckets. See the tasks.position property on how to use this.
|
||||
Position float64 `xorm:"double null" json:"position"`
|
||||
|
||||
// A timestamp when this bucket was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
// A timestamp when this bucket was last updated. You cannot change this value.
|
||||
@ -134,7 +137,10 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int
|
||||
|
||||
// Get all buckets for this list
|
||||
buckets := []*Bucket{}
|
||||
err = s.Where("list_id = ?", b.ListID).Find(&buckets)
|
||||
err = s.
|
||||
Where("list_id = ?", b.ListID).
|
||||
OrderBy("position").
|
||||
Find(&buckets)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -167,7 +173,7 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int
|
||||
opts.sortby = []*sortParam{
|
||||
{
|
||||
orderBy: orderAscending,
|
||||
sortBy: taskPropertyPosition,
|
||||
sortBy: taskPropertyKanbanPosition,
|
||||
},
|
||||
}
|
||||
opts.page = page
|
||||
@ -251,6 +257,12 @@ func (b *Bucket) Create(s *xorm.Session, a web.Auth) (err error) {
|
||||
b.CreatedByID = b.CreatedBy.ID
|
||||
|
||||
_, err = s.Insert(b)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
b.Position = calculateDefaultPosition(b.ID, b.Position)
|
||||
_, err = s.Where("id = ?", b.ID).Update(b)
|
||||
return
|
||||
}
|
||||
|
||||
@ -289,6 +301,7 @@ func (b *Bucket) Update(s *xorm.Session, a web.Auth) (err error) {
|
||||
"title",
|
||||
"limit",
|
||||
"is_done_bucket",
|
||||
"position",
|
||||
).
|
||||
Update(b)
|
||||
return
|
||||
|
@ -49,21 +49,23 @@ func TestBucket_ReadAll(t *testing.T) {
|
||||
assert.Len(t, buckets, 3)
|
||||
|
||||
// Assert all tasks are in the right bucket
|
||||
assert.Len(t, buckets[0].Tasks, 12)
|
||||
assert.Len(t, buckets[1].Tasks, 3)
|
||||
assert.Len(t, buckets[0].Tasks, 3)
|
||||
assert.Len(t, buckets[1].Tasks, 12)
|
||||
assert.Len(t, buckets[2].Tasks, 3)
|
||||
|
||||
// Assert we have bucket 0, 1, 2, 3 but not 4 (that belongs to a different list)
|
||||
assert.Equal(t, int64(1), buckets[0].ID)
|
||||
assert.Equal(t, int64(2), buckets[1].ID)
|
||||
// Assert we have bucket 1, 2, 3 but not 4 (that belongs to a different list) and their position
|
||||
assert.Equal(t, int64(2), buckets[0].ID)
|
||||
assert.Equal(t, int64(1), buckets[1].ID)
|
||||
assert.Equal(t, int64(3), buckets[2].ID)
|
||||
|
||||
// Kinda assert all tasks are in the right buckets
|
||||
assert.Equal(t, int64(1), buckets[0].Tasks[0].BucketID)
|
||||
assert.Equal(t, int64(1), buckets[0].Tasks[1].BucketID)
|
||||
assert.Equal(t, int64(2), buckets[1].Tasks[0].BucketID)
|
||||
assert.Equal(t, int64(2), buckets[1].Tasks[1].BucketID)
|
||||
assert.Equal(t, int64(2), buckets[1].Tasks[2].BucketID)
|
||||
assert.Equal(t, int64(1), buckets[1].Tasks[0].BucketID)
|
||||
assert.Equal(t, int64(1), buckets[1].Tasks[1].BucketID)
|
||||
|
||||
assert.Equal(t, int64(2), buckets[0].Tasks[0].BucketID)
|
||||
assert.Equal(t, int64(2), buckets[0].Tasks[1].BucketID)
|
||||
assert.Equal(t, int64(2), buckets[0].Tasks[2].BucketID)
|
||||
|
||||
assert.Equal(t, int64(3), buckets[2].Tasks[0].BucketID)
|
||||
assert.Equal(t, int64(3), buckets[2].Tasks[1].BucketID)
|
||||
assert.Equal(t, int64(3), buckets[2].Tasks[2].BucketID)
|
||||
@ -87,7 +89,8 @@ func TestBucket_ReadAll(t *testing.T) {
|
||||
|
||||
buckets := bucketsInterface.([]*Bucket)
|
||||
assert.Len(t, buckets, 3)
|
||||
assert.Equal(t, int64(2), buckets[0].Tasks[0].ID)
|
||||
assert.Equal(t, int64(2), buckets[1].Tasks[0].ID)
|
||||
assert.Equal(t, int64(33), buckets[1].Tasks[1].ID)
|
||||
})
|
||||
t.Run("accessed by link share", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
|
@ -71,6 +71,9 @@ type List struct {
|
||||
// Will only returned when retreiving one list.
|
||||
Subscription *Subscription `xorm:"-" json:"subscription,omitempty"`
|
||||
|
||||
// The position this list has when querying all lists. See the tasks.position property on how to use this.
|
||||
Position float64 `xorm:"double null" json:"position"`
|
||||
|
||||
// A timestamp when this list was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
// A timestamp when this list was last updated. You cannot change this value.
|
||||
@ -284,7 +287,10 @@ func GetListSimpleByID(s *xorm.Session, listID int64) (list *List, err error) {
|
||||
return nil, ErrListDoesNotExist{ID: listID}
|
||||
}
|
||||
|
||||
exists, err := s.Where("id = ?", listID).Get(list)
|
||||
exists, err := s.
|
||||
Where("id = ?", listID).
|
||||
OrderBy("position").
|
||||
Get(list)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -361,6 +367,7 @@ func getUserListsStatement(userID int64) *builder.Builder {
|
||||
builder.Eq{"un.user_id": userID},
|
||||
builder.Eq{"l.owner_id": userID},
|
||||
)).
|
||||
OrderBy("position").
|
||||
GroupBy("l.id")
|
||||
}
|
||||
|
||||
@ -559,6 +566,12 @@ func CreateOrUpdateList(s *xorm.Session, list *List, auth web.Auth) (err error)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
list.Position = calculateDefaultPosition(list.ID, list.Position)
|
||||
_, err = s.Where("id = ?", list.ID).Update(list)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if list.IsFavorite {
|
||||
if err := addToFavorites(s, list.ID, auth, FavoriteKindList); err != nil {
|
||||
return err
|
||||
@ -572,6 +585,7 @@ func CreateOrUpdateList(s *xorm.Session, list *List, auth web.Auth) (err error)
|
||||
"identifier",
|
||||
"hex_color",
|
||||
"background_file_id",
|
||||
"position",
|
||||
}
|
||||
if list.Description != "" {
|
||||
colsToUpdate = append(colsToUpdate, "description")
|
||||
|
@ -200,8 +200,11 @@ func TestList_ReadAll(t *testing.T) {
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, reflect.TypeOf(lists3).Kind(), reflect.Slice)
|
||||
ls := reflect.ValueOf(lists3)
|
||||
assert.Equal(t, 16, ls.Len())
|
||||
ls := lists3.([]*List)
|
||||
assert.Equal(t, 16, len(ls))
|
||||
assert.Equal(t, int64(3), ls[0].ID) // List 3 has a position of 1 and should be sorted first
|
||||
assert.Equal(t, int64(1), ls[1].ID)
|
||||
assert.Equal(t, int64(4), ls[2].ID)
|
||||
_ = s.Close()
|
||||
})
|
||||
t.Run("lists for nonexistant user", func(t *testing.T) {
|
||||
|
@ -315,6 +315,7 @@ func getNamespaceSubscriptions(s *xorm.Session, namespaceIDs []int64, userID int
|
||||
func getListsForNamespaces(s *xorm.Session, namespaceIDs []int64, archived bool) ([]*List, error) {
|
||||
lists := []*List{}
|
||||
listQuery := s.
|
||||
OrderBy("position").
|
||||
In("namespace_id", namespaceIDs)
|
||||
|
||||
if !archived {
|
||||
|
@ -73,11 +73,11 @@ func validateTaskField(fieldName string) error {
|
||||
taskPropertyCreated,
|
||||
taskPropertyUpdated,
|
||||
taskPropertyPosition,
|
||||
taskPropertyKanbanPosition,
|
||||
taskPropertyBucketID:
|
||||
return nil
|
||||
}
|
||||
return ErrInvalidTaskField{TaskField: fieldName}
|
||||
|
||||
}
|
||||
|
||||
func getTaskFilterOptsFromCollection(tf *TaskCollection) (opts *taskOptions, err error) {
|
||||
|
@ -26,25 +26,26 @@ type (
|
||||
)
|
||||
|
||||
const (
|
||||
taskPropertyID string = "id"
|
||||
taskPropertyTitle string = "title"
|
||||
taskPropertyDescription string = "description"
|
||||
taskPropertyDone string = "done"
|
||||
taskPropertyDoneAt string = "done_at"
|
||||
taskPropertyDueDate string = "due_date"
|
||||
taskPropertyCreatedByID string = "created_by_id"
|
||||
taskPropertyListID string = "list_id"
|
||||
taskPropertyRepeatAfter string = "repeat_after"
|
||||
taskPropertyPriority string = "priority"
|
||||
taskPropertyStartDate string = "start_date"
|
||||
taskPropertyEndDate string = "end_date"
|
||||
taskPropertyHexColor string = "hex_color"
|
||||
taskPropertyPercentDone string = "percent_done"
|
||||
taskPropertyUID string = "uid"
|
||||
taskPropertyCreated string = "created"
|
||||
taskPropertyUpdated string = "updated"
|
||||
taskPropertyPosition string = "position"
|
||||
taskPropertyBucketID string = "bucket_id"
|
||||
taskPropertyID string = "id"
|
||||
taskPropertyTitle string = "title"
|
||||
taskPropertyDescription string = "description"
|
||||
taskPropertyDone string = "done"
|
||||
taskPropertyDoneAt string = "done_at"
|
||||
taskPropertyDueDate string = "due_date"
|
||||
taskPropertyCreatedByID string = "created_by_id"
|
||||
taskPropertyListID string = "list_id"
|
||||
taskPropertyRepeatAfter string = "repeat_after"
|
||||
taskPropertyPriority string = "priority"
|
||||
taskPropertyStartDate string = "start_date"
|
||||
taskPropertyEndDate string = "end_date"
|
||||
taskPropertyHexColor string = "hex_color"
|
||||
taskPropertyPercentDone string = "percent_done"
|
||||
taskPropertyUID string = "uid"
|
||||
taskPropertyCreated string = "created"
|
||||
taskPropertyUpdated string = "updated"
|
||||
taskPropertyPosition string = "position"
|
||||
taskPropertyKanbanPosition string = "kanban_position"
|
||||
taskPropertyBucketID string = "bucket_id"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -90,6 +90,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||
ListID: 1,
|
||||
BucketID: 1,
|
||||
IsFavorite: true,
|
||||
Position: 2,
|
||||
Labels: []*Label{
|
||||
label4,
|
||||
},
|
||||
@ -160,6 +161,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||
CreatedBy: user1,
|
||||
ListID: 1,
|
||||
BucketID: 1,
|
||||
Position: 4,
|
||||
Labels: []*Label{
|
||||
label4,
|
||||
},
|
||||
@ -517,6 +519,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||
Created: time.Unix(1543626724, 0).In(loc),
|
||||
Updated: time.Unix(1543626724, 0).In(loc),
|
||||
BucketID: 1,
|
||||
Position: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -1033,6 +1036,51 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "order by position",
|
||||
fields: fields{
|
||||
SortBy: []string{"position", "id"},
|
||||
OrderBy: []string{"asc", "asc"},
|
||||
},
|
||||
args: args{
|
||||
a: &user.User{ID: 1},
|
||||
},
|
||||
want: []*Task{
|
||||
// the other ones don't have a position set
|
||||
task3,
|
||||
task4,
|
||||
task5,
|
||||
task6,
|
||||
task7,
|
||||
task8,
|
||||
task9,
|
||||
task10,
|
||||
task11,
|
||||
task12,
|
||||
task15,
|
||||
task16,
|
||||
task17,
|
||||
task18,
|
||||
task19,
|
||||
task20,
|
||||
task21,
|
||||
task22,
|
||||
task23,
|
||||
task24,
|
||||
task25,
|
||||
task26,
|
||||
task27,
|
||||
task28,
|
||||
task29,
|
||||
task30,
|
||||
task31,
|
||||
task32,
|
||||
task33,
|
||||
// The only tasks with a position set
|
||||
task1,
|
||||
task2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
@ -118,6 +118,8 @@ type Task struct {
|
||||
// A 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task
|
||||
// which also leaves a lot of room for rearranging and sorting later.
|
||||
Position float64 `xorm:"double null" json:"position"`
|
||||
// The position of tasks in the kanban board. See the docs for the `position` property on how to use this.
|
||||
KanbanPosition float64 `xorm:"double null" json:"kanban_position"`
|
||||
|
||||
// The user who initially created the task.
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-"`
|
||||
@ -836,6 +838,14 @@ func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucke
|
||||
return nil
|
||||
}
|
||||
|
||||
func calculateDefaultPosition(entityID int64, position float64) float64 {
|
||||
if position == 0 {
|
||||
return float64(entityID) * math.Pow(2, 16)
|
||||
}
|
||||
|
||||
return position
|
||||
}
|
||||
|
||||
// Create is the implementation to create a list task
|
||||
// @Summary Create a task
|
||||
// @Description Inserts a task into a list.
|
||||
@ -895,9 +905,8 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err
|
||||
|
||||
t.Index = latestTask.Index + 1
|
||||
// If no position was supplied, set a default one
|
||||
if t.Position == 0 {
|
||||
t.Position = float64(latestTask.ID+1) * math.Pow(2, 16)
|
||||
}
|
||||
t.Position = calculateDefaultPosition(latestTask.ID+1, t.Position)
|
||||
t.KanbanPosition = calculateDefaultPosition(latestTask.ID+1, t.KanbanPosition)
|
||||
if _, err = s.Insert(t); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -1001,6 +1010,7 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) {
|
||||
"bucket_id",
|
||||
"position",
|
||||
"repeat_mode",
|
||||
"kanban_position",
|
||||
}
|
||||
|
||||
// When a repeating task is marked as done, we update all deadlines and reminders and set it as undone
|
||||
@ -1107,6 +1117,9 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) {
|
||||
if t.Position == 0 {
|
||||
ot.Position = 0
|
||||
}
|
||||
if t.KanbanPosition == 0 {
|
||||
ot.KanbanPosition = 0
|
||||
}
|
||||
// Repeat from current date
|
||||
if t.RepeatMode == TaskRepeatModeDefault {
|
||||
ot.RepeatMode = TaskRepeatModeDefault
|
||||
|
@ -192,12 +192,12 @@ func TestTask_Update(t *testing.T) {
|
||||
defer s.Close()
|
||||
|
||||
task := &Task{
|
||||
ID: 4,
|
||||
Title: "test10000",
|
||||
Description: "Lorem Ipsum Dolor",
|
||||
Position: 10,
|
||||
ListID: 1,
|
||||
BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3
|
||||
ID: 4,
|
||||
Title: "test10000",
|
||||
Description: "Lorem Ipsum Dolor",
|
||||
KanbanPosition: 10,
|
||||
ListID: 1,
|
||||
BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3
|
||||
}
|
||||
err := task.Update(s, u)
|
||||
assert.NoError(t, err)
|
||||
|
Reference in New Issue
Block a user