1
0

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:
konrad
2021-07-28 19:06:40 +00:00
parent dac315db59
commit 6ccb85a0dc
22 changed files with 475 additions and 96 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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