From 6bdb33fb468ecc5c40f5a1d66276a83635b983a7 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 13 Mar 2024 23:16:08 +0100 Subject: [PATCH 01/97] feat(views): add new model and migration --- pkg/migration/20240313230538.go | 101 ++++++++++++++++++++++++++++++++ pkg/models/models.go | 1 + pkg/models/project_view.go | 59 +++++++++++++++++++ pkg/models/task_collection.go | 2 +- 4 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 pkg/migration/20240313230538.go create mode 100644 pkg/models/project_view.go diff --git a/pkg/migration/20240313230538.go b/pkg/migration/20240313230538.go new file mode 100644 index 000000000..f68606fa5 --- /dev/null +++ b/pkg/migration/20240313230538.go @@ -0,0 +1,101 @@ +// 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 Licensee 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 Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package migration + +import ( + "src.techknowlogick.com/xormigrate" + "time" + "xorm.io/xorm" +) + +type projectView20240313230538 struct { + ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view"` + Title string `xorm:"varchar(255) not null" json:"title" valid:"runelength(1|250)"` + ProjectID int64 `xorm:"not null index" json:"project_id" param:"project"` + ViewKind int `xorm:"not null" json:"view_kind"` + Filter string `xorm:"text null default null" query:"filter" json:"filter"` + Position float64 `xorm:"double null" json:"position"` + Updated time.Time `xorm:"updated not null" json:"updated"` + Created time.Time `xorm:"created not null" json:"created"` +} + +func (projectView20240313230538) TableName() string { + return "project_views" +} + +type projects20240313230538 struct { + ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view"` +} + +func (projects20240313230538) TableName() string { + return "projects" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20240313230538", + Description: "Add project views table", + Migrate: func(tx *xorm.Engine) error { + err := tx.Sync2(projectView20240313230538{}) + if err != nil { + return err + } + + projects := []*projects20240313230538{} + err = tx.Find(&projects) + if err != nil { + return err + } + + createView := func(projectID int64, kind int, title string, position float64) error { + view := &projectView20240313230538{ + Title: title, + ProjectID: projectID, + ViewKind: kind, + Position: position, + } + + _, err := tx.Insert(view) + return err + } + + for _, project := range projects { + err = createView(project.ID, 0, "List", 100) + if err != nil { + return err + } + err = createView(project.ID, 1, "Gantt", 200) + if err != nil { + return err + } + err = createView(project.ID, 2, "Table", 300) + if err != nil { + return err + } + err = createView(project.ID, 3, "Kanban", 400) + if err != nil { + return err + } + } + + return nil + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/models.go b/pkg/models/models.go index f8404978c..96d8b3b0f 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -62,6 +62,7 @@ func GetTables() []interface{} { &TypesenseSync{}, &Webhook{}, &Reaction{}, + &ProjectView{}, } } diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go new file mode 100644 index 000000000..012f065fa --- /dev/null +++ b/pkg/models/project_view.go @@ -0,0 +1,59 @@ +// 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 Licensee 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 Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/web" + "time" +) + +type ProjectViewKind int + +const ( + ProjectViewKindList ProjectViewKind = iota + ProjectViewKindGantt + ProjectViewKindTable + ProjectViewKindKanban +) + +type ProjectView struct { + // The unique numeric id of this view + ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view"` + // The title of this view + Title string `xorm:"varchar(255) not null" json:"title" valid:"runelength(1|250)"` + // The project this view belongs to + ProjectID int64 `xorm:"not null index" json:"project_id" param:"project"` + // The kind of this view. Can be `list`, `gantt`, `table` or `kanban`. + ViewKind ProjectViewKind `xorm:"not null" json:"view_kind"` + + // The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation. + Filter string `xorm:"text null default null" query:"filter" json:"filter"` + // The position of this view in the list. The list of all views will be sorted by this parameter. + Position float64 `xorm:"double null" json:"position"` + + // A timestamp when this view was updated. You cannot change this value. + Updated time.Time `xorm:"updated not null" json:"updated"` + // A timestamp when this reaction was created. You cannot change this value. + Created time.Time `xorm:"created not null" json:"created"` + + web.CRUDable `xorm:"-" json:"-"` + web.Rights `xorm:"-" json:"-"` +} + +func (p *ProjectView) TableName() string { + return "project_views" +} diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index f32658fa4..745369d24 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -33,7 +33,7 @@ type TaskCollection struct { OrderBy []string `query:"order_by" json:"order_by"` OrderByArr []string `query:"order_by[]" json:"-"` - // The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature. + // The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation. Filter string `query:"filter" json:"filter"` // The time zone which should be used for date match (statements like "now" resolve to different actual times) FilterTimezone string `query:"filter_timezone" json:"-"` -- 2.45.1 From b39c5580c2c030193cb953b88731b26986d4bc45 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 13 Mar 2024 23:48:34 +0100 Subject: [PATCH 02/97] feat(views): add crud handlers and routes for views --- docs/content/doc/usage/errors.md | 25 ++--- pkg/models/error.go | 27 ++++++ pkg/models/project_view.go | 149 ++++++++++++++++++++++++++++++ pkg/models/project_view_rights.go | 58 ++++++++++++ pkg/routes/routes.go | 14 +++ 5 files changed, 261 insertions(+), 12 deletions(-) create mode 100644 pkg/models/project_view_rights.go diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md index 4aff46443..b2b26225c 100644 --- a/docs/content/doc/usage/errors.md +++ b/docs/content/doc/usage/errors.md @@ -55,19 +55,20 @@ This document describes the different errors Vikunja can return. ## Project -| ErrorCode | HTTP Status Code | Description | -|-----------|------------------|-------------------------------------------------------------------------------------------------------------------------------------| -| 3001 | 404 | The project does not exist. | -| 3004 | 403 | The user needs to have read permissions on that project to perform that action. | -| 3005 | 400 | The project title cannot be empty. | -| 3006 | 404 | The project share does not exist. | -| 3007 | 400 | A project with this identifier already exists. | +| ErrorCode | HTTP Status Code | Description | +|-----------|------------------|------------------------------------------------------------------------------------------------------------------------------------| +| 3001 | 404 | The project does not exist. | +| 3004 | 403 | The user needs to have read permissions on that project to perform that action. | +| 3005 | 400 | The project title cannot be empty. | +| 3006 | 404 | The project share does not exist. | +| 3007 | 400 | A project with this identifier already exists. | | 3008 | 412 | The project is archived and can therefore only be accessed read only. This is also true for all tasks associated with this project. | -| 3009 | 412 | The project cannot belong to a dynamically generated parent project like "Favorites". | -| 3010 | 412 | This project cannot be a child of itself. | -| 3011 | 412 | This project cannot have a cyclic relationship to a parent project. | -| 3012 | 412 | This project cannot be deleted because a user has set it as their default project. | -| 3013 | 412 | This project cannot be archived because a user has set it as their default project. | +| 3009 | 412 | The project cannot belong to a dynamically generated parent project like "Favorites". | +| 3010 | 412 | This project cannot be a child of itself. | +| 3011 | 412 | This project cannot have a cyclic relationship to a parent project. | +| 3012 | 412 | This project cannot be deleted because a user has set it as their default project. | +| 3013 | 412 | This project cannot be archived because a user has set it as their default project. | +| 3014 | 404 | This project view does not exist. | ## Task diff --git a/pkg/models/error.go b/pkg/models/error.go index 8eb151830..1b54b955e 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -412,6 +412,33 @@ func (err *ErrCannotArchiveDefaultProject) HTTPError() web.HTTPError { } } +// ErrProjectViewDoesNotExist represents an error where the default project is being deleted +type ErrProjectViewDoesNotExist struct { + ProjectViewID int64 +} + +// IsErrProjectViewDoesNotExist checks if an error is a project is archived error. +func IsErrProjectViewDoesNotExist(err error) bool { + _, ok := err.(*ErrProjectViewDoesNotExist) + return ok +} + +func (err *ErrProjectViewDoesNotExist) Error() string { + return fmt.Sprintf("Project view does not exist [ProjectViewID: %d]", err.ProjectViewID) +} + +// ErrCodeProjectViewDoesNotExist holds the unique world-error code of this error +const ErrCodeProjectViewDoesNotExist = 3014 + +// HTTPError holds the http error description +func (err *ErrProjectViewDoesNotExist) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusNotFound, + Code: ErrCodeProjectViewDoesNotExist, + Message: "This project view does not exist.", + } +} + // ============== // Task errors // ============== diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index 012f065fa..02b633f5e 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -19,6 +19,7 @@ package models import ( "code.vikunja.io/web" "time" + "xorm.io/xorm" ) type ProjectViewKind int @@ -57,3 +58,151 @@ type ProjectView struct { func (p *ProjectView) TableName() string { return "project_views" } + +// ReadAll gets all project views +// @Summary Get all project views for a project +// @Description Returns all project views for a sepcific project +// @tags project +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param project path int true "Project ID" +// @Success 200 {array} models.ProjectView "The project views" +// @Failure 500 {object} models.Message "Internal error" +// @Router /projects/{project}/views [get] +func (p *ProjectView) ReadAll(s *xorm.Session, a web.Auth, _ string, _ int, _ int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) { + + pp := &Project{ID: p.ProjectID} + can, _, err := pp.CanRead(s, a) + if err != nil { + return nil, 0, 0, err + } + if !can { + return nil, 0, 0, ErrGenericForbidden{} + } + + projectViews := []*ProjectView{} + err = s. + Where("project_id = ?", p.ProjectID). + Find(&projectViews) + if err != nil { + return + } + + totalCount, err := s. + Where("project_id = ?", p.ProjectID). + Count(&ProjectView{}) + if err != nil { + return + } + + return projectViews, len(projectViews), totalCount, nil +} + +// ReadOne implements the CRUD method to get one project view +// @Summary Get one project view +// @Description Returns a project view by its ID. +// @tags project +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param project path int true "Project ID" +// @Param id path int true "Project View ID" +// @Success 200 {object} models.ProjectView "The project view" +// @Failure 403 {object} web.HTTPError "The user does not have access to this project view" +// @Failure 500 {object} models.Message "Internal error" +// @Router /projects/{project}/views/{id} [get] +func (p *ProjectView) ReadOne(s *xorm.Session, _ web.Auth) (err error) { + view, err := GetProjectViewByID(s, p.ID) + if err != nil { + return err + } + + *p = *view + return +} + +// Delete removes the project view +// @Summary Delete a project view +// @Description Deletes a project view. +// @tags project +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param project path int true "Project ID" +// @Param id path int true "Project View ID" +// @Success 200 {object} models.Message "The project view was successfully deleted." +// @Failure 403 {object} web.HTTPError "The user does not have access to the project view" +// @Failure 500 {object} models.Message "Internal error" +// @Router /projects/{project}/views/{id} [delete] +func (p *ProjectView) Delete(s *xorm.Session, a web.Auth) (err error) { + _, err = s. + Where("id = ? AND projec_id = ?", p.ID, p.ProjectID). + Delete(&ProjectView{}) + return +} + +// Create adds a new project view +// @Summary Create a project view +// @Description Create a project view in a specific project. +// @tags project +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param project path int true "Project ID" +// @Param view body models.ProjectView true "The project view you want to create." +// @Success 200 {object} models.ProjectView "The created project view" +// @Failure 403 {object} web.HTTPError "The user does not have access to create a project view" +// @Failure 500 {object} models.Message "Internal error" +// @Router /projects/{project}/views [put] +func (p *ProjectView) Create(s *xorm.Session, a web.Auth) (err error) { + _, err = s.Insert(p) + return +} + +// Update is the handler to update a project view +// @Summary Updates a project view +// @Description Updates a project view. +// @tags project +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param project path int true "Project ID" +// @Param id path int true "Project View ID" +// @Param view body models.ProjectView true "The project view with updated values you want to change." +// @Success 200 {object} models.ProjectView "The updated project view." +// @Failure 400 {object} web.HTTPError "Invalid project view object provided." +// @Failure 500 {object} models.Message "Internal error" +// @Router /projects/{project}/views/{id} [post] +func (p *ProjectView) Update(s *xorm.Session, _ web.Auth) (err error) { + // Check if the project view exists + _, err = GetProjectViewByID(s, p.ID) + if err != nil { + return + } + + _, err = s.ID(p.ID).Update(p) + if err != nil { + return + } + + return +} + +func GetProjectViewByID(s *xorm.Session, id int64) (view *ProjectView, err error) { + exists, err := s. + Where("id = ?", id). + NoAutoCondition(). + Get(view) + if err != nil { + return nil, err + } + + if !exists { + return nil, &ErrProjectViewDoesNotExist{ + ProjectViewID: id, + } + } + + return +} diff --git a/pkg/models/project_view_rights.go b/pkg/models/project_view_rights.go new file mode 100644 index 000000000..7b4a92777 --- /dev/null +++ b/pkg/models/project_view_rights.go @@ -0,0 +1,58 @@ +// 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 Licensee 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 Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/web" + "xorm.io/xorm" +) + +func (p *ProjectView) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { + pp, err := p.getProject(s) + if err != nil { + return false, 0, err + } + return pp.CanRead(s, a) +} + +func (p *ProjectView) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { + pp, err := p.getProject(s) + if err != nil { + return false, err + } + return pp.CanUpdate(s, a) +} + +func (p *ProjectView) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { + pp, err := p.getProject(s) + if err != nil { + return false, err + } + return pp.CanUpdate(s, a) +} + +func (p *ProjectView) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { + pp, err := p.getProject(s) + if err != nil { + return false, err + } + return pp.CanUpdate(s, a) +} + +func (p *ProjectView) getProject(s *xorm.Session) (pp *Project, err error) { + return GetProjectSimpleByID(s, p.ProjectID) +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index d59970059..1936eaf5e 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -590,6 +590,7 @@ func registerAPIRoutes(a *echo.Group) { a.GET("/webhooks/events", apiv1.GetAvailableWebhookEvents) } + // Reactions reactionProvider := &handler.WebHandler{ EmptyStruct: func() handler.CObject { return &models.Reaction{} @@ -598,6 +599,19 @@ func registerAPIRoutes(a *echo.Group) { a.GET("/:entitykind/:entityid/reactions", reactionProvider.ReadAllWeb) a.POST("/:entitykind/:entityid/reactions/delete", reactionProvider.DeleteWeb) a.PUT("/:entitykind/:entityid/reactions", reactionProvider.CreateWeb) + + // Project views + projectViewProvider := &handler.WebHandler{ + EmptyStruct: func() handler.CObject { + return &models.ProjectView{} + }, + } + + a.GET("/projects/:project/views", projectViewProvider.ReadAllWeb) + a.GET("/projects/:project/views/:view", projectViewProvider.ReadOneWeb) + a.PUT("/projects/:project/views", projectViewProvider.CreateWeb) + a.DELETE("/projects/:project/views/:view", projectViewProvider.DeleteWeb) + a.POST("/projects/:project/views/:view", projectViewProvider.UpdateWeb) } func registerMigrations(m *echo.Group) { -- 2.45.1 From ee228106fcfb57ee1bc8f1412bd094f190174cad Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 13 Mar 2024 23:54:28 +0100 Subject: [PATCH 03/97] feat(views): add new default views for filters --- pkg/migration/20240313230538.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pkg/migration/20240313230538.go b/pkg/migration/20240313230538.go index f68606fa5..0bdea57c5 100644 --- a/pkg/migration/20240313230538.go +++ b/pkg/migration/20240313230538.go @@ -45,6 +45,14 @@ func (projects20240313230538) TableName() string { return "projects" } +type filters20240313230538 struct { + ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view"` +} + +func (filters20240313230538) TableName() string { + return "saved_filters" +} + func init() { migrations = append(migrations, &xormigrate.Migration{ ID: "20240313230538", @@ -92,6 +100,31 @@ func init() { } } + filters := []*filters20240313230538{} + err = tx.Find(&filters) + if err != nil { + return err + } + + for _, filter := range filters { + err = createView(filter.ID*-1-1, 0, "List", 100) + if err != nil { + return err + } + err = createView(filter.ID*-1-1, 1, "Gantt", 200) + if err != nil { + return err + } + err = createView(filter.ID*-1-1, 2, "Table", 300) + if err != nil { + return err + } + err = createView(filter.ID*-1-1, 3, "Kanban", 400) + if err != nil { + return err + } + } + return nil }, Rollback: func(tx *xorm.Engine) error { -- 2.45.1 From 2fa3e2c2f5165d302d511ffaca8e724c79aca0b0 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Mar 2024 09:36:39 +0100 Subject: [PATCH 04/97] feat(views): return views with their projects --- pkg/models/project.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/pkg/models/project.go b/pkg/models/project.go index 7be2d933c..878c3e1a0 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -80,6 +80,8 @@ type Project struct { // The position this project has when querying all projects. See the tasks.position property on how to use this. Position float64 `xorm:"double null" json:"position"` + Views []*ProjectView `xorm:"-" json:"views"` + // A timestamp when this project was created. You cannot change this value. Created time.Time `xorm:"created not null" json:"created"` // A timestamp when this project was last updated. You cannot change this value. @@ -266,6 +268,9 @@ func (p *Project) ReadOne(s *xorm.Session, a web.Auth) (err error) { return nil } + err = s. + Where("project_id = ?", p.ID). + Find(&p.Views) return } @@ -587,6 +592,23 @@ func addProjectDetails(s *xorm.Session, projects []*Project, a web.Auth) (err er subscriptions = make(map[int64][]*Subscription) } + views := []*ProjectView{} + err = s. + In("project_id", projectIDs). + Find(&views) + if err != nil { + return + } + + viewMap := make(map[int64][]*ProjectView) + for _, v := range views { + if _, has := viewMap[v.ProjectID]; !has { + viewMap[v.ProjectID] = []*ProjectView{} + } + + viewMap[v.ProjectID] = append(viewMap[v.ProjectID], v) + } + for _, p := range projects { if o, exists := owners[p.OwnerID]; exists { p.Owner = o @@ -604,6 +626,11 @@ func addProjectDetails(s *xorm.Session, projects []*Project, a web.Auth) (err er if subscription, exists := subscriptions[p.ID]; exists && len(subscription) > 0 { p.Subscription = subscription[0] } + + vs, has := viewMap[p.ID] + if has { + p.Views = vs + } } if len(fileIDs) == 0 { -- 2.45.1 From e4b1a5d2db528c844b01f46cd17eea342d121343 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Mar 2024 09:41:55 +0100 Subject: [PATCH 05/97] feat(views): create default 4 default view for projects --- pkg/models/project.go | 5 +++++ pkg/models/project_view.go | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/pkg/models/project.go b/pkg/models/project.go index 878c3e1a0..5e36e76bb 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -789,6 +789,11 @@ func CreateProject(s *xorm.Session, project *Project, auth web.Auth, createBackl } } + err = CreateDefaultViewsForProject(s, project, auth) + if err != nil { + return + } + return events.Dispatch(&ProjectCreatedEvent{ Project: project, Doer: doer, diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index 02b633f5e..2767e7723 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -206,3 +206,47 @@ func GetProjectViewByID(s *xorm.Session, id int64) (view *ProjectView, err error return } + +func CreateDefaultViewsForProject(s *xorm.Session, project *Project, a web.Auth) (err error) { + list := &ProjectView{ + ProjectID: project.ID, + Title: "List", + ViewKind: ProjectViewKindList, + Position: 100, + } + err = list.Create(s, a) + if err != nil { + return + } + + gantt := &ProjectView{ + ProjectID: project.ID, + Title: "Gantt", + ViewKind: ProjectViewKindGantt, + Position: 200, + } + err = gantt.Create(s, a) + if err != nil { + return + } + + table := &ProjectView{ + ProjectID: project.ID, + Title: "Table", + ViewKind: ProjectViewKindTable, + Position: 300, + } + err = table.Create(s, a) + if err != nil { + return + } + + kanban := &ProjectView{ + ProjectID: project.ID, + Title: "Kanban", + ViewKind: ProjectViewKindKanban, + Position: 400, + } + err = kanban.Create(s, a) + return +} -- 2.45.1 From 2096fc52740a12200b62f743099e3729486ef013 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Mar 2024 09:47:42 +0100 Subject: [PATCH 06/97] feat(views): return tasks in a view --- pkg/models/task_collection.go | 15 +++++++++++++-- pkg/routes/routes.go | 1 + 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 745369d24..78709f535 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -24,7 +24,8 @@ import ( // TaskCollection is a struct used to hold filter details and not clutter the Task struct with information not related to actual tasks. type TaskCollection struct { - ProjectID int64 `param:"project" json:"-"` + ProjectID int64 `param:"project" json:"-"` + ProjectViewID int64 `param:"view" json:"-"` // The query parameter to sort by. This is for ex. done, priority, etc. SortBy []string `query:"sort_by" json:"sort_by"` @@ -120,6 +121,7 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection) (opts *taskSearchOption // @Accept json // @Produce json // @Param id path int true "The project ID." +// @Param view path int true "The project view ID." // @Param page query int false "The page number. Used for pagination. If not provided, the first page of results is returned." // @Param per_page query int false "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page." // @Param s query string false "Search tasks by task text." @@ -131,7 +133,7 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection) (opts *taskSearchOption // @Security JWTKeyAuth // @Success 200 {array} models.Task "The tasks" // @Failure 500 {object} models.Message "Internal error" -// @Router /projects/{id}/tasks [get] +// @Router /projects/{id}/views/{view}/tasks [get] func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, totalItems int64, err error) { // If the project id is < -1 this means we're dealing with a saved filter - in that case we get and populate the filter @@ -169,6 +171,15 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa return sf.getTaskCollection().ReadAll(s, a, search, page, perPage) } + if tf.ProjectViewID != 0 { + view, err := GetProjectViewByID(s, tf.ProjectViewID) + if err != nil { + return nil, 0, 0, err + } + + tf.Filter = view.Filter + } + taskopts, err := getTaskFilterOptsFromCollection(tf) if err != nil { return nil, 0, 0, err diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 1936eaf5e..18e05ae2a 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -355,6 +355,7 @@ func registerAPIRoutes(a *echo.Group) { return &models.TaskCollection{} }, } + a.GET("/projects/:project/views/:view/tasks", taskCollectionHandler.ReadAllWeb) a.GET("/projects/:project/tasks", taskCollectionHandler.ReadAllWeb) kanbanBucketHandler := &handler.WebHandler{ -- 2.45.1 From 4149ebed3a9f46c134e6acc02b9c5f40d7a05f6c Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Mar 2024 15:42:15 +0100 Subject: [PATCH 07/97] feat(views): create default views when creating a filter --- pkg/models/saved_filters.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/models/saved_filters.go b/pkg/models/saved_filters.go index be320f347..49fd613a5 100644 --- a/pkg/models/saved_filters.go +++ b/pkg/models/saved_filters.go @@ -116,9 +116,14 @@ func (sf *SavedFilter) toProject() *Project { // @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter." // @Failure 500 {object} models.Message "Internal error" // @Router /filters [put] -func (sf *SavedFilter) Create(s *xorm.Session, auth web.Auth) error { +func (sf *SavedFilter) Create(s *xorm.Session, auth web.Auth) (err error) { sf.OwnerID = auth.GetID() - _, err := s.Insert(sf) + _, err = s.Insert(sf) + if err != nil { + return + } + + err = CreateDefaultViewsForProject(s, &Project{ID: getProjectIDFromSavedFilterID(sf.ID)}, auth) return err } -- 2.45.1 From 98b7cc9254ebf2c50d721fcc23aee104b4887e4e Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Mar 2024 15:44:31 +0100 Subject: [PATCH 08/97] feat(views): do not override filters in view --- pkg/models/task_collection.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 78709f535..0c34d1a54 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -177,7 +177,11 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa return nil, 0, 0, err } - tf.Filter = view.Filter + if tf.Filter != "" { + tf.Filter = "(" + tf.Filter + ") && (" + view.Filter + ")" + } else { + tf.Filter = view.Filter + } } taskopts, err := getTaskFilterOptsFromCollection(tf) -- 2.45.1 From 38457aaca5276af456109238504e30acb86fc0f4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Mar 2024 15:45:50 +0100 Subject: [PATCH 09/97] feat(views): use project id when fetching views --- pkg/models/project_view.go | 8 ++++---- pkg/models/task_collection.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index 2767e7723..f0f29e353 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -113,7 +113,7 @@ func (p *ProjectView) ReadAll(s *xorm.Session, a web.Auth, _ string, _ int, _ in // @Failure 500 {object} models.Message "Internal error" // @Router /projects/{project}/views/{id} [get] func (p *ProjectView) ReadOne(s *xorm.Session, _ web.Auth) (err error) { - view, err := GetProjectViewByID(s, p.ID) + view, err := GetProjectViewByID(s, p.ID, p.ProjectID) if err != nil { return err } @@ -176,7 +176,7 @@ func (p *ProjectView) Create(s *xorm.Session, a web.Auth) (err error) { // @Router /projects/{project}/views/{id} [post] func (p *ProjectView) Update(s *xorm.Session, _ web.Auth) (err error) { // Check if the project view exists - _, err = GetProjectViewByID(s, p.ID) + _, err = GetProjectViewByID(s, p.ID, p.ProjectID) if err != nil { return } @@ -189,9 +189,9 @@ func (p *ProjectView) Update(s *xorm.Session, _ web.Auth) (err error) { return } -func GetProjectViewByID(s *xorm.Session, id int64) (view *ProjectView, err error) { +func GetProjectViewByID(s *xorm.Session, id, projectID int64) (view *ProjectView, err error) { exists, err := s. - Where("id = ?", id). + Where("id = ? AND project_id = ?", id, projectID). NoAutoCondition(). Get(view) if err != nil { diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 0c34d1a54..b178ba292 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -172,7 +172,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa } if tf.ProjectViewID != 0 { - view, err := GetProjectViewByID(s, tf.ProjectViewID) + view, err := GetProjectViewByID(s, tf.ProjectViewID, tf.ProjectID) if err != nil { return nil, 0, 0, err } -- 2.45.1 From a9020e976d696c7f74f4369906d7202262649fac Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Mar 2024 15:58:14 +0100 Subject: [PATCH 10/97] feat(views): add bucket configuration mode --- pkg/migration/20240313230538.go | 29 +++++++++++++++++++++-------- pkg/models/project_view.go | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/pkg/migration/20240313230538.go b/pkg/migration/20240313230538.go index 0bdea57c5..04abf05fa 100644 --- a/pkg/migration/20240313230538.go +++ b/pkg/migration/20240313230538.go @@ -22,15 +22,24 @@ import ( "xorm.io/xorm" ) +type projectViewBucketConfiguration20240313230538 struct { + Title string + Filter string +} + type projectView20240313230538 struct { - ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view"` - Title string `xorm:"varchar(255) not null" json:"title" valid:"runelength(1|250)"` - ProjectID int64 `xorm:"not null index" json:"project_id" param:"project"` - ViewKind int `xorm:"not null" json:"view_kind"` - Filter string `xorm:"text null default null" query:"filter" json:"filter"` - Position float64 `xorm:"double null" json:"position"` - Updated time.Time `xorm:"updated not null" json:"updated"` - Created time.Time `xorm:"created not null" json:"created"` + ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view"` + Title string `xorm:"varchar(255) not null" json:"title" valid:"runelength(1|250)"` + ProjectID int64 `xorm:"not null index" json:"project_id" param:"project"` + ViewKind int `xorm:"not null" json:"view_kind"` + Filter string `xorm:"text null default null" query:"filter" json:"filter"` + Position float64 `xorm:"double null" json:"position"` + + BucketConfigurationMode int `xorm:"default 0" json:"bucket_configuration_mode"` + BucketConfiguration []*projectViewBucketConfiguration20240313230538 `xorm:"json" json:"bucket_configuration"` + + Updated time.Time `xorm:"updated not null" json:"updated"` + Created time.Time `xorm:"created not null" json:"created"` } func (projectView20240313230538) TableName() string { @@ -77,6 +86,10 @@ func init() { Position: position, } + if kind == 3 { + view.BucketConfigurationMode = 1 + } + _, err := tx.Insert(view) return err } diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index f0f29e353..3b1ec7a42 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -31,6 +31,19 @@ const ( ProjectViewKindKanban ) +type BucketConfigurationModeKind int + +const ( + BucketConfigurationModeNone BucketConfigurationModeKind = iota + BucketConfigurationModeManual + BucketConfigurationModeFilter +) + +type ProjectViewBucketConfiguration struct { + Title string + Filter string +} + type ProjectView struct { // The unique numeric id of this view ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view"` @@ -46,6 +59,11 @@ type ProjectView struct { // The position of this view in the list. The list of all views will be sorted by this parameter. Position float64 `xorm:"double null" json:"position"` + // The bucket configuration mode. Can be `none`, `manual` or `filter`. `manual` allows to move tasks between buckets as you normally would. `filter` creates buckets based on a filter for each bucket. + BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode"` + // When the bucket configuration mode is not `manual`, this field holds the options of that configuration. + BucketConfiguration []*ProjectViewBucketConfiguration `xorm:"json" json:"bucket_configuration"` + // A timestamp when this view was updated. You cannot change this value. Updated time.Time `xorm:"updated not null" json:"updated"` // A timestamp when this reaction was created. You cannot change this value. -- 2.45.1 From 652bf4b4ed58ae44f2af28c908f41b4dbb8c95c3 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Mar 2024 16:07:03 +0100 Subject: [PATCH 11/97] feat(views): (un)marshal custom project view mode types --- pkg/models/project_view.go | 70 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index 3b1ec7a42..ab926c42a 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -18,12 +18,50 @@ package models import ( "code.vikunja.io/web" + "encoding/json" + "fmt" "time" "xorm.io/xorm" ) type ProjectViewKind int +func (p *ProjectViewKind) MarshalJSON() ([]byte, error) { + switch *p { + case ProjectViewKindList: + return []byte(`"list"`), nil + case ProjectViewKindGantt: + return []byte(`"gantt"`), nil + case ProjectViewKindTable: + return []byte(`"table"`), nil + case ProjectViewKindKanban: + return []byte(`"kanban"`), nil + } + + return []byte(`null`), nil +} + +func (p *ProjectViewKind) UnmarshalJSON(bytes []byte) error { + var value string + err := json.Unmarshal(bytes, &value) + if err != nil { + return err + } + + switch value { + case "list": + *p = ProjectViewKindList + case "gantt": + *p = ProjectViewKindGantt + case "table": + *p = ProjectViewKindTable + case "kanban": + *p = ProjectViewKindKanban + } + + return fmt.Errorf("unkown project view kind: %s", bytes) +} + const ( ProjectViewKindList ProjectViewKind = iota ProjectViewKindGantt @@ -39,6 +77,38 @@ const ( BucketConfigurationModeFilter ) +func (p *BucketConfigurationModeKind) MarshalJSON() ([]byte, error) { + switch *p { + case BucketConfigurationModeNone: + return []byte(`"none"`), nil + case BucketConfigurationModeManual: + return []byte(`"manual"`), nil + case BucketConfigurationModeFilter: + return []byte(`"filter"`), nil + } + + return []byte(`null`), nil +} + +func (p *BucketConfigurationModeKind) UnmarshalJSON(bytes []byte) error { + var value string + err := json.Unmarshal(bytes, &value) + if err != nil { + return err + } + + switch value { + case "none": + *p = BucketConfigurationModeNone + case "manual": + *p = BucketConfigurationModeManual + case "filter": + *p = BucketConfigurationModeFilter + } + + return fmt.Errorf("unkown bucket configuration mode kind: %s", bytes) +} + type ProjectViewBucketConfiguration struct { Title string Filter string -- 2.45.1 From 238baf86f74c515e5232d220b24fbfef1521b179 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Mar 2024 21:32:42 +0100 Subject: [PATCH 12/97] feat(views)!: return tasks in buckets by view BREAKING CHANGE: tasks in their bucket are now only retrievable via their view. The /project/:id/buckets endpoint now only returns the buckets for that project, which is more in line with the other endpoints --- pkg/models/kanban.go | 87 +++++++++++++++++++++++++---------- pkg/models/task_collection.go | 22 ++++++--- 2 files changed, 77 insertions(+), 32 deletions(-) diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index 0b6adaffc..2672ce132 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -97,21 +97,15 @@ func getDefaultBucketID(s *xorm.Session, project *Project) (bucketID int64, err return bucket.ID, nil } -// ReadAll returns all buckets with their tasks for a certain project +// ReadAll returns all manual buckets for a certain project // @Summary Get all kanban buckets of a project -// @Description Returns all kanban buckets with belong to a project including their tasks. Buckets are always sorted by their `position` in ascending order. Tasks are sorted by their `kanban_position` in ascending order. +// @Description Returns all kanban buckets which belong to that project. Buckets are always sorted by their `position` in ascending order. To get all buckets with their tasks, use the tasks endpoint with a kanban view. // @tags project // @Accept json // @Produce json // @Security JWTKeyAuth -// @Param id path int true "Project Id" -// @Param page query int false "The page number for tasks. Used for pagination. If not provided, the first page of results is returned." -// @Param per_page query int false "The maximum number of tasks per bucket per page. This parameter is limited by the configured maximum of items per page." -// @Param s query string false "Search tasks by task text." -// @Param filter query string false "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature." -// @Param filter_timezone query string false "The time zone which should be used for date match (statements like "now" resolve to different actual times)" -// @Param filter_include_nulls query string false "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`." -// @Success 200 {array} models.Bucket "The buckets with their tasks" +// @Param id path int true "Project ID" +// @Success 200 {array} models.Bucket "The buckets" // @Failure 500 {object} models.Message "Internal server error" // @Router /projects/{id}/buckets [get] func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) { @@ -129,7 +123,6 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int return nil, 0, 0, ErrGenericForbidden{} } - // Get all buckets for this project buckets := []*Bucket{} err = s. Where("project_id = ?", b.ProjectID). @@ -139,6 +132,52 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int return } + userIDs := make([]int64, 0, len(buckets)) + for _, bb := range buckets { + userIDs = append(userIDs, bb.CreatedByID) + } + + // Get all users + users, err := getUsersOrLinkSharesFromIDs(s, userIDs) + if err != nil { + return + } + + for _, bb := range buckets { + bb.CreatedBy = users[bb.CreatedByID] + } + + return buckets, len(buckets), int64(len(buckets)), nil +} + +func GetTasksInBucketsForView(s *xorm.Session, view *ProjectView, opts *taskSearchOptions, auth web.Auth) (bucketsWithTasks []*Bucket, err error) { + // Get all buckets for this project + buckets := []*Bucket{} + + if view.BucketConfigurationMode == BucketConfigurationModeManual { + err = s. + Where("project_id = ?", view.ProjectID). + OrderBy("position"). + Find(&buckets) + if err != nil { + return + } + } + + if view.BucketConfigurationMode == BucketConfigurationModeFilter { + for id, bc := range view.BucketConfiguration { + buckets = append(buckets, &Bucket{ + ID: int64(id), + Title: bc.Title, + ProjectID: view.ProjectID, + Position: float64(id), + CreatedByID: auth.GetID(), + Created: time.Now(), + Updated: time.Now(), + }) + } + } + // Make a map from the bucket slice with their id as key so that we can use it to put the tasks in their buckets bucketMap := make(map[int64]*Bucket, len(buckets)) userIDs := make([]int64, 0, len(buckets)) @@ -159,20 +198,12 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int tasks := []*Task{} - opts, err := getTaskFilterOptsFromCollection(&b.TaskCollection) - if err != nil { - return nil, 0, 0, err - } - opts.sortby = []*sortParam{ { orderBy: orderAscending, sortBy: taskPropertyKanbanPosition, }, } - opts.page = page - opts.perPage = perPage - opts.search = search for _, filter := range opts.parsedFilters { if filter.field == taskPropertyBucketID { @@ -192,11 +223,17 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int for id, bucket := range bucketMap { if !strings.Contains(originalFilter, "bucket_id") { + + var bucketFilter = "bucket_id = " + strconv.FormatInt(id, 10) + if view.BucketConfigurationMode == BucketConfigurationModeFilter { + bucketFilter = "(" + view.BucketConfiguration[id].Filter + ")" + } + var filterString string if originalFilter == "" { - filterString = "bucket_id = " + strconv.FormatInt(id, 10) + filterString = bucketFilter } else { - filterString = "(" + originalFilter + ") && bucket_id = " + strconv.FormatInt(id, 10) + filterString = "(" + originalFilter + ") && " + bucketFilter } opts.parsedFilters, err = getTaskFiltersFromFilterString(filterString, opts.filterTimezone) if err != nil { @@ -206,7 +243,7 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int ts, _, total, err := getRawTasksForProjects(s, []*Project{{ID: bucket.ProjectID}}, auth, opts) if err != nil { - return nil, 0, 0, err + return nil, err } bucket.Count = total @@ -221,7 +258,7 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int err = addMoreInfoToTasks(s, taskMap, auth) if err != nil { - return nil, 0, 0, err + return nil, err } // Put all tasks in their buckets @@ -230,13 +267,13 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int for _, task := range tasks { // Check if the bucket exists in the map to prevent nil pointer panics if _, exists := bucketMap[task.BucketID]; !exists { - log.Debugf("Tried to put task %d into bucket %d which does not exist in project %d", task.ID, task.BucketID, b.ProjectID) + log.Debugf("Tried to put task %d into bucket %d which does not exist in project %d", task.ID, task.BucketID, view.ProjectID) continue } bucketMap[task.BucketID].Tasks = append(bucketMap[task.BucketID].Tasks, task) } - return buckets, len(buckets), int64(len(buckets)), nil + return buckets, nil } // Create creates a new bucket diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index b178ba292..9163746b2 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -171,8 +171,9 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa return sf.getTaskCollection().ReadAll(s, a, search, page, perPage) } + var view *ProjectView if tf.ProjectViewID != 0 { - view, err := GetProjectViewByID(s, tf.ProjectViewID, tf.ProjectID) + view, err = GetProjectViewByID(s, tf.ProjectViewID, tf.ProjectID) if err != nil { return nil, 0, 0, err } @@ -184,14 +185,14 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa } } - taskopts, err := getTaskFilterOptsFromCollection(tf) + opts, err := getTaskFilterOptsFromCollection(tf) if err != nil { return nil, 0, 0, err } - taskopts.search = search - taskopts.page = page - taskopts.perPage = perPage + opts.search = search + opts.page = page + opts.perPage = perPage shareAuth, is := a.(*LinkSharing) if is { @@ -199,7 +200,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa if err != nil { return nil, 0, 0, err } - return getTasksForProjects(s, []*Project{project}, a, taskopts) + return getTasksForProjects(s, []*Project{project}, a, opts) } // If the project ID is not set, we get all tasks for the user. @@ -232,5 +233,12 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa projects = []*Project{{ID: tf.ProjectID}} } - return getTasksForProjects(s, projects, a, taskopts) + if view != nil { + if view.BucketConfigurationMode != BucketConfigurationModeNone { + tasksInBuckets, err := GetTasksInBucketsForView(s, view, opts, a) + return tasksInBuckets, len(tasksInBuckets), int64(len(tasksInBuckets)), err + } + } + + return getTasksForProjects(s, projects, a, opts) } -- 2.45.1 From 2502776460bfd949aafd697210e4c22addfa34f4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Mar 2024 22:28:07 +0100 Subject: [PATCH 13/97] feat(views)!: move task position handling to its own crud entity BREAKING CHANGE: the position of tasks now can't be updated anymore via the task update endpoint. Instead, there is a new endpoint which takes the project view into account as well. --- pkg/migration/20240314214802.go | 200 ++++++++++++++++++++ pkg/models/kanban.go | 2 +- pkg/models/task_collection.go | 1 - pkg/models/task_collection_sort.go | 41 ++-- pkg/models/task_position.go | 115 +++++++++++ pkg/models/tasks.go | 51 +---- pkg/models/tasks_test.go | 11 +- pkg/models/typesense.go | 6 - pkg/modules/migration/trello/trello.go | 5 +- pkg/modules/migration/trello/trello_test.go | 54 +++--- pkg/routes/routes.go | 7 + 11 files changed, 375 insertions(+), 118 deletions(-) create mode 100644 pkg/migration/20240314214802.go create mode 100644 pkg/models/task_position.go diff --git a/pkg/migration/20240314214802.go b/pkg/migration/20240314214802.go new file mode 100644 index 000000000..6797528ad --- /dev/null +++ b/pkg/migration/20240314214802.go @@ -0,0 +1,200 @@ +// 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 Licensee 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 Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package migration + +import ( + "code.vikunja.io/api/pkg/config" + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type taskPositions20240314214802 struct { + TaskID int64 `xorm:"bigint not null index" json:"task_id"` + ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id"` + Position float64 `xorm:"double not null" json:"position"` +} + +func (taskPositions20240314214802) TableName() string { + return "task_positions" +} + +type task20240314214802 struct { + ID int64 `xorm:"bigint autoincr not null unique pk"` + ProjectID int64 `xorm:"bigint INDEX not null"` + Position float64 `xorm:"double not null"` + KanbanPosition float64 `xorm:"double not null"` +} + +func (task20240314214802) TableName() string { + return "tasks" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20240314214802", + Description: "make task position seperate", + Migrate: func(tx *xorm.Engine) error { + err := tx.Sync2(taskPositions20240314214802{}) + if err != nil { + return err + } + + tasks := []*task20240314214802{} + err = tx.Find(&tasks) + if err != nil { + return err + } + + views := []*projectView20240313230538{} + err = tx.Find(&views) + if err != nil { + return err + } + + viewMap := make(map[int64][]*projectView20240313230538) + for _, view := range views { + if _, has := viewMap[view.ProjectID]; !has { + viewMap[view.ProjectID] = []*projectView20240313230538{} + } + + viewMap[view.ProjectID] = append(viewMap[view.ProjectID], view) + } + + for _, task := range tasks { + for _, view := range viewMap[task.ProjectID] { + if view.ViewKind == 0 { // List view + position := &taskPositions20240314214802{ + TaskID: task.ID, + Position: task.Position, + ProjectViewID: view.ID, + } + _, err = tx.Insert(position) + if err != nil { + return err + } + } + if view.ViewKind == 3 { // Kanban view + position := &taskPositions20240314214802{ + TaskID: task.ID, + Position: task.KanbanPosition, + ProjectViewID: view.ID, + } + _, err = tx.Insert(position) + if err != nil { + return err + } + } + } + } + + if config.DatabaseType.GetString() == "sqlite" { + _, err = tx.Exec(` +create table tasks_dg_tmp +( + id INTEGER not null + primary key autoincrement, + title TEXT not null, + description TEXT, + done INTEGER, + done_at DATETIME, + due_date DATETIME, + project_id INTEGER not null, + repeat_after INTEGER, + repeat_mode INTEGER default 0 not null, + priority INTEGER, + start_date DATETIME, + end_date DATETIME, + hex_color TEXT, + percent_done REAL, + "index" INTEGER default 0 not null, + uid TEXT, + cover_image_attachment_id INTEGER default 0, + created DATETIME not null, + updated DATETIME not null, + bucket_id INTEGER, + created_by_id INTEGER not null +); + +insert into tasks_dg_tmp(id, title, description, done, done_at, due_date, project_id, repeat_after, repeat_mode, + priority, start_date, end_date, hex_color, percent_done, "index", uid, + cover_image_attachment_id, created, updated, bucket_id, created_by_id) +select id, + title, + description, + done, + done_at, + due_date, + project_id, + repeat_after, + repeat_mode, + priority, + start_date, + end_date, + hex_color, + percent_done, + "index", + uid, + cover_image_attachment_id, + created, + updated, + bucket_id, + created_by_id +from tasks; + +drop table tasks; + +alter table tasks_dg_tmp + rename to tasks; + +create index IDX_tasks_done + on tasks (done); + +create index IDX_tasks_done_at + on tasks (done_at); + +create index IDX_tasks_due_date + on tasks (due_date); + +create index IDX_tasks_end_date + on tasks (end_date); + +create index IDX_tasks_project_id + on tasks (project_id); + +create index IDX_tasks_repeat_after + on tasks (repeat_after); + +create index IDX_tasks_start_date + on tasks (start_date); + +create unique index UQE_tasks_id + on tasks (id); +`) + return err + } + + err = dropTableColum(tx, "tasks", "position") + if err != nil { + return err + } + return dropTableColum(tx, "tasks", "kanban_position") + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index 2672ce132..97e734a15 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -201,7 +201,7 @@ func GetTasksInBucketsForView(s *xorm.Session, view *ProjectView, opts *taskSear opts.sortby = []*sortParam{ { orderBy: orderAscending, - sortBy: taskPropertyKanbanPosition, + sortBy: taskPropertyPosition, }, } diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 9163746b2..9b3a2d73d 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -67,7 +67,6 @@ func validateTaskField(fieldName string) error { taskPropertyCreated, taskPropertyUpdated, taskPropertyPosition, - taskPropertyKanbanPosition, taskPropertyBucketID, taskPropertyIndex: return nil diff --git a/pkg/models/task_collection_sort.go b/pkg/models/task_collection_sort.go index 8b6a2f06b..4777b3bfe 100644 --- a/pkg/models/task_collection_sort.go +++ b/pkg/models/task_collection_sort.go @@ -26,27 +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" - taskPropertyProjectID string = "project_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" - taskPropertyIndex string = "index" + 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" + taskPropertyProjectID string = "project_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" + taskPropertyIndex string = "index" ) const ( diff --git a/pkg/models/task_position.go b/pkg/models/task_position.go new file mode 100644 index 000000000..87f8f4845 --- /dev/null +++ b/pkg/models/task_position.go @@ -0,0 +1,115 @@ +// 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 Licensee 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 Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/web" + "math" + "xorm.io/xorm" +) + +type TaskPosition struct { + // The ID of the task this position is for + TaskID int64 `xorm:"bigint not null index" json:"task_id" param:"task"` + // The project view this task is related to + ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id"` + // The position of the task - any task project can be sorted as usual by this parameter. + // When accessing tasks via kanban buckets, this is primarily used to sort them based on a range + // We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number). + // You would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2. + // 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. + // Positions are always saved per view. They will automatically be set if you request the tasks through a view + // endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. + Position float64 `xorm:"double not null" json:"position"` + + web.CRUDable `xorm:"-" json:"-"` + web.Rights `xorm:"-" json:"-"` +} + +func (tp *TaskPosition) TableName() string { + return "task_positions" +} + +func (tp *TaskPosition) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { + pv := &ProjectView{ID: tp.ProjectViewID} + return pv.CanUpdate(s, a) +} + +// Update is the handler to update a task position +// @Summary Updates a task position +// @Description Updates a task position. +// @tags task +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param id path int true "Task ID" +// @Param view body models.TaskPosition true "The task position with updated values you want to change." +// @Success 200 {object} models.TaskPosition "The updated task position." +// @Failure 400 {object} web.HTTPError "Invalid task position object provided." +// @Failure 500 {object} models.Message "Internal error" +// @Router /tasks/{id}/position [post] +func (tp *TaskPosition) Update(s *xorm.Session, _ web.Auth) (err error) { + exists, err := s. + Where("task_id = ? AND project_view_id = ?", tp.TaskID, tp.ProjectViewID). + Get(&TaskPosition{}) + if err != nil { + return err + } + + if !exists { + _, err = s.Insert(tp) + return + } + + _, err = s. + Where("task_id = ?", tp.TaskID). + Cols("project_view_id", "position"). + Update(tp) + return +} + +func RecalculateTaskPositions(s *xorm.Session, view *ProjectView) (err error) { + + allTasks := []*Task{} + err = s. + Select("tasks.*, task_positions.position AS position"). + Join("LEFT", "task_positions", "task_positions.task_id = tasks.id AND task_positions.project_view_id = ?", view.ID). + Where("project_id = ?", view.ProjectID). + OrderBy("position asc"). + Find(&allTasks) + if err != nil { + return + } + + maxPosition := math.Pow(2, 32) + newPositions := make([]*TaskPosition, 0, len(allTasks)) + + for i, task := range allTasks { + + currentPosition := maxPosition / float64(len(allTasks)) * (float64(i + 1)) + + newPositions = append(newPositions, &TaskPosition{ + TaskID: task.ID, + ProjectViewID: view.ID, + Position: currentPosition, + }) + } + + _, err = s.Insert(newPositions) + return +} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 4a3b28da7..48b5e1faf 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -121,9 +121,9 @@ type Task struct { // You would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2. // 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"` + // Positions are always saved per view. They will automatically be set if you request the tasks through a view + // endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. + Position float64 `xorm:"-" json:"position"` // Reactions on that task. Reactions ReactionMap `xorm:"-" json:"reactions"` @@ -785,7 +785,6 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err // If no position was supplied, set a default one t.Position = calculateDefaultPosition(t.Index, t.Position) - t.KanbanPosition = calculateDefaultPosition(t.Index, t.KanbanPosition) t.HexColor = utils.NormalizeHex(t.HexColor) @@ -912,7 +911,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { "bucket_id", "position", "repeat_mode", - "kanban_position", "cover_image_attachment_id", } @@ -1028,9 +1026,6 @@ 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 @@ -1059,12 +1054,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { return err } } - if ot.KanbanPosition < 0.1 { - err = recalculateTaskKanbanPositions(s, t.BucketID) - if err != nil { - return err - } - } // Get the task updated timestamp in a new struct - if we'd just try to put it into t which we already have, it // would still contain the old updated date. @@ -1075,7 +1064,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { } t.Updated = nt.Updated t.Position = nt.Position - t.KanbanPosition = nt.KanbanPosition doer, _ := user.GetFromAuth(a) err = events.Dispatch(&TaskUpdatedEvent{ @@ -1089,39 +1077,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { return updateProjectLastUpdated(s, &Project{ID: t.ProjectID}) } -func recalculateTaskKanbanPositions(s *xorm.Session, bucketID int64) (err error) { - - allTasks := []*Task{} - err = s. - Where("bucket_id = ?", bucketID). - OrderBy("kanban_position asc"). - Find(&allTasks) - if err != nil { - return - } - - maxPosition := math.Pow(2, 32) - - for i, task := range allTasks { - - currentPosition := maxPosition / float64(len(allTasks)) * (float64(i + 1)) - - // Here we use "NoAutoTime() to prevent the ORM from updating column "updated" automatically. - // Otherwise, this signals to CalDAV clients that the task has changed, which is not the case. - // Consequence: when synchronizing a list of tasks, the first one immediately changes the date of all the - // following ones from the same batch, which are then unable to be updated. - _, err = s.Cols("kanban_position"). - Where("id = ?", task.ID). - NoAutoTime(). - Update(&Task{KanbanPosition: currentPosition}) - if err != nil { - return - } - } - - return -} - func recalculateTaskPositions(s *xorm.Session, projectID int64) (err error) { allTasks := []*Task{} diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index 1849936f3..558ef8838 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -253,12 +253,11 @@ func TestTask_Update(t *testing.T) { defer s.Close() task := &Task{ - ID: 4, - Title: "test10000", - Description: "Lorem Ipsum Dolor", - KanbanPosition: 10, - ProjectID: 1, - BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3 + ID: 4, + Title: "test10000", + Description: "Lorem Ipsum Dolor", + ProjectID: 1, + BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3 } err := task.Update(s, u) require.NoError(t, err) diff --git a/pkg/models/typesense.go b/pkg/models/typesense.go index 368da9e8c..0afa9be85 100644 --- a/pkg/models/typesense.go +++ b/pkg/models/typesense.go @@ -153,10 +153,6 @@ func CreateTypesenseCollections() error { Name: "position", Type: "float", }, - { - Name: "kanban_position", - Type: "float", - }, { Name: "created_by_id", Type: "int64", @@ -417,7 +413,6 @@ type typesenseTask struct { Updated int64 `json:"updated"` BucketID int64 `json:"bucket_id"` Position float64 `json:"position"` - KanbanPosition float64 `json:"kanban_position"` CreatedByID int64 `json:"created_by_id"` Reminders interface{} `json:"reminders"` Assignees interface{} `json:"assignees"` @@ -451,7 +446,6 @@ func convertTaskToTypesenseTask(task *Task) *typesenseTask { Updated: task.Updated.UTC().Unix(), BucketID: task.BucketID, Position: task.Position, - KanbanPosition: task.KanbanPosition, CreatedByID: task.CreatedByID, Reminders: task.Reminders, Assignees: task.Assignees, diff --git a/pkg/modules/migration/trello/trello.go b/pkg/modules/migration/trello/trello.go index d19fd48b7..b96ed35a8 100644 --- a/pkg/modules/migration/trello/trello.go +++ b/pkg/modules/migration/trello/trello.go @@ -253,9 +253,8 @@ func convertTrelloDataToVikunja(trelloData []*trello.Board, token string) (fullV // The usual stuff: Title, description, position, bucket id task := &models.Task{ - Title: card.Name, - KanbanPosition: card.Pos, - BucketID: bucketID, + Title: card.Name, + BucketID: bucketID, } task.Description, err = convertMarkdownToHTML(card.Desc) diff --git a/pkg/modules/migration/trello/trello_test.go b/pkg/modules/migration/trello/trello_test.go index cc7757ca3..4099a2250 100644 --- a/pkg/modules/migration/trello/trello_test.go +++ b/pkg/modules/migration/trello/trello_test.go @@ -228,11 +228,10 @@ func TestConvertTrelloToVikunja(t *testing.T) { Tasks: []*models.TaskWithComments{ { Task: models.Task{ - Title: "Test Card 1", - Description: "

Card Description bold

\n", - BucketID: 1, - KanbanPosition: 123, - DueDate: time1, + Title: "Test Card 1", + Description: "

Card Description bold

\n", + BucketID: 1, + DueDate: time1, Labels: []*models.Label{ { Title: "Label 1", @@ -271,22 +270,19 @@ func TestConvertTrelloToVikunja(t *testing.T) {
  • Pending Task

  • Another Pending Task

`, - BucketID: 1, - KanbanPosition: 124, + BucketID: 1, }, }, { Task: models.Task{ - Title: "Test Card 3", - BucketID: 1, - KanbanPosition: 126, + Title: "Test Card 3", + BucketID: 1, }, }, { Task: models.Task{ - Title: "Test Card 4", - BucketID: 1, - KanbanPosition: 127, + Title: "Test Card 4", + BucketID: 1, Labels: []*models.Label{ { Title: "Label 2", @@ -297,9 +293,8 @@ func TestConvertTrelloToVikunja(t *testing.T) { }, { Task: models.Task{ - Title: "Test Card 5", - BucketID: 2, - KanbanPosition: 111, + Title: "Test Card 5", + BucketID: 2, Labels: []*models.Label{ { Title: "Label 3", @@ -318,24 +313,21 @@ func TestConvertTrelloToVikunja(t *testing.T) { }, { Task: models.Task{ - Title: "Test Card 6", - BucketID: 2, - KanbanPosition: 222, - DueDate: time1, + Title: "Test Card 6", + BucketID: 2, + DueDate: time1, }, }, { Task: models.Task{ - Title: "Test Card 7", - BucketID: 2, - KanbanPosition: 333, + Title: "Test Card 7", + BucketID: 2, }, }, { Task: models.Task{ - Title: "Test Card 8", - BucketID: 2, - KanbanPosition: 444, + Title: "Test Card 8", + BucketID: 2, }, }, }, @@ -355,9 +347,8 @@ func TestConvertTrelloToVikunja(t *testing.T) { Tasks: []*models.TaskWithComments{ { Task: models.Task{ - Title: "Test Card 634", - BucketID: 3, - KanbanPosition: 123, + Title: "Test Card 634", + BucketID: 3, }, }, }, @@ -378,9 +369,8 @@ func TestConvertTrelloToVikunja(t *testing.T) { Tasks: []*models.TaskWithComments{ { Task: models.Task{ - Title: "Test Card 63423", - BucketID: 4, - KanbanPosition: 123, + Title: "Test Card 63423", + BucketID: 4, }, }, }, diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 18e05ae2a..b9bf28961 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -386,6 +386,13 @@ func registerAPIRoutes(a *echo.Group) { a.DELETE("/tasks/:projecttask", taskHandler.DeleteWeb) a.POST("/tasks/:projecttask", taskHandler.UpdateWeb) + taskPositionHandler := &handler.WebHandler{ + EmptyStruct: func() handler.CObject { + return &models.TaskPosition{} + }, + } + a.POST("/tasks/:task/position", taskPositionHandler.UpdateWeb) + bulkTaskHandler := &handler.WebHandler{ EmptyStruct: func() handler.CObject { return &models.BulkTask{} -- 2.45.1 From d1d07f462c4c2d1b4ac24f6480d9ee18364d693e Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 14 Mar 2024 22:55:18 +0100 Subject: [PATCH 14/97] feat(views): sort tasks by their position relative to the view they're in --- docs/content/doc/usage/errors.md | 1 + pkg/models/error.go | 19 +++++++++++++++++++ pkg/models/kanban.go | 5 +++-- pkg/models/task_collection.go | 8 ++++++++ pkg/models/task_collection_sort.go | 10 ++++++++-- pkg/models/task_search.go | 16 ++++++++++++++-- 6 files changed, 53 insertions(+), 6 deletions(-) diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md index b2b26225c..8183f44c3 100644 --- a/docs/content/doc/usage/errors.md +++ b/docs/content/doc/usage/errors.md @@ -99,6 +99,7 @@ This document describes the different errors Vikunja can return. | 4023 | 409 | Tried to create a task relation which would create a cycle. | | 4024 | 400 | The provided filter expression is invalid. | | 4025 | 400 | The reaction kind is invalid. | +| 4026 | 400 | You must provide a project view ID when sorting by position. | ## Team diff --git a/pkg/models/error.go b/pkg/models/error.go index 1b54b955e..f2f05a2d6 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -1114,6 +1114,25 @@ func (err ErrInvalidReactionEntityKind) HTTPError() web.HTTPError { } } +// ErrMustHaveProjectViewToSortByPosition represents an error where no project view id was supplied +type ErrMustHaveProjectViewToSortByPosition struct{} + +func (err ErrMustHaveProjectViewToSortByPosition) Error() string { + return "You must provide a project view ID when sorting by position" +} + +// ErrCodeMustHaveProjectViewToSortByPosition holds the unique world-error code of this error +const ErrCodeMustHaveProjectViewToSortByPosition = 4026 + +// HTTPError holds the http error description +func (err ErrMustHaveProjectViewToSortByPosition) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeMustHaveProjectViewToSortByPosition, + Message: "You must provide a project view ID when sorting by position", + } +} + // ============ // Team errors // ============ diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index 97e734a15..671d23fbc 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -200,8 +200,9 @@ func GetTasksInBucketsForView(s *xorm.Session, view *ProjectView, opts *taskSear opts.sortby = []*sortParam{ { - orderBy: orderAscending, - sortBy: taskPropertyPosition, + projectViewID: view.ProjectID, + orderBy: orderAscending, + sortBy: taskPropertyPosition, }, } diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 9b3a2d73d..4ef81e976 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -193,6 +193,14 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa opts.page = page opts.perPage = perPage + if view != nil { + opts.sortby = append(opts.sortby, &sortParam{ + projectViewID: view.ID, + sortBy: taskPropertyPosition, + orderBy: orderAscending, + }) + } + shareAuth, is := a.(*LinkSharing) if is { project, err := GetProjectSimpleByID(s, shareAuth.ProjectID) diff --git a/pkg/models/task_collection_sort.go b/pkg/models/task_collection_sort.go index 4777b3bfe..fb8b797f2 100644 --- a/pkg/models/task_collection_sort.go +++ b/pkg/models/task_collection_sort.go @@ -18,8 +18,9 @@ package models type ( sortParam struct { - sortBy string - orderBy sortOrder // asc or desc + sortBy string + orderBy sortOrder // asc or desc + projectViewID int64 } sortOrder string @@ -72,5 +73,10 @@ func (sp *sortParam) validate() error { if sp.orderBy != orderDescending && sp.orderBy != orderAscending { return ErrInvalidSortOrder{OrderBy: sp.orderBy} } + + if sp.sortBy == taskPropertyPosition && sp.projectViewID == 0 { + return ErrMustHaveProjectViewToSortByPosition{} + } + return validateTaskField(sp.sortBy) } diff --git a/pkg/models/task_search.go b/pkg/models/task_search.go index 66949dbfb..6b0b4ad2f 100644 --- a/pkg/models/task_search.go +++ b/pkg/models/task_search.go @@ -53,14 +53,19 @@ func getOrderByDBStatement(opts *taskSearchOptions) (orderby string, err error) return "", err } + var prefix string + if param.sortBy == taskPropertyPosition { + prefix = "task_positions." + } + // Mysql sorts columns with null values before ones without null value. // Because it does not have support for NULLS FIRST or NULLS LAST we work around this by // first sorting for null (or not null) values and then the order we actually want to. if db.Type() == schemas.MYSQL { - orderby += "`" + param.sortBy + "` IS NULL, " + orderby += prefix + "`" + param.sortBy + "` IS NULL, " } - orderby += "`" + param.sortBy + "` " + param.orderBy.String() + orderby += prefix + "`" + param.sortBy + "` " + param.orderBy.String() // Postgres and sqlite allow us to control how columns with null values are sorted. // To make that consistent with the sort order we have and other dbms, we're adding a separate clause here. @@ -253,6 +258,13 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo query = query.Limit(limit, start) } + for _, param := range opts.sortby { + if param.sortBy == taskPropertyPosition { + query = query.Join("LEFT", "task_positions", "task_positions.task_id = tasks.id AND task_positions.project_view_id = ?", param.projectViewID) + break + } + } + tasks = []*Task{} err = query.OrderBy(orderby).Find(&tasks) if err != nil { -- 2.45.1 From 0a3f45ab11460da6c867aae1a92be67242a44950 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 10:32:38 +0100 Subject: [PATCH 15/97] feat(views): decouple buckets from projects --- pkg/migration/20240315093418.go | 119 ++++++++++++++++++++++++++++++++ pkg/models/kanban.go | 16 +++-- pkg/models/kanban_rights.go | 8 +-- pkg/models/project.go | 14 +--- pkg/models/project_view.go | 15 +++- pkg/models/saved_filters.go | 2 +- 6 files changed, 148 insertions(+), 26 deletions(-) create mode 100644 pkg/migration/20240315093418.go diff --git a/pkg/migration/20240315093418.go b/pkg/migration/20240315093418.go new file mode 100644 index 000000000..0fa146075 --- /dev/null +++ b/pkg/migration/20240315093418.go @@ -0,0 +1,119 @@ +// 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 Licensee 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 Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package migration + +import ( + "code.vikunja.io/api/pkg/config" + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type buckets20240315093418 struct { + ID int64 `xorm:"bigint autoincr not null unique pk"` + ProjectID int64 `xorm:"bigint not null"` + ProjectViewID int64 `xorm:"bigint not null default 0"` +} + +func (buckets20240315093418) TableName() string { + return "buckets" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20240315093418", + Description: "Relate buckets to views instead of projects", + Migrate: func(tx *xorm.Engine) (err error) { + err = tx.Sync2(buckets20240315093418{}) + if err != nil { + return + } + + buckets := []*buckets20240315093418{} + err = tx.Find(&buckets) + if err != nil { + return err + } + + views := []*projectView20240313230538{} + err = tx.Find(&views) + if err != nil { + return err + } + + viewMap := make(map[int64][]*projectView20240313230538) + for _, view := range views { + if _, has := viewMap[view.ProjectID]; !has { + viewMap[view.ProjectID] = []*projectView20240313230538{} + } + + viewMap[view.ProjectID] = append(viewMap[view.ProjectID], view) + } + + for _, bucket := range buckets { + for _, view := range viewMap[bucket.ProjectID] { + if view.ViewKind == 3 { // Kanban view + + bucket.ProjectViewID = view.ID + + _, err = tx. + Where("id = ?", bucket.ID). + Cols("project_view_id"). + Update(bucket) + if err != nil { + return err + } + } + } + } + + if config.DatabaseType.GetString() == "sqlite" { + _, err = tx.Exec(` +create table buckets_dg_tmp +( + id INTEGER not null + primary key autoincrement, + title TEXT not null, + "limit" INTEGER default 0, + position REAL, + created DATETIME not null, + updated DATETIME not null, + created_by_id INTEGER not null, + project_view_id INTEGER not null default 0 +); + +insert into buckets_dg_tmp(id, title, "limit", position, created, updated, created_by_id, project_view_id) +select id, title, "limit", position, created, updated, created_by_id, project_view_id +from buckets; + +drop table buckets; + +alter table buckets_dg_tmp + rename to buckets; + +create unique index UQE_buckets_id + on buckets (id); +`) + return err + } + + return dropTableColum(tx, "buckets", "project_id") + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index 671d23fbc..35fe1b087 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -35,6 +35,8 @@ type Bucket struct { Title string `xorm:"text not null" valid:"required" minLength:"1" json:"title"` // The project this bucket belongs to. ProjectID int64 `xorm:"bigint not null" json:"project_id" param:"project"` + // The project view this bucket belongs to. + ProjectViewID int64 `xorm:"bigint not null" json:"project_view_id" param:"view"` // All tasks which belong to this bucket. Tasks []*Task `xorm:"-" json:"tasks"` @@ -167,13 +169,13 @@ func GetTasksInBucketsForView(s *xorm.Session, view *ProjectView, opts *taskSear if view.BucketConfigurationMode == BucketConfigurationModeFilter { for id, bc := range view.BucketConfiguration { buckets = append(buckets, &Bucket{ - ID: int64(id), - Title: bc.Title, - ProjectID: view.ProjectID, - Position: float64(id), - CreatedByID: auth.GetID(), - Created: time.Now(), - Updated: time.Now(), + ID: int64(id), + Title: bc.Title, + ProjectViewID: view.ID, + Position: float64(id), + CreatedByID: auth.GetID(), + Created: time.Now(), + Updated: time.Now(), }) } } diff --git a/pkg/models/kanban_rights.go b/pkg/models/kanban_rights.go index 53d18d4e4..07b096acc 100644 --- a/pkg/models/kanban_rights.go +++ b/pkg/models/kanban_rights.go @@ -23,8 +23,8 @@ import ( // CanCreate checks if a user can create a new bucket func (b *Bucket) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { - l := &Project{ID: b.ProjectID} - return l.CanWrite(s, a) + pv := &ProjectView{ID: b.ProjectViewID} + return pv.CanUpdate(s, a) } // CanUpdate checks if a user can update an existing bucket @@ -43,6 +43,6 @@ func (b *Bucket) canDoBucket(s *xorm.Session, a web.Auth) (bool, error) { if err != nil { return false, err } - l := &Project{ID: bb.ProjectID} - return l.CanWrite(s, a) + pv := &ProjectView{ID: bb.ProjectViewID} + return pv.CanUpdate(s, a) } diff --git a/pkg/models/project.go b/pkg/models/project.go index 5e36e76bb..3cf13d600 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -777,19 +777,7 @@ func CreateProject(s *xorm.Session, project *Project, auth web.Auth, createBackl } } - if createBacklogBucket { - // Create a new first bucket for this project - b := &Bucket{ - ProjectID: project.ID, - Title: "Backlog", - } - err = b.Create(s, auth) - if err != nil { - return - } - } - - err = CreateDefaultViewsForProject(s, project, auth) + err = CreateDefaultViewsForProject(s, project, auth, createBacklogBucket) if err != nil { return } diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index ab926c42a..f26a8df76 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -295,7 +295,7 @@ func GetProjectViewByID(s *xorm.Session, id, projectID int64) (view *ProjectView return } -func CreateDefaultViewsForProject(s *xorm.Session, project *Project, a web.Auth) (err error) { +func CreateDefaultViewsForProject(s *xorm.Session, project *Project, a web.Auth, createBacklogBucket bool) (err error) { list := &ProjectView{ ProjectID: project.ID, Title: "List", @@ -336,5 +336,18 @@ func CreateDefaultViewsForProject(s *xorm.Session, project *Project, a web.Auth) Position: 400, } err = kanban.Create(s, a) + if err != nil { + return + } + + if createBacklogBucket { + // Create a new first bucket for this project + b := &Bucket{ + ProjectViewID: kanban.ID, + Title: "Backlog", + } + err = b.Create(s, a) + } + return } diff --git a/pkg/models/saved_filters.go b/pkg/models/saved_filters.go index 49fd613a5..7cc8c76af 100644 --- a/pkg/models/saved_filters.go +++ b/pkg/models/saved_filters.go @@ -123,7 +123,7 @@ func (sf *SavedFilter) Create(s *xorm.Session, auth web.Auth) (err error) { return } - err = CreateDefaultViewsForProject(s, &Project{ID: getProjectIDFromSavedFilterID(sf.ID)}, auth) + err = CreateDefaultViewsForProject(s, &Project{ID: getProjectIDFromSavedFilterID(sf.ID)}, auth, false) return err } -- 2.45.1 From 006f932dc4da3a9c83d55c2907927f72b1acf3e0 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 10:39:53 +0100 Subject: [PATCH 16/97] feat(views)!: decouple bucket CRUD from projects --- pkg/models/error.go | 6 +++--- pkg/models/kanban.go | 28 ++++++++++++++++------------ pkg/routes/routes.go | 8 ++++---- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/pkg/models/error.go b/pkg/models/error.go index f2f05a2d6..e63f6fc00 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -1557,8 +1557,8 @@ func (err ErrBucketDoesNotBelongToProject) HTTPError() web.HTTPError { // ErrCannotRemoveLastBucket represents an error where a kanban bucket is the last on a project and thus cannot be removed. type ErrCannotRemoveLastBucket struct { - BucketID int64 - ProjectID int64 + BucketID int64 + ProjectViewID int64 } // IsErrCannotRemoveLastBucket checks if an error is ErrCannotRemoveLastBucket. @@ -1568,7 +1568,7 @@ func IsErrCannotRemoveLastBucket(err error) bool { } func (err ErrCannotRemoveLastBucket) Error() string { - return fmt.Sprintf("Cannot remove last bucket of project [BucketID: %d, ProjectID: %d]", err.BucketID, err.ProjectID) + return fmt.Sprintf("Cannot remove last bucket of project [BucketID: %d, ProjectID: %d]", err.BucketID, err.ProjectViewID) } // ErrCodeCannotRemoveLastBucket holds the unique world-error code of this error diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index 35fe1b087..9769d4904 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -34,7 +34,7 @@ type Bucket struct { // The title of this bucket. Title string `xorm:"text not null" valid:"required" minLength:"1" json:"title"` // The project this bucket belongs to. - ProjectID int64 `xorm:"bigint not null" json:"project_id" param:"project"` + ProjectID int64 `xorm:"-" json:"-" param:"project"` // The project view this bucket belongs to. ProjectViewID int64 `xorm:"bigint not null" json:"project_view_id" param:"view"` // All tasks which belong to this bucket. @@ -107,17 +107,18 @@ func getDefaultBucketID(s *xorm.Session, project *Project) (bucketID int64, err // @Produce json // @Security JWTKeyAuth // @Param id path int true "Project ID" +// @Param view path int true "Project view ID" // @Success 200 {array} models.Bucket "The buckets" // @Failure 500 {object} models.Message "Internal server error" -// @Router /projects/{id}/buckets [get] +// @Router /projects/{id}/views/{view}/buckets [get] func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) { - project, err := GetProjectSimpleByID(s, b.ProjectID) + view, err := GetProjectViewByID(s, b.ProjectViewID, b.ProjectID) if err != nil { return nil, 0, 0, err } - can, _, err := project.CanRead(s, auth) + can, _, err := view.CanRead(s, auth) if err != nil { return nil, 0, 0, err } @@ -127,7 +128,7 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int buckets := []*Bucket{} err = s. - Where("project_id = ?", b.ProjectID). + Where("project_view_id = ?", b.ProjectViewID). OrderBy("position"). Find(&buckets) if err != nil { @@ -244,7 +245,7 @@ func GetTasksInBucketsForView(s *xorm.Session, view *ProjectView, opts *taskSear } } - ts, _, total, err := getRawTasksForProjects(s, []*Project{{ID: bucket.ProjectID}}, auth, opts) + ts, _, total, err := getRawTasksForProjects(s, []*Project{{ID: view.ProjectID}}, auth, opts) if err != nil { return nil, err } @@ -287,12 +288,13 @@ func GetTasksInBucketsForView(s *xorm.Session, view *ProjectView, opts *taskSear // @Produce json // @Security JWTKeyAuth // @Param id path int true "Project Id" +// @Param view path int true "Project view ID" // @Param bucket body models.Bucket true "The bucket object" // @Success 200 {object} models.Bucket "The created bucket object." // @Failure 400 {object} web.HTTPError "Invalid bucket object provided." // @Failure 404 {object} web.HTTPError "The project does not exist." // @Failure 500 {object} models.Message "Internal error" -// @Router /projects/{id}/buckets [put] +// @Router /projects/{id}/views/{view}/buckets [put] func (b *Bucket) Create(s *xorm.Session, a web.Auth) (err error) { b.CreatedBy, err = GetUserOrLinkShareUser(s, a) if err != nil { @@ -319,12 +321,13 @@ func (b *Bucket) Create(s *xorm.Session, a web.Auth) (err error) { // @Security JWTKeyAuth // @Param projectID path int true "Project Id" // @Param bucketID path int true "Bucket Id" +// @Param view path int true "Project view ID" // @Param bucket body models.Bucket true "The bucket object" // @Success 200 {object} models.Bucket "The created bucket object." // @Failure 400 {object} web.HTTPError "Invalid bucket object provided." // @Failure 404 {object} web.HTTPError "The bucket does not exist." // @Failure 500 {object} models.Message "Internal error" -// @Router /projects/{projectID}/buckets/{bucketID} [post] +// @Router /projects/{projectID}/views/{view}/buckets/{bucketID} [post] func (b *Bucket) Update(s *xorm.Session, _ web.Auth) (err error) { _, err = s. Where("id = ?", b.ID). @@ -346,21 +349,22 @@ func (b *Bucket) Update(s *xorm.Session, _ web.Auth) (err error) { // @Security JWTKeyAuth // @Param projectID path int true "Project Id" // @Param bucketID path int true "Bucket Id" +// @Param view path int true "Project view ID" // @Success 200 {object} models.Message "Successfully deleted." // @Failure 404 {object} web.HTTPError "The bucket does not exist." // @Failure 500 {object} models.Message "Internal error" -// @Router /projects/{projectID}/buckets/{bucketID} [delete] +// @Router /projects/{projectID}/views/{view}/buckets/{bucketID} [delete] func (b *Bucket) Delete(s *xorm.Session, a web.Auth) (err error) { // Prevent removing the last bucket - total, err := s.Where("project_id = ?", b.ProjectID).Count(&Bucket{}) + total, err := s.Where("project_view_id = ?", b.ProjectViewID).Count(&Bucket{}) if err != nil { return } if total <= 1 { return ErrCannotRemoveLastBucket{ - BucketID: b.ID, - ProjectID: b.ProjectID, + BucketID: b.ID, + ProjectViewID: b.ProjectViewID, } } diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index b9bf28961..e6f4e89fc 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -363,10 +363,10 @@ func registerAPIRoutes(a *echo.Group) { return &models.Bucket{} }, } - a.GET("/projects/:project/buckets", kanbanBucketHandler.ReadAllWeb) - a.PUT("/projects/:project/buckets", kanbanBucketHandler.CreateWeb) - a.POST("/projects/:project/buckets/:bucket", kanbanBucketHandler.UpdateWeb) - a.DELETE("/projects/:project/buckets/:bucket", kanbanBucketHandler.DeleteWeb) + a.GET("/projects/:project/views/:view/buckets", kanbanBucketHandler.ReadAllWeb) + a.PUT("/projects/:project/views/:view/buckets", kanbanBucketHandler.CreateWeb) + a.POST("/projects/:project/views/:view/buckets/:bucket", kanbanBucketHandler.UpdateWeb) + a.DELETE("/projects/:project/views/:view/buckets/:bucket", kanbanBucketHandler.DeleteWeb) projectDuplicateHandler := &handler.WebHandler{ EmptyStruct: func() handler.CObject { -- 2.45.1 From 9cf84646a1ac5662e45a1fac1355901dc13fcc50 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 11:27:31 +0100 Subject: [PATCH 17/97] feat(views)!: move done and default bucket setting to view --- pkg/migration/20240315104205.go | 158 ++++++++++++++++++++++++++++++++ pkg/models/kanban.go | 10 +- pkg/models/models.go | 2 + pkg/models/project.go | 4 +- pkg/models/project_view.go | 4 + pkg/models/tasks.go | 14 +-- 6 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 pkg/migration/20240315104205.go diff --git a/pkg/migration/20240315104205.go b/pkg/migration/20240315104205.go new file mode 100644 index 000000000..6100af526 --- /dev/null +++ b/pkg/migration/20240315104205.go @@ -0,0 +1,158 @@ +// 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 Licensee 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 Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package migration + +import ( + "code.vikunja.io/api/pkg/config" + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type projects20240315104205 struct { + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"project"` + DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id"` + DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id"` +} + +func (projects20240315104205) TableName() string { + return "projects" +} + +type projectView20240315104205 struct { + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"project"` + ViewKind int `xorm:"not null" json:"view_kind"` + DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id"` + DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id"` + ProjectID int64 `xorm:"not null index" json:"project_id" param:"project"` +} + +func (projectView20240315104205) TableName() string { + return "project_views" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20240315104205", + Description: "Move done and default bucket id to views", + Migrate: func(tx *xorm.Engine) (err error) { + err = tx.Sync(projectView20240315104205{}) + if err != nil { + return + } + + projects := []*projects20240315104205{} + err = tx.Find(&projects) + if err != nil { + return + } + + views := []*projectView20240315104205{} + err = tx.Find(&views) + if err != nil { + return err + } + + viewMap := make(map[int64][]*projectView20240315104205) + for _, view := range views { + if _, has := viewMap[view.ProjectID]; !has { + viewMap[view.ProjectID] = []*projectView20240315104205{} + } + + viewMap[view.ProjectID] = append(viewMap[view.ProjectID], view) + } + + for _, project := range projects { + for _, view := range viewMap[project.ID] { + if view.ViewKind == 3 { // Kanban view + view.DefaultBucketID = project.DefaultBucketID + view.DoneBucketID = project.DoneBucketID + _, err = tx. + Where("id = ?", view.ID). + Cols("default_bucket_id", "done_bucket_id"). + Update(view) + if err != nil { + return + } + } + } + } + + if config.DatabaseType.GetString() == "sqlite" { + _, err = tx.Exec(` +create table projects_dg_tmp +( + id INTEGER not null + primary key autoincrement, + title TEXT not null, + description TEXT, + identifier TEXT, + hex_color TEXT, + owner_id INTEGER not null, + parent_project_id INTEGER, + is_archived INTEGER default 0 not null, + background_file_id INTEGER, + background_blur_hash TEXT, + position REAL, + created DATETIME not null, + updated DATETIME not null +); + +insert into projects_dg_tmp(id, title, description, identifier, hex_color, owner_id, parent_project_id, is_archived, + background_file_id, background_blur_hash, position, created, updated) +select id, + title, + description, + identifier, + hex_color, + owner_id, + parent_project_id, + is_archived, + background_file_id, + background_blur_hash, + position, + created, + updated +from projects; + +drop table projects; + +alter table projects_dg_tmp + rename to projects; + +create index IDX_projects_owner_id + on projects (owner_id); + +create index IDX_projects_parent_project_id + on projects (parent_project_id); + +create unique index UQE_projects_id + on projects (id); +`) + return err + } + + err = dropTableColum(tx, "projects", "done_bucket_id") + if err != nil { + return + } + return dropTableColum(tx, "projects", "default_bucket_id") + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index 9769d4904..aa3bd2b0f 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -82,14 +82,14 @@ func getBucketByID(s *xorm.Session, id int64) (b *Bucket, err error) { return } -func getDefaultBucketID(s *xorm.Session, project *Project) (bucketID int64, err error) { - if project.DefaultBucketID != 0 { - return project.DefaultBucketID, nil +func getDefaultBucketID(s *xorm.Session, view *ProjectView) (bucketID int64, err error) { + if view.DefaultBucketID != 0 { + return view.DefaultBucketID, nil } bucket := &Bucket{} _, err = s. - Where("project_id = ?", project.ID). + Where("project_view_id = ?", view.ID). OrderBy("position asc"). Get(bucket) if err != nil { @@ -369,7 +369,7 @@ func (b *Bucket) Delete(s *xorm.Session, a web.Auth) (err error) { } // Get the default bucket - p, err := GetProjectSimpleByID(s, b.ProjectID) + p, err := GetProjectViewByID(s, b.ProjectViewID, b.ProjectID) if err != nil { return } diff --git a/pkg/models/models.go b/pkg/models/models.go index 96d8b3b0f..a9d5d388d 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -63,6 +63,8 @@ func GetTables() []interface{} { &Webhook{}, &Reaction{}, &ProjectView{}, + &TaskPosition{}, + &TaskBucket{}, } } diff --git a/pkg/models/project.go b/pkg/models/project.go index 3cf13d600..1311b6eaa 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -52,9 +52,7 @@ type Project struct { ParentProjectID int64 `xorm:"bigint INDEX null" json:"parent_project_id"` ParentProject *Project `xorm:"-" json:"-"` - // The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a project. - DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id"` - // If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket. + // Deprecated: If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket. DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id"` // The user who created this project. diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index f26a8df76..de9c90e37 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -133,6 +133,10 @@ type ProjectView struct { BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode"` // When the bucket configuration mode is not `manual`, this field holds the options of that configuration. BucketConfiguration []*ProjectViewBucketConfiguration `xorm:"json" json:"bucket_configuration"` + // The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a view. + DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id"` + // If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket. + DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id"` // A timestamp when this view was updated. You cannot change this value. Updated time.Time `xorm:"updated not null" json:"updated"` diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 48b5e1faf..04d22ccc5 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -648,10 +648,10 @@ func checkBucketLimit(s *xorm.Session, t *Task, bucket *Bucket) (err error) { } // Contains all the task logic to figure out what bucket to use for this task. -func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucketLimit bool, project *Project) (targetBucket *Bucket, err error) { +func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucketLimit bool, view *ProjectView) (targetBucket *Bucket, err error) { - if project == nil { - project, err = GetProjectSimpleByID(s, task.ProjectID) + if view == nil { + view, err = GetProjectViewByID(s, view.ID, task.ProjectID) if err != nil { return nil, err } @@ -660,7 +660,7 @@ func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucke var bucket *Bucket if task.Done && originalTask != nil && (!originalTask.Done || task.ProjectID != originalTask.ProjectID) { - task.BucketID = project.DoneBucketID + task.BucketID = view.DoneBucketID } if task.BucketID == 0 && originalTask != nil && originalTask.BucketID != 0 { @@ -672,7 +672,7 @@ func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucke // because then we have it already updated to the done bucket. if task.BucketID == 0 || (originalTask != nil && task.ProjectID != 0 && originalTask.ProjectID != task.ProjectID && !task.Done) { - task.BucketID, err = getDefaultBucketID(s, project) + task.BucketID, err = getDefaultBucketID(s, view) if err != nil { return } @@ -699,7 +699,7 @@ func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucke } } - if bucket.ID == project.DoneBucketID && originalTask != nil && !originalTask.Done { + if bucket.ID == view.DoneBucketID && originalTask != nil && !originalTask.Done { task.Done = true } @@ -869,7 +869,7 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { return err } - targetBucket, err := setTaskBucket(s, t, &ot, t.BucketID != 0 && t.BucketID != ot.BucketID, project) + targetBucket, err := setTaskBucket(s, t, &ot, t.BucketID != 0 && t.BucketID != ot.BucketID, nil) if err != nil { return err } -- 2.45.1 From a13276e28e295d7e8bfbc9ae4b6de89f8cad1f48 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 11:28:07 +0100 Subject: [PATCH 18/97] feat(views)!: decouple bucket <-> task relationship --- pkg/migration/20240315110428.go | 183 ++++++++++++++++++++++++++++++++ pkg/models/kanban.go | 5 + pkg/models/tasks.go | 2 +- 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 pkg/migration/20240315110428.go diff --git a/pkg/migration/20240315110428.go b/pkg/migration/20240315110428.go new file mode 100644 index 000000000..332a8f93e --- /dev/null +++ b/pkg/migration/20240315110428.go @@ -0,0 +1,183 @@ +// 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 Licensee 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 Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package migration + +import ( + "code.vikunja.io/api/pkg/config" + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type task20240315110428 struct { + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"projecttask"` + BucketID int64 `xorm:"bigint not null"` + ProjectID int64 `xorm:"bigint INDEX not null" json:"project_id" param:"project"` +} + +func (task20240315110428) TableName() string { + return "tasks" +} + +type taskBuckets20240315110428 struct { + BucketID int64 `xorm:"bigint not null index"` + TaskID int64 `xorm:"bigint not null index"` +} + +func (taskBuckets20240315110428) TableName() string { + return "task_buckets" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20240315110428", + Description: "", + Migrate: func(tx *xorm.Engine) (err error) { + err = tx.Sync2(taskBuckets20240315110428{}) + if err != nil { + return + } + + tasks := []*task20240315110428{} + err = tx.Find(&tasks) + if err != nil { + return err + } + + views := []*projectView20240313230538{} + err = tx.Find(&views) + if err != nil { + return err + } + + viewMap := make(map[int64][]*projectView20240313230538) + for _, view := range views { + if _, has := viewMap[view.ProjectID]; !has { + viewMap[view.ProjectID] = []*projectView20240313230538{} + } + + viewMap[view.ProjectID] = append(viewMap[view.ProjectID], view) + } + + for _, task := range tasks { + for _, view := range viewMap[task.ProjectID] { + if view.ViewKind == 3 { // Kanban view + + pos := taskBuckets20240315110428{ + TaskID: task.ID, + BucketID: task.BucketID, + } + + _, err = tx.Insert(pos) + if err != nil { + return err + } + } + } + } + + if config.DatabaseType.GetString() == "sqlite" { + _, err = tx.Exec(` +create table tasks_dg_tmp +( + id INTEGER not null + primary key autoincrement, + title TEXT not null, + description TEXT, + done INTEGER, + done_at DATETIME, + due_date DATETIME, + project_id INTEGER not null, + repeat_after INTEGER, + repeat_mode INTEGER default 0 not null, + priority INTEGER, + start_date DATETIME, + end_date DATETIME, + hex_color TEXT, + percent_done REAL, + "index" INTEGER default 0 not null, + uid TEXT, + cover_image_attachment_id INTEGER default 0, + created DATETIME not null, + updated DATETIME not null, + created_by_id INTEGER not null +); + +insert into tasks_dg_tmp(id, title, description, done, done_at, due_date, project_id, repeat_after, repeat_mode, + priority, start_date, end_date, hex_color, percent_done, "index", uid, + cover_image_attachment_id, created, updated, created_by_id) +select id, + title, + description, + done, + done_at, + due_date, + project_id, + repeat_after, + repeat_mode, + priority, + start_date, + end_date, + hex_color, + percent_done, + "index", + uid, + cover_image_attachment_id, + created, + updated, + created_by_id +from tasks; + +drop table tasks; + +alter table tasks_dg_tmp + rename to tasks; + +create index IDX_tasks_done + on tasks (done); + +create index IDX_tasks_done_at + on tasks (done_at); + +create index IDX_tasks_due_date + on tasks (due_date); + +create index IDX_tasks_end_date + on tasks (end_date); + +create index IDX_tasks_project_id + on tasks (project_id); + +create index IDX_tasks_repeat_after + on tasks (repeat_after); + +create index IDX_tasks_start_date + on tasks (start_date); + +create unique index UQE_tasks_id + on tasks (id); + +`) + return err + } + + return dropTableColum(tx, "tasks", "bucket_id") + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index aa3bd2b0f..1b4a1128d 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -70,6 +70,11 @@ func (b *Bucket) TableName() string { return "buckets" } +type TaskBucket struct { + BucketID int64 `xorm:"bigint not null index"` + TaskID int64 `xorm:"bigint not null index"` +} + func getBucketByID(s *xorm.Session, id int64) (b *Bucket, err error) { b = &Bucket{} exists, err := s.Where("id = ?", id).Get(b) diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 04d22ccc5..3710fdc1f 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -112,7 +112,7 @@ type Task struct { // A timestamp when this task was last updated. You cannot change this value. Updated time.Time `xorm:"updated not null" json:"updated"` - // BucketID is the ID of the kanban bucket this task belongs to. + // Deprecated: use the id via the separate entity. BucketID int64 `xorm:"bigint null" json:"bucket_id"` // The position of the task - any task project can be sorted as usual by this parameter. -- 2.45.1 From f2a0d69670c12f32f6b873a1c2c2b9731c9f4f63 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 13:12:56 +0100 Subject: [PATCH 19/97] feat(views)!: make updating a bucket work again --- pkg/migration/20240315110428.go | 5 +- pkg/models/kanban.go | 9 +- pkg/models/project_view.go | 15 ++-- pkg/models/tasks.go | 140 ++++++++++++++++++++++---------- 4 files changed, 118 insertions(+), 51 deletions(-) diff --git a/pkg/migration/20240315110428.go b/pkg/migration/20240315110428.go index 332a8f93e..b2ae07ba8 100644 --- a/pkg/migration/20240315110428.go +++ b/pkg/migration/20240315110428.go @@ -33,8 +33,9 @@ func (task20240315110428) TableName() string { } type taskBuckets20240315110428 struct { - BucketID int64 `xorm:"bigint not null index"` - TaskID int64 `xorm:"bigint not null index"` + BucketID int64 `xorm:"bigint not null index"` + TaskID int64 `xorm:"bigint not null index"` + ProjectViewID int64 `xorm:"bigint not null index"` } func (taskBuckets20240315110428) TableName() string { diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index 1b4a1128d..df885d17f 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -71,8 +71,13 @@ func (b *Bucket) TableName() string { } type TaskBucket struct { - BucketID int64 `xorm:"bigint not null index"` - TaskID int64 `xorm:"bigint not null index"` + BucketID int64 `xorm:"bigint not null index"` + TaskID int64 `xorm:"bigint not null index"` + ProjectViewID int64 `xorm:"bigint not null index"` +} + +func (b *TaskBucket) TableName() string { + return "task_buckets" } func getBucketByID(s *xorm.Session, id int64) (b *Bucket, err error) { diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index de9c90e37..58c51328e 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -151,6 +151,14 @@ func (p *ProjectView) TableName() string { return "project_views" } +func getViewsForProject(s *xorm.Session, projectID int64) (views []*ProjectView, err error) { + views = []*ProjectView{} + err = s. + Where("project_id = ?", projectID). + Find(&views) + return +} + // ReadAll gets all project views // @Summary Get all project views for a project // @Description Returns all project views for a sepcific project @@ -173,12 +181,9 @@ func (p *ProjectView) ReadAll(s *xorm.Session, a web.Auth, _ string, _ int, _ in return nil, 0, 0, ErrGenericForbidden{} } - projectViews := []*ProjectView{} - err = s. - Where("project_id = ?", p.ProjectID). - Find(&projectViews) + projectViews, err := getViewsForProject(s, p.ProjectID) if err != nil { - return + return nil, 0, 0, err } totalCount, err := s. diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 3710fdc1f..d4ad6ec05 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -648,41 +648,48 @@ func checkBucketLimit(s *xorm.Session, t *Task, bucket *Bucket) (err error) { } // Contains all the task logic to figure out what bucket to use for this task. -func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucketLimit bool, view *ProjectView) (targetBucket *Bucket, err error) { +func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, view *ProjectView, targetBucket *TaskBucket) (err error) { - if view == nil { - view, err = GetProjectViewByID(s, view.ID, task.ProjectID) - if err != nil { - return nil, err + var shouldChangeBucket = true + if targetBucket == nil { + targetBucket = &TaskBucket{ + BucketID: 0, + TaskID: task.ID, + ProjectViewID: view.ID, } } - var bucket *Bucket - if task.Done && originalTask != nil && - (!originalTask.Done || task.ProjectID != originalTask.ProjectID) { - task.BucketID = view.DoneBucketID + oldTaskBucket := &TaskBucket{} + _, err = s. + Where("task_id = ? AND project_view_id = ?", task.ID, view.ID). + Get(oldTaskBucket) + if err != nil { + return } - if task.BucketID == 0 && originalTask != nil && originalTask.BucketID != 0 { - task.BucketID = originalTask.BucketID + if task.Done && originalTask != nil && + (!originalTask.Done || task.ProjectID != originalTask.ProjectID) { + targetBucket.BucketID = view.DoneBucketID + } + + if targetBucket.BucketID == 0 && oldTaskBucket.BucketID != 0 { + shouldChangeBucket = false } // Either no bucket was provided or the task was moved between projects // But if the task was moved between projects, don't update the done bucket // because then we have it already updated to the done bucket. - if task.BucketID == 0 || + if targetBucket.BucketID == 0 || (originalTask != nil && task.ProjectID != 0 && originalTask.ProjectID != task.ProjectID && !task.Done) { - task.BucketID, err = getDefaultBucketID(s, view) + targetBucket.BucketID, err = getDefaultBucketID(s, view) if err != nil { return } } - if bucket == nil { - bucket, err = getBucketByID(s, task.BucketID) - if err != nil { - return - } + bucket, err := getBucketByID(s, targetBucket.BucketID) + if err != nil { + return err } // If there is a bucket set, make sure they belong to the same project as the task @@ -693,9 +700,10 @@ func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucke // Check the bucket limit // Only check the bucket limit if the task is being moved between buckets, allow reordering the task within a bucket - if doCheckBucketLimit { - if err := checkBucketLimit(s, task, bucket); err != nil { - return nil, err + if targetBucket.BucketID != 0 && targetBucket.BucketID != oldTaskBucket.BucketID { + err = checkBucketLimit(s, task, bucket) + if err != nil { + return err } } @@ -703,7 +711,26 @@ func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucke task.Done = true } - return bucket, nil + // If the task was moved into the done bucket and the task has a repeating cycle we should not update + // the bucket. + if bucket.ID == view.DoneBucketID && task.RepeatAfter > 0 { + task.Done = true // This will trigger the correct re-scheduling of the task (happening in updateDone later) + shouldChangeBucket = false + } + + if shouldChangeBucket { + _, err = s. + Where("task_id = ? AND project_view_id = ?", task.ID, view.ID). + Delete(&TaskBucket{}) + if err != nil { + return + } + + targetBucket.BucketID = bucket.ID + _, err = s.Insert(targetBucket) + } + + return } func calculateDefaultPosition(entityID int64, position float64) float64 { @@ -714,6 +741,18 @@ func calculateDefaultPosition(entityID int64, position float64) float64 { return position } +func (t *Task) setTaskPosition(s *xorm.Session, position float64, view *ProjectView) (err error) { + + pos := &TaskPosition{ + TaskID: t.ID, + ProjectViewID: view.ID, + Position: calculateDefaultPosition(t.Index, position), + } + + _, err = s.Insert(pos) + return +} + func getNextTaskIndex(s *xorm.Session, projectID int64) (nextIndex int64, err error) { latestTask := &Task{} _, err = s. @@ -771,21 +810,12 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err t.UID = uuid.NewString() } - // Get the default bucket and move the task there - _, err = setTaskBucket(s, t, nil, true, nil) - if err != nil { - return - } - // Get the index for this task t.Index, err = getNextTaskIndex(s, t.ProjectID) if err != nil { return err } - // If no position was supplied, set a default one - t.Position = calculateDefaultPosition(t.Index, t.Position) - t.HexColor = utils.NormalizeHex(t.HexColor) _, err = s.Insert(t) @@ -793,6 +823,25 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err return err } + views, err := getViewsForProject(s, t.ProjectID) + if err != nil { + return err + } + + for _, view := range views { + + // Get the default bucket and move the task there + err = setTaskBucket(s, t, nil, view, &TaskBucket{}) + if err != nil { + return + } + + err = t.setTaskPosition(s, t.Position, view) + if err != nil { + return + } + } + t.CreatedBy = createdBy // Update the assignees @@ -864,21 +913,29 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { // Old task has the stored reminders ot.Reminders = reminders - project, err := GetProjectSimpleByID(s, t.ProjectID) + views, err := getViewsForProject(s, t.ProjectID) if err != nil { return err } - targetBucket, err := setTaskBucket(s, t, &ot, t.BucketID != 0 && t.BucketID != ot.BucketID, nil) - if err != nil { - return err - } + for _, view := range views { + // bucket id mitgeben, dann den view zu dem bucket suchen und updaten wenn nötig - // If the task was moved into the done bucket and the task has a repeating cycle we should not update - // the bucket. - if targetBucket.ID == project.DoneBucketID && t.RepeatAfter > 0 { - t.Done = true // This will trigger the correct re-scheduling of the task (happening in updateDone later) - t.BucketID = ot.BucketID + taskBucket := &TaskBucket{ + BucketID: t.BucketID, + TaskID: t.ID, + ProjectViewID: view.ID, + } + + err = setTaskBucket(s, t, &ot, view, taskBucket) + if err != nil { + return err + } + + err = t.setTaskPosition(s, t.Position, view) + if err != nil { + return + } } // When a repeating task is marked as done, we update all deadlines and reminders and set it as undone @@ -909,7 +966,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { "percent_done", "project_id", "bucket_id", - "position", "repeat_mode", "cover_image_attachment_id", } -- 2.45.1 From 8ce476491ec72e086f36e7ca8747874c730bb8e1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 13:24:02 +0100 Subject: [PATCH 20/97] feat(views): only update the bucket when necessary --- pkg/models/tasks.go | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index d4ad6ec05..cc761e439 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -112,7 +112,8 @@ type Task struct { // A timestamp when this task was last updated. You cannot change this value. Updated time.Time `xorm:"updated not null" json:"updated"` - // Deprecated: use the id via the separate entity. + // The bucket id. Will only be populated when the task is accessed via a view with buckets. + // Can be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one. BucketID int64 `xorm:"bigint null" json:"bucket_id"` // The position of the task - any task project can be sorted as usual by this parameter. @@ -620,17 +621,6 @@ func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth) (e return } -func checkBucketAndTaskBelongToSameProject(fullTask *Task, bucket *Bucket) (err error) { - if fullTask.ProjectID != bucket.ProjectID { - return ErrBucketDoesNotBelongToProject{ - ProjectID: fullTask.ProjectID, - BucketID: fullTask.BucketID, - } - } - - return -} - // Checks if adding a new task would exceed the bucket limit func checkBucketLimit(s *xorm.Session, t *Task, bucket *Bucket) (err error) { if bucket.Limit > 0 { @@ -693,7 +683,12 @@ func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, view *Projec } // If there is a bucket set, make sure they belong to the same project as the task - err = checkBucketAndTaskBelongToSameProject(task, bucket) + if task.ProjectID != bucket.ProjectID { + return ErrBucketDoesNotBelongToProject{ + ProjectID: task.ProjectID, + BucketID: bucket.ID, + } + } if err != nil { return } @@ -918,15 +913,28 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { return err } - for _, view := range views { - // bucket id mitgeben, dann den view zu dem bucket suchen und updaten wenn nötig + buckets := make(map[int64]*Bucket) + err = s.In("project_project_id", + builder.Select("id"). + From("project_views"). + Where(builder.Eq{"project_id": t.ProjectID}), + ). + Find(&buckets) + for _, view := range views { taskBucket := &TaskBucket{ - BucketID: t.BucketID, TaskID: t.ID, ProjectViewID: view.ID, } + // Only update the bucket when the current view + if t.BucketID != 0 { + bucket, has := buckets[t.BucketID] + if has && bucket.ProjectViewID == view.ID { + taskBucket.BucketID = t.BucketID + } + } + err = setTaskBucket(s, t, &ot, view, taskBucket) if err != nil { return err -- 2.45.1 From ca4e3e01c5ef1a88730b5f800faebb9d216a1860 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 13:45:30 +0100 Subject: [PATCH 21/97] feat(views): recalculate all positions when updating --- pkg/models/kanban.go | 4 ++-- pkg/models/project_view.go | 24 +++++++++++++++++--- pkg/models/task_collection.go | 2 +- pkg/models/task_position.go | 18 +++++++++++++++ pkg/models/tasks.go | 41 ----------------------------------- 5 files changed, 42 insertions(+), 47 deletions(-) diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index df885d17f..facd2c70f 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -123,7 +123,7 @@ func getDefaultBucketID(s *xorm.Session, view *ProjectView) (bucketID int64, err // @Router /projects/{id}/views/{view}/buckets [get] func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) { - view, err := GetProjectViewByID(s, b.ProjectViewID, b.ProjectID) + view, err := GetProjectViewByIDAndProject(s, b.ProjectViewID, b.ProjectID) if err != nil { return nil, 0, 0, err } @@ -379,7 +379,7 @@ func (b *Bucket) Delete(s *xorm.Session, a web.Auth) (err error) { } // Get the default bucket - p, err := GetProjectViewByID(s, b.ProjectViewID, b.ProjectID) + p, err := GetProjectViewByIDAndProject(s, b.ProjectViewID, b.ProjectID) if err != nil { return } diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index 58c51328e..41b5f0736 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -210,7 +210,7 @@ func (p *ProjectView) ReadAll(s *xorm.Session, a web.Auth, _ string, _ int, _ in // @Failure 500 {object} models.Message "Internal error" // @Router /projects/{project}/views/{id} [get] func (p *ProjectView) ReadOne(s *xorm.Session, _ web.Auth) (err error) { - view, err := GetProjectViewByID(s, p.ID, p.ProjectID) + view, err := GetProjectViewByIDAndProject(s, p.ID, p.ProjectID) if err != nil { return err } @@ -273,7 +273,7 @@ func (p *ProjectView) Create(s *xorm.Session, a web.Auth) (err error) { // @Router /projects/{project}/views/{id} [post] func (p *ProjectView) Update(s *xorm.Session, _ web.Auth) (err error) { // Check if the project view exists - _, err = GetProjectViewByID(s, p.ID, p.ProjectID) + _, err = GetProjectViewByIDAndProject(s, p.ID, p.ProjectID) if err != nil { return } @@ -286,7 +286,7 @@ func (p *ProjectView) Update(s *xorm.Session, _ web.Auth) (err error) { return } -func GetProjectViewByID(s *xorm.Session, id, projectID int64) (view *ProjectView, err error) { +func GetProjectViewByIDAndProject(s *xorm.Session, id, projectID int64) (view *ProjectView, err error) { exists, err := s. Where("id = ? AND project_id = ?", id, projectID). NoAutoCondition(). @@ -304,6 +304,24 @@ func GetProjectViewByID(s *xorm.Session, id, projectID int64) (view *ProjectView return } +func GetProjectViewByID(s *xorm.Session, id int64) (view *ProjectView, err error) { + exists, err := s. + Where("id = ?", id). + NoAutoCondition(). + Get(view) + if err != nil { + return nil, err + } + + if !exists { + return nil, &ErrProjectViewDoesNotExist{ + ProjectViewID: id, + } + } + + return +} + func CreateDefaultViewsForProject(s *xorm.Session, project *Project, a web.Auth, createBacklogBucket bool) (err error) { list := &ProjectView{ ProjectID: project.ID, diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 4ef81e976..700585713 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -172,7 +172,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa var view *ProjectView if tf.ProjectViewID != 0 { - view, err = GetProjectViewByID(s, tf.ProjectViewID, tf.ProjectID) + view, err = GetProjectViewByIDAndProject(s, tf.ProjectViewID, tf.ProjectID) if err != nil { return nil, 0, 0, err } diff --git a/pkg/models/task_position.go b/pkg/models/task_position.go index 87f8f4845..c56141ae9 100644 --- a/pkg/models/task_position.go +++ b/pkg/models/task_position.go @@ -64,6 +64,17 @@ func (tp *TaskPosition) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { // @Failure 500 {object} models.Message "Internal error" // @Router /tasks/{id}/position [post] func (tp *TaskPosition) Update(s *xorm.Session, _ web.Auth) (err error) { + + // Update all positions if the newly saved position is < 0.1 + if tp.Position < 0.1 { + view, err := GetProjectViewByID(s, tp.ProjectViewID) + if err != nil { + return err + } + + return RecalculateTaskPositions(s, view) + } + exists, err := s. Where("task_id = ? AND project_view_id = ?", tp.TaskID, tp.ProjectViewID). Get(&TaskPosition{}) @@ -110,6 +121,13 @@ func RecalculateTaskPositions(s *xorm.Session, view *ProjectView) (err error) { }) } + _, err = s. + Where("project_view_id = ?", view.ID). + Delete(&TaskPosition{}) + if err != nil { + return + } + _, err = s.Insert(newPositions) return } diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index cc761e439..f2fb153dd 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -1111,14 +1111,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { return err } - // Update all positions if the newly saved position is < 0.1 - if ot.Position < 0.1 { - err = recalculateTaskPositions(s, t.ProjectID) - if err != nil { - return err - } - } - // Get the task updated timestamp in a new struct - if we'd just try to put it into t which we already have, it // would still contain the old updated date. nt := &Task{} @@ -1141,39 +1133,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { return updateProjectLastUpdated(s, &Project{ID: t.ProjectID}) } -func recalculateTaskPositions(s *xorm.Session, projectID int64) (err error) { - - allTasks := []*Task{} - err = s. - Where("project_id = ?", projectID). - OrderBy("position asc"). - Find(&allTasks) - if err != nil { - return - } - - maxPosition := math.Pow(2, 32) - - for i, task := range allTasks { - - currentPosition := maxPosition / float64(len(allTasks)) * (float64(i + 1)) - - // Here we use "NoAutoTime() to prevent the ORM from updating column "updated" automatically. - // Otherwise, this signals to CalDAV clients that the task has changed, which is not the case. - // Consequence: when synchronizing a list of tasks, the first one immediately changes the date of all the - // following ones from the same batch, which are then unable to be updated. - _, err = s.Cols("position"). - Where("id = ?", task.ID). - NoAutoTime(). - Update(&Task{Position: currentPosition}) - if err != nil { - return - } - } - - return -} - func addOneMonthToDate(d time.Time) time.Time { return time.Date(d.Year(), d.Month()+1, d.Day(), d.Hour(), d.Minute(), d.Second(), d.Nanosecond(), config.GetTimeZone()) } -- 2.45.1 From 14353b24d7eb7fe1046fb7eb53d348eec4eb167c Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 13:51:21 +0100 Subject: [PATCH 22/97] feat(views): set default position --- pkg/models/tasks.go | 44 ++++++++++++----------------------------- pkg/models/typesense.go | 1 - 2 files changed, 13 insertions(+), 32 deletions(-) diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index f2fb153dd..1e325cd06 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -117,11 +117,7 @@ type Task struct { BucketID int64 `xorm:"bigint null" json:"bucket_id"` // The position of the task - any task project can be sorted as usual by this parameter. - // When accessing tasks via kanban buckets, this is primarily used to sort them based on a range - // We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number). - // You would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2. - // 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. + // When accessing tasks via views with buckets, this is primarily used to sort them based on a range. // Positions are always saved per view. They will automatically be set if you request the tasks through a view // endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. Position float64 `xorm:"-" json:"position"` @@ -736,18 +732,6 @@ func calculateDefaultPosition(entityID int64, position float64) float64 { return position } -func (t *Task) setTaskPosition(s *xorm.Session, position float64, view *ProjectView) (err error) { - - pos := &TaskPosition{ - TaskID: t.ID, - ProjectViewID: view.ID, - Position: calculateDefaultPosition(t.Index, position), - } - - _, err = s.Insert(pos) - return -} - func getNextTaskIndex(s *xorm.Session, projectID int64) (nextIndex int64, err error) { latestTask := &Task{} _, err = s. @@ -823,6 +807,8 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err return err } + positions := []*TaskPosition{} + for _, view := range views { // Get the default bucket and move the task there @@ -831,10 +817,16 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err return } - err = t.setTaskPosition(s, t.Position, view) - if err != nil { - return - } + positions = append(positions, &TaskPosition{ + TaskID: t.ID, + ProjectViewID: view.ID, + Position: calculateDefaultPosition(t.Index, t.Position), + }) + } + + _, err = s.Insert(&positions) + if err != nil { + return } t.CreatedBy = createdBy @@ -939,11 +931,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { if err != nil { return err } - - err = t.setTaskPosition(s, t.Position, view) - if err != nil { - return - } } // When a repeating task is marked as done, we update all deadlines and reminders and set it as undone @@ -1086,10 +1073,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { if t.PercentDone == 0 { ot.PercentDone = 0 } - // Position - if t.Position == 0 { - ot.Position = 0 - } // Repeat from current date if t.RepeatMode == TaskRepeatModeDefault { ot.RepeatMode = TaskRepeatModeDefault @@ -1119,7 +1102,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { return err } t.Updated = nt.Updated - t.Position = nt.Position doer, _ := user.GetFromAuth(a) err = events.Dispatch(&TaskUpdatedEvent{ diff --git a/pkg/models/typesense.go b/pkg/models/typesense.go index 0afa9be85..ed82395bf 100644 --- a/pkg/models/typesense.go +++ b/pkg/models/typesense.go @@ -445,7 +445,6 @@ func convertTaskToTypesenseTask(task *Task) *typesenseTask { Created: task.Created.UTC().Unix(), Updated: task.Updated.UTC().Unix(), BucketID: task.BucketID, - Position: task.Position, CreatedByID: task.CreatedByID, Reminders: task.Reminders, Assignees: task.Assignees, -- 2.45.1 From 5641da27f7d6a33112a47681e9d427d7301d1f01 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 14:02:13 +0100 Subject: [PATCH 23/97] feat(views): save position in Typesense --- pkg/models/typesense.go | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/pkg/models/typesense.go b/pkg/models/typesense.go index ed82395bf..512ee8349 100644 --- a/pkg/models/typesense.go +++ b/pkg/models/typesense.go @@ -244,7 +244,13 @@ func ReindexAllTasks() (err error) { } func getTypesenseTaskForTask(s *xorm.Session, task *Task, projectsCache map[int64]*Project) (ttask *typesenseTask, err error) { - ttask = convertTaskToTypesenseTask(task) + positions := []*TaskPosition{} + err = s.Where("task_id = ?", task.ID).Find(&positions) + if err != nil { + return + } + + ttask = convertTaskToTypesenseTask(task, positions) var p *Project if projectsCache == nil { @@ -411,8 +417,6 @@ type typesenseTask struct { CoverImageAttachmentID int64 `json:"cover_image_attachment_id"` Created int64 `json:"created"` Updated int64 `json:"updated"` - BucketID int64 `json:"bucket_id"` - Position float64 `json:"position"` CreatedByID int64 `json:"created_by_id"` Reminders interface{} `json:"reminders"` Assignees interface{} `json:"assignees"` @@ -420,9 +424,13 @@ type typesenseTask struct { //RelatedTasks interface{} `json:"related_tasks"` // TODO Attachments interface{} `json:"attachments"` Comments interface{} `json:"comments"` + Positions []*struct { + Position float64 `json:"position"` + ProjectViewID int64 `json:"project_view_id"` + } `json:"positions"` } -func convertTaskToTypesenseTask(task *Task) *typesenseTask { +func convertTaskToTypesenseTask(task *Task, positions []*TaskPosition) *typesenseTask { tt := &typesenseTask{ ID: fmt.Sprintf("%d", task.ID), Title: task.Title, @@ -444,7 +452,6 @@ func convertTaskToTypesenseTask(task *Task) *typesenseTask { CoverImageAttachmentID: task.CoverImageAttachmentID, Created: task.Created.UTC().Unix(), Updated: task.Updated.UTC().Unix(), - BucketID: task.BucketID, CreatedByID: task.CreatedByID, Reminders: task.Reminders, Assignees: task.Assignees, @@ -466,6 +473,16 @@ func convertTaskToTypesenseTask(task *Task) *typesenseTask { tt.EndDate = nil } + for _, position := range positions { + tt.Positions = append(tt.Positions, &struct { + Position float64 `json:"position"` + ProjectViewID int64 `json:"project_view_id"` + }{ + Position: position.Position, + ProjectViewID: position.ProjectViewID, + }) + } + return tt } -- 2.45.1 From 43f24661d77d08489313f2f375b623bc2ad84492 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 16:09:09 +0100 Subject: [PATCH 24/97] feat(views): save view and position in Typesense --- pkg/models/listeners.go | 11 +++++ pkg/models/task_collection_sort.go | 41 ++++++++-------- pkg/models/task_search.go | 51 +++++++++++--------- pkg/models/tasks.go | 1 - pkg/models/typesense.go | 76 ++++++++++++++++++++---------- 5 files changed, 111 insertions(+), 69 deletions(-) diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index 9809d77c8..e8d87c877 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -19,6 +19,8 @@ package models import ( "context" "encoding/json" + "github.com/typesense/typesense-go/typesense/api" + "github.com/typesense/typesense-go/typesense/api/pointer" "strconv" "time" @@ -534,6 +536,15 @@ func (l *AddTaskToTypesense) Handle(msg *message.Message) (err error) { return err } + _, err = typesenseClient.Collection("tasks"). + Documents(). + Delete(context.Background(), &api.DeleteDocumentsParams{ + FilterBy: pointer.String("task_id:" + strconv.FormatInt(event.Task.ID, 10)), + }) + if err != nil { + return err + } + _, err = typesenseClient.Collection("tasks"). Documents(). Create(context.Background(), ttask) diff --git a/pkg/models/task_collection_sort.go b/pkg/models/task_collection_sort.go index fb8b797f2..bd686e049 100644 --- a/pkg/models/task_collection_sort.go +++ b/pkg/models/task_collection_sort.go @@ -27,26 +27,27 @@ 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" - taskPropertyProjectID string = "project_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" - taskPropertyIndex string = "index" + 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" + taskPropertyProjectID string = "project_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" + taskPropertyIndex string = "index" + taskPropertyProjectViewID string = "project_view_id" ) const ( diff --git a/pkg/models/task_search.go b/pkg/models/task_search.go index 6b0b4ad2f..3283c37c6 100644 --- a/pkg/models/task_search.go +++ b/pkg/models/task_search.go @@ -416,29 +416,6 @@ func convertParsedFilterToTypesense(rawFilters []*taskFilter) (filterBy string, func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error) { - var sortbyFields []string - for i, param := range opts.sortby { - // Validate the params - if err := param.validate(); err != nil { - return nil, totalCount, err - } - - // Typesense does not allow sorting by ID, so we sort by created timestamp instead - if param.sortBy == "id" { - param.sortBy = "created" - } - - sortbyFields = append(sortbyFields, param.sortBy+"(missing_values:last):"+param.orderBy.String()) - - if i == 2 { - // Typesense supports up to 3 sorting parameters - // https://typesense.org/docs/0.25.0/api/search.html#ranking-and-sorting-parameters - break - } - } - - sortby := strings.Join(sortbyFields, ",") - projectIDStrings := []string{} for _, id := range opts.projectIDs { projectIDStrings = append(projectIDStrings, strconv.FormatInt(id, 10)) @@ -454,6 +431,34 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, "(" + filter + ")", } + var sortbyFields []string + for i, param := range opts.sortby { + // Validate the params + if err := param.validate(); err != nil { + return nil, totalCount, err + } + + // Typesense does not allow sorting by ID, so we sort by created timestamp instead + if param.sortBy == taskPropertyID { + param.sortBy = taskPropertyCreated + } + + if param.sortBy == taskPropertyPosition { + filterBy = append(filterBy, "project_view_id: "+strconv.FormatInt(param.projectViewID, 10)) + break + } + + sortbyFields = append(sortbyFields, param.sortBy+"(missing_values:last):"+param.orderBy.String()) + + if i == 2 { + // Typesense supports up to 3 sorting parameters + // https://typesense.org/docs/0.25.0/api/search.html#ranking-and-sorting-parameters + break + } + } + + sortby := strings.Join(sortbyFields, ",") + //////////////// // Actual search diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 1e325cd06..074086e64 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -258,7 +258,6 @@ func getTaskIndexFromSearchString(s string) (index int64) { return } -//nolint:gocyclo func getRawTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts *taskSearchOptions) (tasks []*Task, resultCount int, totalItems int64, err error) { // If the user does not have any projects, don't try to get any tasks diff --git a/pkg/models/typesense.go b/pkg/models/typesense.go index 512ee8349..92df10126 100644 --- a/pkg/models/typesense.go +++ b/pkg/models/typesense.go @@ -19,6 +19,8 @@ package models import ( "context" "fmt" + "strconv" + "strings" "time" "code.vikunja.io/api/pkg/config" @@ -157,6 +159,10 @@ func CreateTypesenseCollections() error { Name: "created_by_id", Type: "int64", }, + { + Name: "project_view_id", + Type: "int64", + }, { Name: "reminders", Type: "object[]", // TODO @@ -243,14 +249,17 @@ func ReindexAllTasks() (err error) { return } -func getTypesenseTaskForTask(s *xorm.Session, task *Task, projectsCache map[int64]*Project) (ttask *typesenseTask, err error) { +func getTypesenseTaskForTask(s *xorm.Session, task *Task, projectsCache map[int64]*Project) (ttasks []*typesenseTask, err error) { positions := []*TaskPosition{} err = s.Where("task_id = ?", task.ID).Find(&positions) if err != nil { return } - ttask = convertTaskToTypesenseTask(task, positions) + for _, position := range positions { + ttask := convertTaskToTypesenseTask(task, position) + ttasks = append(ttasks, ttask) + } var p *Project if projectsCache == nil { @@ -271,11 +280,15 @@ func getTypesenseTaskForTask(s *xorm.Session, task *Task, projectsCache map[int6 } comment := &TaskComment{TaskID: task.ID} - ttask.Comments, _, _, err = comment.ReadAll(s, &user.User{ID: p.OwnerID}, "", -1, -1) + comments, _, _, err := comment.ReadAll(s, &user.User{ID: p.OwnerID}, "", -1, -1) if err != nil { return nil, fmt.Errorf("could not fetch comments for task %d: %s", task.ID, err.Error()) } + for _, t := range ttasks { + t.Comments = comments + } + return } @@ -292,16 +305,31 @@ func reindexTasksInTypesense(s *xorm.Session, tasks map[int64]*Task) (err error) } projects := make(map[int64]*Project) - typesenseTasks := []interface{}{} + taskIDs := []string{} + for _, task := range tasks { - ttask, err := getTypesenseTaskForTask(s, task, projects) + ttasks, err := getTypesenseTaskForTask(s, task, projects) if err != nil { return err } - typesenseTasks = append(typesenseTasks, ttask) + for _, ttask := range ttasks { + typesenseTasks = append(typesenseTasks, ttask) + } + + taskIDs = append(taskIDs, strconv.FormatInt(task.ID, 10)) + } + + _, err = typesenseClient.Collection("tasks"). + Documents(). + Delete(context.Background(), &api.DeleteDocumentsParams{ + FilterBy: pointer.String("task_id:[" + strings.Join(taskIDs, ",") + "]"), + }) + if err != nil { + log.Errorf("Could not delete old tasks in Typesense", err) + return err } _, err = typesenseClient.Collection("tasks"). @@ -398,6 +426,7 @@ func indexDummyTask() (err error) { type typesenseTask struct { ID string `json:"id"` + TaskID string `json:"task_id"` Title string `json:"title"` Description string `json:"description"` Done bool `json:"done"` @@ -422,17 +451,22 @@ type typesenseTask struct { Assignees interface{} `json:"assignees"` Labels interface{} `json:"labels"` //RelatedTasks interface{} `json:"related_tasks"` // TODO - Attachments interface{} `json:"attachments"` - Comments interface{} `json:"comments"` - Positions []*struct { - Position float64 `json:"position"` - ProjectViewID int64 `json:"project_view_id"` - } `json:"positions"` + Attachments interface{} `json:"attachments"` + Comments interface{} `json:"comments"` + Position float64 `json:"position"` + ProjectViewID int64 `json:"project_view_id"` } -func convertTaskToTypesenseTask(task *Task, positions []*TaskPosition) *typesenseTask { +func convertTaskToTypesenseTask(task *Task, position *TaskPosition) *typesenseTask { + + var projectViewID int64 + if position != nil { + projectViewID = position.ProjectViewID + } + tt := &typesenseTask{ - ID: fmt.Sprintf("%d", task.ID), + ID: fmt.Sprintf("%d_%d", task.ID, projectViewID), + TaskID: fmt.Sprintf("%d", task.ID), Title: task.Title, Description: task.Description, Done: task.Done, @@ -457,7 +491,9 @@ func convertTaskToTypesenseTask(task *Task, positions []*TaskPosition) *typesens Assignees: task.Assignees, Labels: task.Labels, //RelatedTasks: task.RelatedTasks, - Attachments: task.Attachments, + Attachments: task.Attachments, + Position: position.Position, + ProjectViewID: projectViewID, } if task.DoneAt.IsZero() { @@ -473,16 +509,6 @@ func convertTaskToTypesenseTask(task *Task, positions []*TaskPosition) *typesens tt.EndDate = nil } - for _, position := range positions { - tt.Positions = append(tt.Positions, &struct { - Position float64 `json:"position"` - ProjectViewID int64 `json:"project_view_id"` - }{ - Position: position.Position, - ProjectViewID: position.ProjectViewID, - }) - } - return tt } -- 2.45.1 From ee6ea0350603e54899a0958edf54d5b2f5462d13 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 19:23:59 +0100 Subject: [PATCH 25/97] feat(views): sort by position --- pkg/models/listeners.go | 11 -------- pkg/models/task_search.go | 4 +-- pkg/models/typesense.go | 59 ++++++++++----------------------------- 3 files changed, 17 insertions(+), 57 deletions(-) diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index e8d87c877..9809d77c8 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -19,8 +19,6 @@ package models import ( "context" "encoding/json" - "github.com/typesense/typesense-go/typesense/api" - "github.com/typesense/typesense-go/typesense/api/pointer" "strconv" "time" @@ -536,15 +534,6 @@ func (l *AddTaskToTypesense) Handle(msg *message.Message) (err error) { return err } - _, err = typesenseClient.Collection("tasks"). - Documents(). - Delete(context.Background(), &api.DeleteDocumentsParams{ - FilterBy: pointer.String("task_id:" + strconv.FormatInt(event.Task.ID, 10)), - }) - if err != nil { - return err - } - _, err = typesenseClient.Collection("tasks"). Documents(). Create(context.Background(), ttask) diff --git a/pkg/models/task_search.go b/pkg/models/task_search.go index 3283c37c6..221721328 100644 --- a/pkg/models/task_search.go +++ b/pkg/models/task_search.go @@ -444,8 +444,8 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, } if param.sortBy == taskPropertyPosition { - filterBy = append(filterBy, "project_view_id: "+strconv.FormatInt(param.projectViewID, 10)) - break + param.sortBy = "positions.view_" + strconv.FormatInt(param.projectViewID, 10) + continue } sortbyFields = append(sortbyFields, param.sortBy+"(missing_values:last):"+param.orderBy.String()) diff --git a/pkg/models/typesense.go b/pkg/models/typesense.go index 92df10126..6f9097cd5 100644 --- a/pkg/models/typesense.go +++ b/pkg/models/typesense.go @@ -20,7 +20,6 @@ import ( "context" "fmt" "strconv" - "strings" "time" "code.vikunja.io/api/pkg/config" @@ -249,17 +248,14 @@ func ReindexAllTasks() (err error) { return } -func getTypesenseTaskForTask(s *xorm.Session, task *Task, projectsCache map[int64]*Project) (ttasks []*typesenseTask, err error) { +func getTypesenseTaskForTask(s *xorm.Session, task *Task, projectsCache map[int64]*Project) (ttask *typesenseTask, err error) { positions := []*TaskPosition{} err = s.Where("task_id = ?", task.ID).Find(&positions) if err != nil { return } - for _, position := range positions { - ttask := convertTaskToTypesenseTask(task, position) - ttasks = append(ttasks, ttask) - } + ttask = convertTaskToTypesenseTask(task, positions) var p *Project if projectsCache == nil { @@ -280,15 +276,11 @@ func getTypesenseTaskForTask(s *xorm.Session, task *Task, projectsCache map[int6 } comment := &TaskComment{TaskID: task.ID} - comments, _, _, err := comment.ReadAll(s, &user.User{ID: p.OwnerID}, "", -1, -1) + ttask.Comments, _, _, err = comment.ReadAll(s, &user.User{ID: p.OwnerID}, "", -1, -1) if err != nil { return nil, fmt.Errorf("could not fetch comments for task %d: %s", task.ID, err.Error()) } - for _, t := range ttasks { - t.Comments = comments - } - return } @@ -306,30 +298,15 @@ func reindexTasksInTypesense(s *xorm.Session, tasks map[int64]*Task) (err error) projects := make(map[int64]*Project) typesenseTasks := []interface{}{} - taskIDs := []string{} for _, task := range tasks { - ttasks, err := getTypesenseTaskForTask(s, task, projects) + ttask, err := getTypesenseTaskForTask(s, task, projects) if err != nil { return err } - for _, ttask := range ttasks { - typesenseTasks = append(typesenseTasks, ttask) - } - - taskIDs = append(taskIDs, strconv.FormatInt(task.ID, 10)) - } - - _, err = typesenseClient.Collection("tasks"). - Documents(). - Delete(context.Background(), &api.DeleteDocumentsParams{ - FilterBy: pointer.String("task_id:[" + strings.Join(taskIDs, ",") + "]"), - }) - if err != nil { - log.Errorf("Could not delete old tasks in Typesense", err) - return err + typesenseTasks = append(typesenseTasks, ttask) } _, err = typesenseClient.Collection("tasks"). @@ -426,7 +403,6 @@ func indexDummyTask() (err error) { type typesenseTask struct { ID string `json:"id"` - TaskID string `json:"task_id"` Title string `json:"title"` Description string `json:"description"` Done bool `json:"done"` @@ -451,22 +427,15 @@ type typesenseTask struct { Assignees interface{} `json:"assignees"` Labels interface{} `json:"labels"` //RelatedTasks interface{} `json:"related_tasks"` // TODO - Attachments interface{} `json:"attachments"` - Comments interface{} `json:"comments"` - Position float64 `json:"position"` - ProjectViewID int64 `json:"project_view_id"` + Attachments interface{} `json:"attachments"` + Comments interface{} `json:"comments"` + Positions map[string]float64 `json:"positions"` } -func convertTaskToTypesenseTask(task *Task, position *TaskPosition) *typesenseTask { - - var projectViewID int64 - if position != nil { - projectViewID = position.ProjectViewID - } +func convertTaskToTypesenseTask(task *Task, positions []*TaskPosition) *typesenseTask { tt := &typesenseTask{ - ID: fmt.Sprintf("%d_%d", task.ID, projectViewID), - TaskID: fmt.Sprintf("%d", task.ID), + ID: fmt.Sprintf("%d", task.ID), Title: task.Title, Description: task.Description, Done: task.Done, @@ -491,9 +460,7 @@ func convertTaskToTypesenseTask(task *Task, position *TaskPosition) *typesenseTa Assignees: task.Assignees, Labels: task.Labels, //RelatedTasks: task.RelatedTasks, - Attachments: task.Attachments, - Position: position.Position, - ProjectViewID: projectViewID, + Attachments: task.Attachments, } if task.DoneAt.IsZero() { @@ -509,6 +476,10 @@ func convertTaskToTypesenseTask(task *Task, position *TaskPosition) *typesenseTa tt.EndDate = nil } + for _, position := range positions { + tt.Positions["view_"+strconv.FormatInt(position.ProjectViewID, 10)] = position.Position + } + return tt } -- 2.45.1 From cf15cc6f129e4019cf18e05217c32e8d8782f8f4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 22:18:44 +0100 Subject: [PATCH 26/97] feat(views): fetch tasks via view context when accessing them through views --- .../src/components/project/ProjectWrapper.vue | 122 ++++++++---------- frontend/src/composables/useTaskList.ts | 13 +- frontend/src/helpers/projectView.ts | 16 +-- frontend/src/modelTypes/IProject.ts | 2 + frontend/src/modelTypes/IProjectView.ts | 24 ++++ frontend/src/router/index.ts | 11 ++ frontend/src/services/taskCollection.ts | 2 +- frontend/src/stores/kanban.ts | 4 +- frontend/src/stores/tasks.ts | 19 +-- frontend/src/views/project/ProjectGantt.vue | 10 +- frontend/src/views/project/ProjectKanban.vue | 6 +- frontend/src/views/project/ProjectList.vue | 7 +- frontend/src/views/project/ProjectTable.vue | 5 +- frontend/src/views/project/ProjectView.vue | 55 ++++++++ .../views/project/helpers/useGanttFilters.ts | 5 +- .../views/project/helpers/useGanttTaskList.ts | 17 ++- 16 files changed, 210 insertions(+), 108 deletions(-) create mode 100644 frontend/src/modelTypes/IProjectView.ts create mode 100644 frontend/src/views/project/ProjectView.vue diff --git a/frontend/src/components/project/ProjectWrapper.vue b/frontend/src/components/project/ProjectWrapper.vue index 255124b19..b4d178a92 100644 --- a/frontend/src/components/project/ProjectWrapper.vue +++ b/frontend/src/components/project/ProjectWrapper.vue @@ -6,47 +6,19 @@

{{ getProjectTitle(currentProject) }}

- +
- {{ $t('project.list.title') }} - - - {{ $t('project.gantt.title') }} - - - {{ $t('project.table.title') }} - - - {{ $t('project.kanban.title') }} + {{ getViewTitle(v) }}
- +
- + \ No newline at end of file diff --git a/frontend/src/views/project/helpers/useGanttFilters.ts b/frontend/src/views/project/helpers/useGanttFilters.ts index dfe8ccd8b..caf4df18d 100644 --- a/frontend/src/views/project/helpers/useGanttFilters.ts +++ b/frontend/src/views/project/helpers/useGanttFilters.ts @@ -12,6 +12,7 @@ import type {TaskFilterParams} from '@/services/taskCollection' import type {DateISO} from '@/types/DateISO' import type {DateKebab} from '@/types/DateKebab' +import type {IProjectView} from '@/modelTypes/IProjectView' // convenient internal filter object export interface GanttFilters { @@ -88,7 +89,7 @@ export type UseGanttFiltersReturn = ReturnType> & ReturnType> -export function useGanttFilters(route: Ref): UseGanttFiltersReturn { +export function useGanttFilters(route: Ref, view: IProjectView): UseGanttFiltersReturn { const { filters, hasDefaultFilters, @@ -108,7 +109,7 @@ export function useGanttFilters(route: Ref): UseGanttFi isLoading, addTask, updateTask, - } = useGanttTaskList(filters, ganttFiltersToApiParams) + } = useGanttTaskList(filters, ganttFiltersToApiParams, view) return { filters, diff --git a/frontend/src/views/project/helpers/useGanttTaskList.ts b/frontend/src/views/project/helpers/useGanttTaskList.ts index f2a76c8d6..8349b3c7c 100644 --- a/frontend/src/views/project/helpers/useGanttTaskList.ts +++ b/frontend/src/views/project/helpers/useGanttTaskList.ts @@ -1,4 +1,4 @@ -import {computed, ref, shallowReactive, watch, type Ref} from 'vue' +import {computed, ref, type Ref, shallowReactive, watch} from 'vue' import {klona} from 'klona/lite' import type {Filters} from '@/composables/useRouteFilters' @@ -10,16 +10,15 @@ import TaskService from '@/services/task' import TaskModel from '@/models/task' import {error, success} from '@/message' import {useAuthStore} from '@/stores/auth' +import type {IProjectView} from '@/modelTypes/IProjectView' // FIXME: unify with general `useTaskList` export function useGanttTaskList( filters: Ref, filterToApiParams: (filters: F) => TaskFilterParams, - options: { - loadAll?: boolean, - } = { - loadAll: true, - }) { + view: IProjectView, + loadAll: boolean = true, +) { const taskCollectionService = shallowReactive(new TaskCollectionService()) const taskService = shallowReactive(new TaskService()) const authStore = useAuthStore() @@ -29,13 +28,13 @@ export function useGanttTaskList( const tasks = ref>(new Map()) async function fetchTasks(params: TaskFilterParams, page = 1): Promise { - - if(params.filter_timezone === '') { + + if (params.filter_timezone === '') { params.filter_timezone = authStore.settings.timezone } const tasks = await taskCollectionService.getAll({projectId: filters.value.projectId}, params, page) as ITask[] - if (options.loadAll && page < taskCollectionService.totalPages) { + if (loadAll && page < taskCollectionService.totalPages) { const nextTasks = await fetchTasks(params, page + 1) return tasks.concat(nextTasks) } -- 2.45.1 From 786654319857c46b00a8724180ed81f14f392552 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 22:19:26 +0100 Subject: [PATCH 27/97] feat(views): generate swagger docs --- pkg/swagger/docs.go | 1215 ++++++++++++++++++++++++++------------ pkg/swagger/swagger.json | 1210 +++++++++++++++++++++++++------------ pkg/swagger/swagger.yaml | 770 ++++++++++++++++-------- 3 files changed, 2197 insertions(+), 998 deletions(-) diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index a27794f94..2ce644a3b 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -1,4 +1,5 @@ -// Package swagger Code generated by swaggo/swag. DO NOT EDIT +// Code generated by swaggo/swag. DO NOT EDIT. + package swagger import "github.com/swaggo/swag" @@ -1891,150 +1892,6 @@ const docTemplate = `{ } } }, - "/projects/{id}/buckets": { - "get": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Returns all kanban buckets with belong to a project including their tasks. Buckets are always sorted by their ` + "`" + `position` + "`" + ` in ascending order. Tasks are sorted by their ` + "`" + `kanban_position` + "`" + ` in ascending order.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Get all kanban buckets of a project", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "The page number for tasks. Used for pagination. If not provided, the first page of results is returned.", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "The maximum number of tasks per bucket per page. This parameter is limited by the configured maximum of items per page.", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "Search tasks by task text.", - "name": "s", - "in": "query" - }, - { - "type": "string", - "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", - "name": "filter", - "in": "query" - }, - { - "type": "string", - "description": "The time zone which should be used for date match (statements like ", - "name": "filter_timezone", - "in": "query" - }, - { - "type": "string", - "description": "If set to true the result will include filtered fields whose value is set to ` + "`" + `null` + "`" + `. Available values are ` + "`" + `true` + "`" + ` or ` + "`" + `false` + "`" + `. Defaults to ` + "`" + `false` + "`" + `.", - "name": "filter_include_nulls", - "in": "query" - } - ], - "responses": { - "200": { - "description": "The buckets with their tasks", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Bucket" - } - } - }, - "500": { - "description": "Internal server error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - }, - "put": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Creates a new kanban bucket on a project.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Create a new bucket", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "The bucket object", - "name": "bucket", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.Bucket" - } - } - ], - "responses": { - "200": { - "description": "The created bucket object.", - "schema": { - "$ref": "#/definitions/models.Bucket" - } - }, - "400": { - "description": "Invalid bucket object provided.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "404": { - "description": "The project does not exist.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - } - }, "/projects/{id}/projectusers": { "get": { "security": [ @@ -2100,98 +1957,6 @@ const docTemplate = `{ } }, "/projects/{id}/tasks": { - "get": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Returns all tasks for the current project.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "task" - ], - "summary": "Get tasks in a project", - "parameters": [ - { - "type": "integer", - "description": "The project ID.", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "The page number. Used for pagination. If not provided, the first page of results is returned.", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page.", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "Search tasks by task text.", - "name": "s", - "in": "query" - }, - { - "type": "string", - "description": "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with ` + "`" + `order_by` + "`" + `. Possible values to sort by are ` + "`" + `id` + "`" + `, ` + "`" + `title` + "`" + `, ` + "`" + `description` + "`" + `, ` + "`" + `done` + "`" + `, ` + "`" + `done_at` + "`" + `, ` + "`" + `due_date` + "`" + `, ` + "`" + `created_by_id` + "`" + `, ` + "`" + `project_id` + "`" + `, ` + "`" + `repeat_after` + "`" + `, ` + "`" + `priority` + "`" + `, ` + "`" + `start_date` + "`" + `, ` + "`" + `end_date` + "`" + `, ` + "`" + `hex_color` + "`" + `, ` + "`" + `percent_done` + "`" + `, ` + "`" + `uid` + "`" + `, ` + "`" + `created` + "`" + `, ` + "`" + `updated` + "`" + `. Default is ` + "`" + `id` + "`" + `.", - "name": "sort_by", - "in": "query" - }, - { - "type": "string", - "description": "The ordering parameter. Possible values to order by are ` + "`" + `asc` + "`" + ` or ` + "`" + `desc` + "`" + `. Default is ` + "`" + `asc` + "`" + `.", - "name": "order_by", - "in": "query" - }, - { - "type": "string", - "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", - "name": "filter", - "in": "query" - }, - { - "type": "string", - "description": "The time zone which should be used for date match (statements like ", - "name": "filter_timezone", - "in": "query" - }, - { - "type": "string", - "description": "If set to true the result will include filtered fields whose value is set to ` + "`" + `null` + "`" + `. Available values are ` + "`" + `true` + "`" + ` or ` + "`" + `false` + "`" + `. Defaults to ` + "`" + `false` + "`" + `.", - "name": "filter_include_nulls", - "in": "query" - } - ], - "responses": { - "200": { - "description": "The tasks", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Task" - } - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - }, "put": { "security": [ { @@ -2531,6 +2296,229 @@ const docTemplate = `{ } } }, + "/projects/{id}/views/{view}/buckets": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns all kanban buckets which belong to that project. Buckets are always sorted by their ` + "`" + `position` + "`" + ` in ascending order. To get all buckets with their tasks, use the tasks endpoint with a kanban view.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get all kanban buckets of a project", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The buckets", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Bucket" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Creates a new kanban bucket on a project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create a new bucket", + "parameters": [ + { + "type": "integer", + "description": "Project Id", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + }, + { + "description": "The bucket object", + "name": "bucket", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Bucket" + } + } + ], + "responses": { + "200": { + "description": "The created bucket object.", + "schema": { + "$ref": "#/definitions/models.Bucket" + } + }, + "400": { + "description": "Invalid bucket object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "The project does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/projects/{id}/views/{view}/tasks": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns all tasks for the current project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "task" + ], + "summary": "Get tasks in a project", + "parameters": [ + { + "type": "integer", + "description": "The project ID.", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "The project view ID.", + "name": "view", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "The page number. Used for pagination. If not provided, the first page of results is returned.", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page.", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Search tasks by task text.", + "name": "s", + "in": "query" + }, + { + "type": "string", + "description": "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with ` + "`" + `order_by` + "`" + `. Possible values to sort by are ` + "`" + `id` + "`" + `, ` + "`" + `title` + "`" + `, ` + "`" + `description` + "`" + `, ` + "`" + `done` + "`" + `, ` + "`" + `done_at` + "`" + `, ` + "`" + `due_date` + "`" + `, ` + "`" + `created_by_id` + "`" + `, ` + "`" + `project_id` + "`" + `, ` + "`" + `repeat_after` + "`" + `, ` + "`" + `priority` + "`" + `, ` + "`" + `start_date` + "`" + `, ` + "`" + `end_date` + "`" + `, ` + "`" + `hex_color` + "`" + `, ` + "`" + `percent_done` + "`" + `, ` + "`" + `uid` + "`" + `, ` + "`" + `created` + "`" + `, ` + "`" + `updated` + "`" + `. Default is ` + "`" + `id` + "`" + `.", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "The ordering parameter. Possible values to order by are ` + "`" + `asc` + "`" + ` or ` + "`" + `desc` + "`" + `. Default is ` + "`" + `asc` + "`" + `.", + "name": "order_by", + "in": "query" + }, + { + "type": "string", + "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", + "name": "filter", + "in": "query" + }, + { + "type": "string", + "description": "The time zone which should be used for date match (statements like ", + "name": "filter_timezone", + "in": "query" + }, + { + "type": "string", + "description": "If set to true the result will include filtered fields whose value is set to ` + "`" + `null` + "`" + `. Available values are ` + "`" + `true` + "`" + ` or ` + "`" + `false` + "`" + `. Defaults to ` + "`" + `false` + "`" + `.", + "name": "filter_include_nulls", + "in": "query" + } + ], + "responses": { + "200": { + "description": "The tasks", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Task" + } + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/projects/{id}/webhooks": { "get": { "security": [ @@ -2755,131 +2743,6 @@ const docTemplate = `{ } } }, - "/projects/{projectID}/buckets/{bucketID}": { - "post": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Updates an existing kanban bucket.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Update an existing bucket", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "projectID", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Bucket Id", - "name": "bucketID", - "in": "path", - "required": true - }, - { - "description": "The bucket object", - "name": "bucket", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.Bucket" - } - } - ], - "responses": { - "200": { - "description": "The created bucket object.", - "schema": { - "$ref": "#/definitions/models.Bucket" - } - }, - "400": { - "description": "Invalid bucket object provided.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "404": { - "description": "The bucket does not exist.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - }, - "delete": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Deletes an existing kanban bucket and dissociates all of its task. It does not delete any tasks. You cannot delete the last bucket on a project.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Deletes an existing bucket", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "projectID", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Bucket Id", - "name": "bucketID", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Successfully deleted.", - "schema": { - "$ref": "#/definitions/models.Message" - } - }, - "404": { - "description": "The bucket does not exist.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - } - }, "/projects/{projectID}/duplicate": { "put": { "security": [ @@ -3200,6 +3063,145 @@ const docTemplate = `{ } } }, + "/projects/{projectID}/views/{view}/buckets/{bucketID}": { + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Updates an existing kanban bucket.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Update an existing bucket", + "parameters": [ + { + "type": "integer", + "description": "Project Id", + "name": "projectID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Bucket Id", + "name": "bucketID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + }, + { + "description": "The bucket object", + "name": "bucket", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Bucket" + } + } + ], + "responses": { + "200": { + "description": "The created bucket object.", + "schema": { + "$ref": "#/definitions/models.Bucket" + } + }, + "400": { + "description": "Invalid bucket object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "The bucket does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "delete": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Deletes an existing kanban bucket and dissociates all of its task. It does not delete any tasks. You cannot delete the last bucket on a project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Deletes an existing bucket", + "parameters": [ + { + "type": "integer", + "description": "Project Id", + "name": "projectID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Bucket Id", + "name": "bucketID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully deleted.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "404": { + "description": "The bucket does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/projects/{project}/shares": { "get": { "security": [ @@ -3454,6 +3456,281 @@ const docTemplate = `{ } } }, + "/projects/{project}/views": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns all project views for a sepcific project", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get all project views for a project", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The project views", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectView" + } + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Create a project view in a specific project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create a project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "description": "The project view you want to create.", + "name": "view", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + } + ], + "responses": { + "200": { + "description": "The created project view", + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + }, + "403": { + "description": "The user does not have access to create a project view", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/projects/{project}/views/{id}": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns a project view by its ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get one project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project View ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The project view", + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + }, + "403": { + "description": "The user does not have access to this project view", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Updates a project view.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Updates a project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project View ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "The project view with updated values you want to change.", + "name": "view", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + } + ], + "responses": { + "200": { + "description": "The updated project view.", + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + }, + "400": { + "description": "Invalid project view object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "delete": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Deletes a project view.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Delete a project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project View ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The project view was successfully deleted.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "403": { + "description": "The user does not have access to the project view", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/register": { "post": { "description": "Creates a new user account.", @@ -4269,6 +4546,64 @@ const docTemplate = `{ } } }, + "/tasks/{id}/position": { + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Updates a task position.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "task" + ], + "summary": "Updates a task position", + "parameters": [ + { + "type": "integer", + "description": "Task ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "The task position with updated values you want to change.", + "name": "view", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.TaskPosition" + } + } + ], + "responses": { + "200": { + "description": "The updated task position.", + "schema": { + "$ref": "#/definitions/models.TaskPosition" + } + }, + "400": { + "description": "Invalid task position object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/tasks/{taskID}/assignees": { "get": { "security": [ @@ -7454,8 +7789,8 @@ const docTemplate = `{ "description": "The position this bucket has when querying all buckets. See the tasks.position property on how to use this.", "type": "number" }, - "project_id": { - "description": "The project this bucket belongs to.", + "project_view_id": { + "description": "The project view this bucket belongs to.", "type": "integer" }, "tasks": { @@ -7476,6 +7811,19 @@ const docTemplate = `{ } } }, + "models.BucketConfigurationModeKind": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-varnames": [ + "BucketConfigurationModeNone", + "BucketConfigurationModeManual", + "BucketConfigurationModeFilter" + ] + }, "models.BulkAssignees": { "type": "object", "properties": { @@ -7506,7 +7854,7 @@ const docTemplate = `{ } }, "bucket_id": { - "description": "BucketID is the ID of the kanban bucket this task belongs to.", + "description": "The bucket id. Will only be populated when the task is accessed via a view with buckets.\nCan be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one.", "type": "integer" }, "cover_image_attachment_id": { @@ -7566,10 +7914,6 @@ const docTemplate = `{ "description": "True if a task is a favorite task. Favorite tasks show up in a separate \"Important\" project. This value depends on the user making the call to the api.", "type": "boolean" }, - "kanban_position": { - "description": "The position of tasks in the kanban board. See the docs for the ` + "`" + `position` + "`" + ` property on how to use this.", - "type": "number" - }, "labels": { "description": "An array of labels which are associated with this task.", "type": "array", @@ -7582,7 +7926,7 @@ const docTemplate = `{ "type": "number" }, "position": { - "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via kanban buckets, this is primarily used to sort them based on a range\nWe're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).\nYou would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2.\nA 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task\nwhich also leaves a lot of room for rearranging and sorting later.", + "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via views with buckets, this is primarily used to sort them based on a range.\nPositions are always saved per view. They will automatically be set if you request the tasks through a view\nendpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.", "type": "number" }, "priority": { @@ -7831,16 +8175,12 @@ const docTemplate = `{ "description": "A timestamp when this project was created. You cannot change this value.", "type": "string" }, - "default_bucket_id": { - "description": "The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a project.", - "type": "integer" - }, "description": { "description": "The description of the project.", "type": "string" }, "done_bucket_id": { - "description": "If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.", + "description": "Deprecated: If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.", "type": "integer" }, "hex_color": { @@ -7898,6 +8238,12 @@ const docTemplate = `{ "updated": { "description": "A timestamp when this project was last updated. You cannot change this value.", "type": "string" + }, + "views": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectView" + } } } }, @@ -7949,6 +8295,96 @@ const docTemplate = `{ } } }, + "models.ProjectView": { + "type": "object", + "properties": { + "bucket_configuration": { + "description": "When the bucket configuration mode is not ` + "`" + `manual` + "`" + `, this field holds the options of that configuration.", + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectViewBucketConfiguration" + } + }, + "bucket_configuration_mode": { + "description": "The bucket configuration mode. Can be ` + "`" + `none` + "`" + `, ` + "`" + `manual` + "`" + ` or ` + "`" + `filter` + "`" + `. ` + "`" + `manual` + "`" + ` allows to move tasks between buckets as you normally would. ` + "`" + `filter` + "`" + ` creates buckets based on a filter for each bucket.", + "allOf": [ + { + "$ref": "#/definitions/models.BucketConfigurationModeKind" + } + ] + }, + "created": { + "description": "A timestamp when this reaction was created. You cannot change this value.", + "type": "string" + }, + "default_bucket_id": { + "description": "The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a view.", + "type": "integer" + }, + "done_bucket_id": { + "description": "If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.", + "type": "integer" + }, + "filter": { + "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.", + "type": "string" + }, + "id": { + "description": "The unique numeric id of this view", + "type": "integer" + }, + "position": { + "description": "The position of this view in the list. The list of all views will be sorted by this parameter.", + "type": "number" + }, + "project_id": { + "description": "The project this view belongs to", + "type": "integer" + }, + "title": { + "description": "The title of this view", + "type": "string" + }, + "updated": { + "description": "A timestamp when this view was updated. You cannot change this value.", + "type": "string" + }, + "view_kind": { + "description": "The kind of this view. Can be ` + "`" + `list` + "`" + `, ` + "`" + `gantt` + "`" + `, ` + "`" + `table` + "`" + ` or ` + "`" + `kanban` + "`" + `.", + "allOf": [ + { + "$ref": "#/definitions/models.ProjectViewKind" + } + ] + } + } + }, + "models.ProjectViewBucketConfiguration": { + "type": "object", + "properties": { + "filter": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "models.ProjectViewKind": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-varnames": [ + "ProjectViewKindList", + "ProjectViewKindGantt", + "ProjectViewKindTable", + "ProjectViewKindKanban" + ] + }, "models.Reaction": { "type": "object", "properties": { @@ -8162,7 +8598,7 @@ const docTemplate = `{ } }, "bucket_id": { - "description": "BucketID is the ID of the kanban bucket this task belongs to.", + "description": "The bucket id. Will only be populated when the task is accessed via a view with buckets.\nCan be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one.", "type": "integer" }, "cover_image_attachment_id": { @@ -8222,10 +8658,6 @@ const docTemplate = `{ "description": "True if a task is a favorite task. Favorite tasks show up in a separate \"Important\" project. This value depends on the user making the call to the api.", "type": "boolean" }, - "kanban_position": { - "description": "The position of tasks in the kanban board. See the docs for the ` + "`" + `position` + "`" + ` property on how to use this.", - "type": "number" - }, "labels": { "description": "An array of labels which are associated with this task.", "type": "array", @@ -8238,7 +8670,7 @@ const docTemplate = `{ "type": "number" }, "position": { - "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via kanban buckets, this is primarily used to sort them based on a range\nWe're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).\nYou would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2.\nA 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task\nwhich also leaves a lot of room for rearranging and sorting later.", + "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via views with buckets, this is primarily used to sort them based on a range.\nPositions are always saved per view. They will automatically be set if you request the tasks through a view\nendpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.", "type": "number" }, "priority": { @@ -8342,7 +8774,7 @@ const docTemplate = `{ "type": "object", "properties": { "filter": { - "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", + "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.", "type": "string" }, "filter_include_nulls": { @@ -8388,6 +8820,23 @@ const docTemplate = `{ } } }, + "models.TaskPosition": { + "type": "object", + "properties": { + "position": { + "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via kanban buckets, this is primarily used to sort them based on a range\nWe're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).\nYou would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2.\nA 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task\nwhich also leaves a lot of room for rearranging and sorting later.\nPositions are always saved per view. They will automatically be set if you request the tasks through a view\nendpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.", + "type": "number" + }, + "project_view_id": { + "description": "The project view this task is related to", + "type": "integer" + }, + "task_id": { + "description": "The ID of the task this position is for", + "type": "integer" + } + } + }, "models.TaskRelation": { "type": "object", "properties": { @@ -9211,8 +9660,6 @@ var SwaggerInfo = &swag.Spec{ Description: "# Pagination\nEvery endpoint capable of pagination will return two headers:\n* `x-pagination-total-pages`: The total number of available pages for this request\n* `x-pagination-result-count`: The number of items returned for this request.\n# Rights\nAll endpoints which return a single item (project, task, etc.) - no array - will also return a `x-max-right` header with the max right the user has on this item as an int where `0` is `Read Only`, `1` is `Read & Write` and `2` is `Admin`.\nThis can be used to show or hide ui elements based on the rights the user has.\n# Errors\nAll errors have an error code and a human-readable error message in addition to the http status code. You should always check for the status code in the response, not only the http status code.\nDue to limitations in the swagger library we're using for this document, only one error per http status code is documented here. Make sure to check the [error docs](https://vikunja.io/docs/errors/) in Vikunja's documentation for a full list of available error codes.\n# Authorization\n**JWT-Auth:** Main authorization method, used for most of the requests. Needs `Authorization: Bearer `-header to authenticate successfully.\n\n**API Token:** You can create scoped API tokens for your user and use the token to make authenticated requests in the context of that user. The token must be provided via an `Authorization: Bearer ` header, similar to jwt auth. See the documentation for the `api` group to manage token creation and revocation.\n\n**BasicAuth:** Only used when requesting tasks via CalDAV.\n", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, - LeftDelim: "{{", - RightDelim: "}}", } func init() { diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index d5d89a40b..2d65ab7d1 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -1883,150 +1883,6 @@ } } }, - "/projects/{id}/buckets": { - "get": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Returns all kanban buckets with belong to a project including their tasks. Buckets are always sorted by their `position` in ascending order. Tasks are sorted by their `kanban_position` in ascending order.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Get all kanban buckets of a project", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "The page number for tasks. Used for pagination. If not provided, the first page of results is returned.", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "The maximum number of tasks per bucket per page. This parameter is limited by the configured maximum of items per page.", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "Search tasks by task text.", - "name": "s", - "in": "query" - }, - { - "type": "string", - "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", - "name": "filter", - "in": "query" - }, - { - "type": "string", - "description": "The time zone which should be used for date match (statements like ", - "name": "filter_timezone", - "in": "query" - }, - { - "type": "string", - "description": "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`.", - "name": "filter_include_nulls", - "in": "query" - } - ], - "responses": { - "200": { - "description": "The buckets with their tasks", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Bucket" - } - } - }, - "500": { - "description": "Internal server error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - }, - "put": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Creates a new kanban bucket on a project.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Create a new bucket", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "The bucket object", - "name": "bucket", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.Bucket" - } - } - ], - "responses": { - "200": { - "description": "The created bucket object.", - "schema": { - "$ref": "#/definitions/models.Bucket" - } - }, - "400": { - "description": "Invalid bucket object provided.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "404": { - "description": "The project does not exist.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - } - }, "/projects/{id}/projectusers": { "get": { "security": [ @@ -2092,98 +1948,6 @@ } }, "/projects/{id}/tasks": { - "get": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Returns all tasks for the current project.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "task" - ], - "summary": "Get tasks in a project", - "parameters": [ - { - "type": "integer", - "description": "The project ID.", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "The page number. Used for pagination. If not provided, the first page of results is returned.", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page.", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "Search tasks by task text.", - "name": "s", - "in": "query" - }, - { - "type": "string", - "description": "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with `order_by`. Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, `due_date`, `created_by_id`, `project_id`, `repeat_after`, `priority`, `start_date`, `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default is `id`.", - "name": "sort_by", - "in": "query" - }, - { - "type": "string", - "description": "The ordering parameter. Possible values to order by are `asc` or `desc`. Default is `asc`.", - "name": "order_by", - "in": "query" - }, - { - "type": "string", - "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", - "name": "filter", - "in": "query" - }, - { - "type": "string", - "description": "The time zone which should be used for date match (statements like ", - "name": "filter_timezone", - "in": "query" - }, - { - "type": "string", - "description": "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`.", - "name": "filter_include_nulls", - "in": "query" - } - ], - "responses": { - "200": { - "description": "The tasks", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Task" - } - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - }, "put": { "security": [ { @@ -2523,6 +2287,229 @@ } } }, + "/projects/{id}/views/{view}/buckets": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns all kanban buckets which belong to that project. Buckets are always sorted by their `position` in ascending order. To get all buckets with their tasks, use the tasks endpoint with a kanban view.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get all kanban buckets of a project", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The buckets", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Bucket" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Creates a new kanban bucket on a project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create a new bucket", + "parameters": [ + { + "type": "integer", + "description": "Project Id", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + }, + { + "description": "The bucket object", + "name": "bucket", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Bucket" + } + } + ], + "responses": { + "200": { + "description": "The created bucket object.", + "schema": { + "$ref": "#/definitions/models.Bucket" + } + }, + "400": { + "description": "Invalid bucket object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "The project does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/projects/{id}/views/{view}/tasks": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns all tasks for the current project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "task" + ], + "summary": "Get tasks in a project", + "parameters": [ + { + "type": "integer", + "description": "The project ID.", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "The project view ID.", + "name": "view", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "The page number. Used for pagination. If not provided, the first page of results is returned.", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page.", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Search tasks by task text.", + "name": "s", + "in": "query" + }, + { + "type": "string", + "description": "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with `order_by`. Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, `due_date`, `created_by_id`, `project_id`, `repeat_after`, `priority`, `start_date`, `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default is `id`.", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "The ordering parameter. Possible values to order by are `asc` or `desc`. Default is `asc`.", + "name": "order_by", + "in": "query" + }, + { + "type": "string", + "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", + "name": "filter", + "in": "query" + }, + { + "type": "string", + "description": "The time zone which should be used for date match (statements like ", + "name": "filter_timezone", + "in": "query" + }, + { + "type": "string", + "description": "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`.", + "name": "filter_include_nulls", + "in": "query" + } + ], + "responses": { + "200": { + "description": "The tasks", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Task" + } + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/projects/{id}/webhooks": { "get": { "security": [ @@ -2747,131 +2734,6 @@ } } }, - "/projects/{projectID}/buckets/{bucketID}": { - "post": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Updates an existing kanban bucket.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Update an existing bucket", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "projectID", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Bucket Id", - "name": "bucketID", - "in": "path", - "required": true - }, - { - "description": "The bucket object", - "name": "bucket", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.Bucket" - } - } - ], - "responses": { - "200": { - "description": "The created bucket object.", - "schema": { - "$ref": "#/definitions/models.Bucket" - } - }, - "400": { - "description": "Invalid bucket object provided.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "404": { - "description": "The bucket does not exist.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - }, - "delete": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Deletes an existing kanban bucket and dissociates all of its task. It does not delete any tasks. You cannot delete the last bucket on a project.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Deletes an existing bucket", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "projectID", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Bucket Id", - "name": "bucketID", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Successfully deleted.", - "schema": { - "$ref": "#/definitions/models.Message" - } - }, - "404": { - "description": "The bucket does not exist.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - } - }, "/projects/{projectID}/duplicate": { "put": { "security": [ @@ -3192,6 +3054,145 @@ } } }, + "/projects/{projectID}/views/{view}/buckets/{bucketID}": { + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Updates an existing kanban bucket.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Update an existing bucket", + "parameters": [ + { + "type": "integer", + "description": "Project Id", + "name": "projectID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Bucket Id", + "name": "bucketID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + }, + { + "description": "The bucket object", + "name": "bucket", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Bucket" + } + } + ], + "responses": { + "200": { + "description": "The created bucket object.", + "schema": { + "$ref": "#/definitions/models.Bucket" + } + }, + "400": { + "description": "Invalid bucket object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "The bucket does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "delete": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Deletes an existing kanban bucket and dissociates all of its task. It does not delete any tasks. You cannot delete the last bucket on a project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Deletes an existing bucket", + "parameters": [ + { + "type": "integer", + "description": "Project Id", + "name": "projectID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Bucket Id", + "name": "bucketID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully deleted.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "404": { + "description": "The bucket does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/projects/{project}/shares": { "get": { "security": [ @@ -3446,6 +3447,281 @@ } } }, + "/projects/{project}/views": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns all project views for a sepcific project", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get all project views for a project", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The project views", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectView" + } + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Create a project view in a specific project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create a project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "description": "The project view you want to create.", + "name": "view", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + } + ], + "responses": { + "200": { + "description": "The created project view", + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + }, + "403": { + "description": "The user does not have access to create a project view", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/projects/{project}/views/{id}": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns a project view by its ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get one project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project View ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The project view", + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + }, + "403": { + "description": "The user does not have access to this project view", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Updates a project view.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Updates a project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project View ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "The project view with updated values you want to change.", + "name": "view", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + } + ], + "responses": { + "200": { + "description": "The updated project view.", + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + }, + "400": { + "description": "Invalid project view object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "delete": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Deletes a project view.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Delete a project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project View ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The project view was successfully deleted.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "403": { + "description": "The user does not have access to the project view", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/register": { "post": { "description": "Creates a new user account.", @@ -4261,6 +4537,64 @@ } } }, + "/tasks/{id}/position": { + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Updates a task position.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "task" + ], + "summary": "Updates a task position", + "parameters": [ + { + "type": "integer", + "description": "Task ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "The task position with updated values you want to change.", + "name": "view", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.TaskPosition" + } + } + ], + "responses": { + "200": { + "description": "The updated task position.", + "schema": { + "$ref": "#/definitions/models.TaskPosition" + } + }, + "400": { + "description": "Invalid task position object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/tasks/{taskID}/assignees": { "get": { "security": [ @@ -7446,8 +7780,8 @@ "description": "The position this bucket has when querying all buckets. See the tasks.position property on how to use this.", "type": "number" }, - "project_id": { - "description": "The project this bucket belongs to.", + "project_view_id": { + "description": "The project view this bucket belongs to.", "type": "integer" }, "tasks": { @@ -7468,6 +7802,19 @@ } } }, + "models.BucketConfigurationModeKind": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-varnames": [ + "BucketConfigurationModeNone", + "BucketConfigurationModeManual", + "BucketConfigurationModeFilter" + ] + }, "models.BulkAssignees": { "type": "object", "properties": { @@ -7498,7 +7845,7 @@ } }, "bucket_id": { - "description": "BucketID is the ID of the kanban bucket this task belongs to.", + "description": "The bucket id. Will only be populated when the task is accessed via a view with buckets.\nCan be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one.", "type": "integer" }, "cover_image_attachment_id": { @@ -7558,10 +7905,6 @@ "description": "True if a task is a favorite task. Favorite tasks show up in a separate \"Important\" project. This value depends on the user making the call to the api.", "type": "boolean" }, - "kanban_position": { - "description": "The position of tasks in the kanban board. See the docs for the `position` property on how to use this.", - "type": "number" - }, "labels": { "description": "An array of labels which are associated with this task.", "type": "array", @@ -7574,7 +7917,7 @@ "type": "number" }, "position": { - "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via kanban buckets, this is primarily used to sort them based on a range\nWe're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).\nYou would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2.\nA 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task\nwhich also leaves a lot of room for rearranging and sorting later.", + "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via views with buckets, this is primarily used to sort them based on a range.\nPositions are always saved per view. They will automatically be set if you request the tasks through a view\nendpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.", "type": "number" }, "priority": { @@ -7823,16 +8166,12 @@ "description": "A timestamp when this project was created. You cannot change this value.", "type": "string" }, - "default_bucket_id": { - "description": "The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a project.", - "type": "integer" - }, "description": { "description": "The description of the project.", "type": "string" }, "done_bucket_id": { - "description": "If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.", + "description": "Deprecated: If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.", "type": "integer" }, "hex_color": { @@ -7890,6 +8229,12 @@ "updated": { "description": "A timestamp when this project was last updated. You cannot change this value.", "type": "string" + }, + "views": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectView" + } } } }, @@ -7941,6 +8286,96 @@ } } }, + "models.ProjectView": { + "type": "object", + "properties": { + "bucket_configuration": { + "description": "When the bucket configuration mode is not `manual`, this field holds the options of that configuration.", + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectViewBucketConfiguration" + } + }, + "bucket_configuration_mode": { + "description": "The bucket configuration mode. Can be `none`, `manual` or `filter`. `manual` allows to move tasks between buckets as you normally would. `filter` creates buckets based on a filter for each bucket.", + "allOf": [ + { + "$ref": "#/definitions/models.BucketConfigurationModeKind" + } + ] + }, + "created": { + "description": "A timestamp when this reaction was created. You cannot change this value.", + "type": "string" + }, + "default_bucket_id": { + "description": "The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a view.", + "type": "integer" + }, + "done_bucket_id": { + "description": "If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.", + "type": "integer" + }, + "filter": { + "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.", + "type": "string" + }, + "id": { + "description": "The unique numeric id of this view", + "type": "integer" + }, + "position": { + "description": "The position of this view in the list. The list of all views will be sorted by this parameter.", + "type": "number" + }, + "project_id": { + "description": "The project this view belongs to", + "type": "integer" + }, + "title": { + "description": "The title of this view", + "type": "string" + }, + "updated": { + "description": "A timestamp when this view was updated. You cannot change this value.", + "type": "string" + }, + "view_kind": { + "description": "The kind of this view. Can be `list`, `gantt`, `table` or `kanban`.", + "allOf": [ + { + "$ref": "#/definitions/models.ProjectViewKind" + } + ] + } + } + }, + "models.ProjectViewBucketConfiguration": { + "type": "object", + "properties": { + "filter": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "models.ProjectViewKind": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-varnames": [ + "ProjectViewKindList", + "ProjectViewKindGantt", + "ProjectViewKindTable", + "ProjectViewKindKanban" + ] + }, "models.Reaction": { "type": "object", "properties": { @@ -8154,7 +8589,7 @@ } }, "bucket_id": { - "description": "BucketID is the ID of the kanban bucket this task belongs to.", + "description": "The bucket id. Will only be populated when the task is accessed via a view with buckets.\nCan be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one.", "type": "integer" }, "cover_image_attachment_id": { @@ -8214,10 +8649,6 @@ "description": "True if a task is a favorite task. Favorite tasks show up in a separate \"Important\" project. This value depends on the user making the call to the api.", "type": "boolean" }, - "kanban_position": { - "description": "The position of tasks in the kanban board. See the docs for the `position` property on how to use this.", - "type": "number" - }, "labels": { "description": "An array of labels which are associated with this task.", "type": "array", @@ -8230,7 +8661,7 @@ "type": "number" }, "position": { - "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via kanban buckets, this is primarily used to sort them based on a range\nWe're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).\nYou would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2.\nA 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task\nwhich also leaves a lot of room for rearranging and sorting later.", + "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via views with buckets, this is primarily used to sort them based on a range.\nPositions are always saved per view. They will automatically be set if you request the tasks through a view\nendpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.", "type": "number" }, "priority": { @@ -8334,7 +8765,7 @@ "type": "object", "properties": { "filter": { - "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", + "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.", "type": "string" }, "filter_include_nulls": { @@ -8380,6 +8811,23 @@ } } }, + "models.TaskPosition": { + "type": "object", + "properties": { + "position": { + "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via kanban buckets, this is primarily used to sort them based on a range\nWe're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).\nYou would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2.\nA 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task\nwhich also leaves a lot of room for rearranging and sorting later.\nPositions are always saved per view. They will automatically be set if you request the tasks through a view\nendpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.", + "type": "number" + }, + "project_view_id": { + "description": "The project view this task is related to", + "type": "integer" + }, + "task_id": { + "description": "The ID of the task this position is for", + "type": "integer" + } + } + }, "models.TaskRelation": { "type": "object", "properties": { diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index 9a9f34822..a077d6cf7 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -123,8 +123,8 @@ definitions: description: The position this bucket has when querying all buckets. See the tasks.position property on how to use this. type: number - project_id: - description: The project this bucket belongs to. + project_view_id: + description: The project view this bucket belongs to. type: integer tasks: description: All tasks which belong to this bucket. @@ -140,6 +140,16 @@ definitions: this value. type: string type: object + models.BucketConfigurationModeKind: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-varnames: + - BucketConfigurationModeNone + - BucketConfigurationModeManual + - BucketConfigurationModeFilter models.BulkAssignees: properties: assignees: @@ -161,7 +171,9 @@ definitions: $ref: '#/definitions/models.TaskAttachment' type: array bucket_id: - description: BucketID is the ID of the kanban bucket this task belongs to. + description: |- + The bucket id. Will only be populated when the task is accessed via a view with buckets. + Can be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one. type: integer cover_image_attachment_id: description: If this task has a cover image, the field will return the id @@ -209,10 +221,6 @@ definitions: a separate "Important" project. This value depends on the user making the call to the api. type: boolean - kanban_position: - description: The position of tasks in the kanban board. See the docs for the - `position` property on how to use this. - type: number labels: description: An array of labels which are associated with this task. items: @@ -224,11 +232,9 @@ definitions: position: description: |- The position of the task - any task project can be sorted as usual by this parameter. - When accessing tasks via kanban buckets, this is primarily used to sort them based on a range - We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number). - You would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2. - 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. + When accessing tasks via views with buckets, this is primarily used to sort them based on a range. + Positions are always saved per view. They will automatically be set if you request the tasks through a view + endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. type: number priority: description: The task priority. Can be anything you want, it is possible to @@ -422,16 +428,13 @@ definitions: description: A timestamp when this project was created. You cannot change this value. type: string - default_bucket_id: - description: The ID of the bucket where new tasks without a bucket are added - to. By default, this is the leftmost bucket in a project. - type: integer description: description: The description of the project. type: string done_bucket_id: - description: If tasks are moved to the done bucket, they are marked as done. - If they are marked as done individually, they are moved into the done bucket. + description: 'Deprecated: If tasks are moved to the done bucket, they are + marked as done. If they are marked as done individually, they are moved + into the done bucket.' type: integer hex_color: description: The hex color of this project @@ -478,6 +481,10 @@ definitions: description: A timestamp when this project was last updated. You cannot change this value. type: string + views: + items: + $ref: '#/definitions/models.ProjectView' + type: array type: object models.ProjectDuplicate: properties: @@ -513,6 +520,77 @@ definitions: description: The username. type: string type: object + models.ProjectView: + properties: + bucket_configuration: + description: When the bucket configuration mode is not `manual`, this field + holds the options of that configuration. + items: + $ref: '#/definitions/models.ProjectViewBucketConfiguration' + type: array + bucket_configuration_mode: + allOf: + - $ref: '#/definitions/models.BucketConfigurationModeKind' + description: The bucket configuration mode. Can be `none`, `manual` or `filter`. + `manual` allows to move tasks between buckets as you normally would. `filter` + creates buckets based on a filter for each bucket. + created: + description: A timestamp when this reaction was created. You cannot change + this value. + type: string + default_bucket_id: + description: The ID of the bucket where new tasks without a bucket are added + to. By default, this is the leftmost bucket in a view. + type: integer + done_bucket_id: + description: If tasks are moved to the done bucket, they are marked as done. + If they are marked as done individually, they are moved into the done bucket. + type: integer + filter: + description: The filter query to match tasks by. Check out https://vikunja.io/docs/filters + for a full explanation. + type: string + id: + description: The unique numeric id of this view + type: integer + position: + description: The position of this view in the list. The list of all views + will be sorted by this parameter. + type: number + project_id: + description: The project this view belongs to + type: integer + title: + description: The title of this view + type: string + updated: + description: A timestamp when this view was updated. You cannot change this + value. + type: string + view_kind: + allOf: + - $ref: '#/definitions/models.ProjectViewKind' + description: The kind of this view. Can be `list`, `gantt`, `table` or `kanban`. + type: object + models.ProjectViewBucketConfiguration: + properties: + filter: + type: string + title: + type: string + type: object + models.ProjectViewKind: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - ProjectViewKindList + - ProjectViewKindGantt + - ProjectViewKindTable + - ProjectViewKindKanban models.Reaction: properties: created: @@ -671,7 +749,9 @@ definitions: $ref: '#/definitions/models.TaskAttachment' type: array bucket_id: - description: BucketID is the ID of the kanban bucket this task belongs to. + description: |- + The bucket id. Will only be populated when the task is accessed via a view with buckets. + Can be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one. type: integer cover_image_attachment_id: description: If this task has a cover image, the field will return the id @@ -719,10 +799,6 @@ definitions: a separate "Important" project. This value depends on the user making the call to the api. type: boolean - kanban_position: - description: The position of tasks in the kanban board. See the docs for the - `position` property on how to use this. - type: number labels: description: An array of labels which are associated with this task. items: @@ -734,11 +810,9 @@ definitions: position: description: |- The position of the task - any task project can be sorted as usual by this parameter. - When accessing tasks via kanban buckets, this is primarily used to sort them based on a range - We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number). - You would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2. - 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. + When accessing tasks via views with buckets, this is primarily used to sort them based on a range. + Positions are always saved per view. They will automatically be set if you request the tasks through a view + endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. type: number priority: description: The task priority. Can be anything you want, it is possible to @@ -814,7 +888,7 @@ definitions: properties: filter: description: The filter query to match tasks by. Check out https://vikunja.io/docs/filters - for a full explanation of the feature. + for a full explanation. type: string filter_include_nulls: description: If set to true, the result will also include null values @@ -847,6 +921,26 @@ definitions: updated: type: string type: object + models.TaskPosition: + properties: + position: + description: |- + The position of the task - any task project can be sorted as usual by this parameter. + When accessing tasks via kanban buckets, this is primarily used to sort them based on a range + We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number). + You would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2. + 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. + Positions are always saved per view. They will automatically be set if you request the tasks through a view + endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. + type: number + project_view_id: + description: The project view this task is related to + type: integer + task_id: + description: The ID of the task this position is for + type: integer + type: object models.TaskRelation: properties: created: @@ -2850,107 +2944,6 @@ paths: summary: Upload a project background tags: - project - /projects/{id}/buckets: - get: - consumes: - - application/json - description: Returns all kanban buckets with belong to a project including their - tasks. Buckets are always sorted by their `position` in ascending order. Tasks - are sorted by their `kanban_position` in ascending order. - parameters: - - description: Project Id - in: path - name: id - required: true - type: integer - - description: The page number for tasks. Used for pagination. If not provided, - the first page of results is returned. - in: query - name: page - type: integer - - description: The maximum number of tasks per bucket per page. This parameter - is limited by the configured maximum of items per page. - in: query - name: per_page - type: integer - - description: Search tasks by task text. - in: query - name: s - type: string - - description: The filter query to match tasks by. Check out https://vikunja.io/docs/filters - for a full explanation of the feature. - in: query - name: filter - type: string - - description: 'The time zone which should be used for date match (statements - like ' - in: query - name: filter_timezone - type: string - - description: If set to true the result will include filtered fields whose - value is set to `null`. Available values are `true` or `false`. Defaults - to `false`. - in: query - name: filter_include_nulls - type: string - produces: - - application/json - responses: - "200": - description: The buckets with their tasks - schema: - items: - $ref: '#/definitions/models.Bucket' - type: array - "500": - description: Internal server error - schema: - $ref: '#/definitions/models.Message' - security: - - JWTKeyAuth: [] - summary: Get all kanban buckets of a project - tags: - - project - put: - consumes: - - application/json - description: Creates a new kanban bucket on a project. - parameters: - - description: Project Id - in: path - name: id - required: true - type: integer - - description: The bucket object - in: body - name: bucket - required: true - schema: - $ref: '#/definitions/models.Bucket' - produces: - - application/json - responses: - "200": - description: The created bucket object. - schema: - $ref: '#/definitions/models.Bucket' - "400": - description: Invalid bucket object provided. - schema: - $ref: '#/definitions/web.HTTPError' - "404": - description: The project does not exist. - schema: - $ref: '#/definitions/web.HTTPError' - "500": - description: Internal error - schema: - $ref: '#/definitions/models.Message' - security: - - JWTKeyAuth: [] - summary: Create a new bucket - tags: - - project /projects/{id}/projectusers: get: consumes: @@ -2994,78 +2987,6 @@ paths: tags: - project /projects/{id}/tasks: - get: - consumes: - - application/json - description: Returns all tasks for the current project. - parameters: - - description: The project ID. - in: path - name: id - required: true - type: integer - - description: The page number. Used for pagination. If not provided, the first - page of results is returned. - in: query - name: page - type: integer - - description: The maximum number of items per page. Note this parameter is - limited by the configured maximum of items per page. - in: query - name: per_page - type: integer - - description: Search tasks by task text. - in: query - name: s - type: string - - description: The sorting parameter. You can pass this multiple times to get - the tasks ordered by multiple different parametes, along with `order_by`. - Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, - `due_date`, `created_by_id`, `project_id`, `repeat_after`, `priority`, `start_date`, - `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default - is `id`. - in: query - name: sort_by - type: string - - description: The ordering parameter. Possible values to order by are `asc` - or `desc`. Default is `asc`. - in: query - name: order_by - type: string - - description: The filter query to match tasks by. Check out https://vikunja.io/docs/filters - for a full explanation of the feature. - in: query - name: filter - type: string - - description: 'The time zone which should be used for date match (statements - like ' - in: query - name: filter_timezone - type: string - - description: If set to true the result will include filtered fields whose - value is set to `null`. Available values are `true` or `false`. Defaults - to `false`. - in: query - name: filter_include_nulls - type: string - produces: - - application/json - responses: - "200": - description: The tasks - schema: - items: - $ref: '#/definitions/models.Task' - type: array - "500": - description: Internal error - schema: - $ref: '#/definitions/models.Message' - security: - - JWTKeyAuth: [] - summary: Get tasks in a project - tags: - - task put: consumes: - application/json @@ -3288,6 +3209,165 @@ paths: summary: Add a user to a project tags: - sharing + /projects/{id}/views/{view}/buckets: + get: + consumes: + - application/json + description: Returns all kanban buckets which belong to that project. Buckets + are always sorted by their `position` in ascending order. To get all buckets + with their tasks, use the tasks endpoint with a kanban view. + parameters: + - description: Project ID + in: path + name: id + required: true + type: integer + - description: Project view ID + in: path + name: view + required: true + type: integer + produces: + - application/json + responses: + "200": + description: The buckets + schema: + items: + $ref: '#/definitions/models.Bucket' + type: array + "500": + description: Internal server error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Get all kanban buckets of a project + tags: + - project + put: + consumes: + - application/json + description: Creates a new kanban bucket on a project. + parameters: + - description: Project Id + in: path + name: id + required: true + type: integer + - description: Project view ID + in: path + name: view + required: true + type: integer + - description: The bucket object + in: body + name: bucket + required: true + schema: + $ref: '#/definitions/models.Bucket' + produces: + - application/json + responses: + "200": + description: The created bucket object. + schema: + $ref: '#/definitions/models.Bucket' + "400": + description: Invalid bucket object provided. + schema: + $ref: '#/definitions/web.HTTPError' + "404": + description: The project does not exist. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Create a new bucket + tags: + - project + /projects/{id}/views/{view}/tasks: + get: + consumes: + - application/json + description: Returns all tasks for the current project. + parameters: + - description: The project ID. + in: path + name: id + required: true + type: integer + - description: The project view ID. + in: path + name: view + required: true + type: integer + - description: The page number. Used for pagination. If not provided, the first + page of results is returned. + in: query + name: page + type: integer + - description: The maximum number of items per page. Note this parameter is + limited by the configured maximum of items per page. + in: query + name: per_page + type: integer + - description: Search tasks by task text. + in: query + name: s + type: string + - description: The sorting parameter. You can pass this multiple times to get + the tasks ordered by multiple different parametes, along with `order_by`. + Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, + `due_date`, `created_by_id`, `project_id`, `repeat_after`, `priority`, `start_date`, + `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default + is `id`. + in: query + name: sort_by + type: string + - description: The ordering parameter. Possible values to order by are `asc` + or `desc`. Default is `asc`. + in: query + name: order_by + type: string + - description: The filter query to match tasks by. Check out https://vikunja.io/docs/filters + for a full explanation of the feature. + in: query + name: filter + type: string + - description: 'The time zone which should be used for date match (statements + like ' + in: query + name: filter_timezone + type: string + - description: If set to true the result will include filtered fields whose + value is set to `null`. Available values are `true` or `false`. Defaults + to `false`. + in: query + name: filter_include_nulls + type: string + produces: + - application/json + responses: + "200": + description: The tasks + schema: + items: + $ref: '#/definitions/models.Task' + type: array + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Get tasks in a project + tags: + - task /projects/{id}/webhooks: get: consumes: @@ -3604,32 +3684,60 @@ paths: summary: Get one link shares for a project tags: - sharing - /projects/{projectID}/buckets/{bucketID}: - delete: + /projects/{project}/views: + get: consumes: - application/json - description: Deletes an existing kanban bucket and dissociates all of its task. - It does not delete any tasks. You cannot delete the last bucket on a project. + description: Returns all project views for a sepcific project parameters: - - description: Project Id + - description: Project ID in: path - name: projectID - required: true - type: integer - - description: Bucket Id - in: path - name: bucketID + name: project required: true type: integer produces: - application/json responses: "200": - description: Successfully deleted. + description: The project views + schema: + items: + $ref: '#/definitions/models.ProjectView' + type: array + "500": + description: Internal error schema: $ref: '#/definitions/models.Message' - "404": - description: The bucket does not exist. + security: + - JWTKeyAuth: [] + summary: Get all project views for a project + tags: + - project + put: + consumes: + - application/json + description: Create a project view in a specific project. + parameters: + - description: Project ID + in: path + name: project + required: true + type: integer + - description: The project view you want to create. + in: body + name: view + required: true + schema: + $ref: '#/definitions/models.ProjectView' + produces: + - application/json + responses: + "200": + description: The created project view + schema: + $ref: '#/definitions/models.ProjectView' + "403": + description: The user does not have access to create a project view schema: $ref: '#/definitions/web.HTTPError' "500": @@ -3638,43 +3746,110 @@ paths: $ref: '#/definitions/models.Message' security: - JWTKeyAuth: [] - summary: Deletes an existing bucket + summary: Create a project view + tags: + - project + /projects/{project}/views/{id}: + delete: + consumes: + - application/json + description: Deletes a project view. + parameters: + - description: Project ID + in: path + name: project + required: true + type: integer + - description: Project View ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: The project view was successfully deleted. + schema: + $ref: '#/definitions/models.Message' + "403": + description: The user does not have access to the project view + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Delete a project view + tags: + - project + get: + consumes: + - application/json + description: Returns a project view by its ID. + parameters: + - description: Project ID + in: path + name: project + required: true + type: integer + - description: Project View ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: The project view + schema: + $ref: '#/definitions/models.ProjectView' + "403": + description: The user does not have access to this project view + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Get one project view tags: - project post: consumes: - application/json - description: Updates an existing kanban bucket. + description: Updates a project view. parameters: - - description: Project Id + - description: Project ID in: path - name: projectID + name: project required: true type: integer - - description: Bucket Id + - description: Project View ID in: path - name: bucketID + name: id required: true type: integer - - description: The bucket object + - description: The project view with updated values you want to change. in: body - name: bucket + name: view required: true schema: - $ref: '#/definitions/models.Bucket' + $ref: '#/definitions/models.ProjectView' produces: - application/json responses: "200": - description: The created bucket object. + description: The updated project view. schema: - $ref: '#/definitions/models.Bucket' + $ref: '#/definitions/models.ProjectView' "400": - description: Invalid bucket object provided. - schema: - $ref: '#/definitions/web.HTTPError' - "404": - description: The bucket does not exist. + description: Invalid project view object provided. schema: $ref: '#/definitions/web.HTTPError' "500": @@ -3683,7 +3858,7 @@ paths: $ref: '#/definitions/models.Message' security: - JWTKeyAuth: [] - summary: Update an existing bucket + summary: Updates a project view tags: - project /projects/{projectID}/duplicate: @@ -3900,6 +4075,98 @@ paths: summary: Update a user <-> project relation tags: - sharing + /projects/{projectID}/views/{view}/buckets/{bucketID}: + delete: + consumes: + - application/json + description: Deletes an existing kanban bucket and dissociates all of its task. + It does not delete any tasks. You cannot delete the last bucket on a project. + parameters: + - description: Project Id + in: path + name: projectID + required: true + type: integer + - description: Bucket Id + in: path + name: bucketID + required: true + type: integer + - description: Project view ID + in: path + name: view + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Successfully deleted. + schema: + $ref: '#/definitions/models.Message' + "404": + description: The bucket does not exist. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Deletes an existing bucket + tags: + - project + post: + consumes: + - application/json + description: Updates an existing kanban bucket. + parameters: + - description: Project Id + in: path + name: projectID + required: true + type: integer + - description: Bucket Id + in: path + name: bucketID + required: true + type: integer + - description: Project view ID + in: path + name: view + required: true + type: integer + - description: The bucket object + in: body + name: bucket + required: true + schema: + $ref: '#/definitions/models.Bucket' + produces: + - application/json + responses: + "200": + description: The created bucket object. + schema: + $ref: '#/definitions/models.Bucket' + "400": + description: Invalid bucket object provided. + schema: + $ref: '#/definitions/web.HTTPError' + "404": + description: The bucket does not exist. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Update an existing bucket + tags: + - project /register: post: consumes: @@ -4334,6 +4601,43 @@ paths: summary: Get one attachment. tags: - task + /tasks/{id}/position: + post: + consumes: + - application/json + description: Updates a task position. + parameters: + - description: Task ID + in: path + name: id + required: true + type: integer + - description: The task position with updated values you want to change. + in: body + name: view + required: true + schema: + $ref: '#/definitions/models.TaskPosition' + produces: + - application/json + responses: + "200": + description: The updated task position. + schema: + $ref: '#/definitions/models.TaskPosition' + "400": + description: Invalid task position object provided. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Updates a task position + tags: + - task /tasks/{task}/labels: get: consumes: -- 2.45.1 From 6913334b175d35ceeaa1fddf24d0d5c431c8724a Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 22:23:38 +0100 Subject: [PATCH 28/97] fix(views): correctly fetch project when fetching tasks --- pkg/models/project_view.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index 41b5f0736..9a8d12d01 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -287,6 +287,7 @@ func (p *ProjectView) Update(s *xorm.Session, _ web.Auth) (err error) { } func GetProjectViewByIDAndProject(s *xorm.Session, id, projectID int64) (view *ProjectView, err error) { + view = &ProjectView{} exists, err := s. Where("id = ? AND project_id = ?", id, projectID). NoAutoCondition(). -- 2.45.1 From 73e5483e87696df65dcc8b464fd53f048ad06b98 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 22:30:31 +0100 Subject: [PATCH 29/97] fix(views): do not break filters when combining them with view filters --- pkg/models/task_collection.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 700585713..f71b203e5 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -74,7 +74,7 @@ func validateTaskField(fieldName string) error { return ErrInvalidTaskField{TaskField: fieldName} } -func getTaskFilterOptsFromCollection(tf *TaskCollection) (opts *taskSearchOptions, err error) { +func getTaskFilterOptsFromCollection(tf *TaskCollection, projectView *ProjectView) (opts *taskSearchOptions, err error) { if len(tf.SortByArr) > 0 { tf.SortBy = append(tf.SortBy, tf.SortByArr...) } @@ -95,6 +95,10 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection) (opts *taskSearchOption param.orderBy = getSortOrderFromString(tf.OrderBy[i]) } + if s == taskPropertyPosition && projectView != nil { + param.projectViewID = projectView.ID + } + // Param validation if err := param.validate(); err != nil { return nil, err @@ -177,14 +181,16 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa return nil, 0, 0, err } - if tf.Filter != "" { - tf.Filter = "(" + tf.Filter + ") && (" + view.Filter + ")" - } else { - tf.Filter = view.Filter + if view.Filter != "" { + if tf.Filter != "" { + tf.Filter = "(" + tf.Filter + ") && (" + view.Filter + ")" + } else { + tf.Filter = view.Filter + } } } - opts, err := getTaskFilterOptsFromCollection(tf) + opts, err := getTaskFilterOptsFromCollection(tf, view) if err != nil { return nil, 0, 0, err } -- 2.45.1 From 86039b1dd2b553a980dfdde891c0b02190ff457c Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 22:35:59 +0100 Subject: [PATCH 30/97] fix(views): make gantt view load tasks again --- frontend/src/views/project/helpers/useGanttTaskList.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/project/helpers/useGanttTaskList.ts b/frontend/src/views/project/helpers/useGanttTaskList.ts index 8349b3c7c..506f24546 100644 --- a/frontend/src/views/project/helpers/useGanttTaskList.ts +++ b/frontend/src/views/project/helpers/useGanttTaskList.ts @@ -33,7 +33,7 @@ export function useGanttTaskList( params.filter_timezone = authStore.settings.timezone } - const tasks = await taskCollectionService.getAll({projectId: filters.value.projectId}, params, page) as ITask[] + const tasks = await taskCollectionService.getAll({projectId: filters.value.projectId, viewId: view.id}, params, page) as ITask[] if (loadAll && page < taskCollectionService.totalPages) { const nextTasks = await fetchTasks(params, page + 1) return tasks.concat(nextTasks) -- 2.45.1 From df415f97a9e854243edcd9159b85c2596b3cd68d Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 22:37:17 +0100 Subject: [PATCH 31/97] fix(views): make table view load tasks again --- frontend/src/views/project/ProjectTable.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/project/ProjectTable.vue b/frontend/src/views/project/ProjectTable.vue index 43ca5198c..8509d0668 100644 --- a/frontend/src/views/project/ProjectTable.vue +++ b/frontend/src/views/project/ProjectTable.vue @@ -323,7 +323,7 @@ const SORT_BY_DEFAULT: SortBy = { const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT}) const sortBy = useStorage('tableViewSortBy', {...SORT_BY_DEFAULT}) -const taskList = useTaskList(() => projectId, sortBy.value) +const taskList = useTaskList(() => projectId, () => view.id, sortBy.value) const { loading, -- 2.45.1 From cb111df2b78ced8d128e2cfa9217edecd79a4bd2 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 22:49:18 +0100 Subject: [PATCH 32/97] fix(views): make fetching tasks in kanban buckets through view actually work --- pkg/models/kanban.go | 2 +- pkg/models/project_view.go | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index facd2c70f..9cf024ea1 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -169,7 +169,7 @@ func GetTasksInBucketsForView(s *xorm.Session, view *ProjectView, opts *taskSear if view.BucketConfigurationMode == BucketConfigurationModeManual { err = s. - Where("project_id = ?", view.ProjectID). + Where("project_view_id = ?", view.ID). OrderBy("position"). Find(&buckets) if err != nil { diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index 9a8d12d01..11cb83567 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -358,10 +358,11 @@ func CreateDefaultViewsForProject(s *xorm.Session, project *Project, a web.Auth, } kanban := &ProjectView{ - ProjectID: project.ID, - Title: "Kanban", - ViewKind: ProjectViewKindKanban, - Position: 400, + ProjectID: project.ID, + Title: "Kanban", + ViewKind: ProjectViewKindKanban, + Position: 400, + BucketConfigurationMode: BucketConfigurationModeManual, } err = kanban.Create(s, a) if err != nil { -- 2.45.1 From ca0550aceacee7f2ed2948b33fc303c79901c234 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 22:49:37 +0100 Subject: [PATCH 33/97] fix(views): fetch buckets through view --- frontend/src/modelTypes/IBucket.ts | 2 ++ frontend/src/stores/kanban.ts | 8 ++++---- frontend/src/views/project/ProjectKanban.vue | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/modelTypes/IBucket.ts b/frontend/src/modelTypes/IBucket.ts index 0f7ce12da..dccd0d842 100644 --- a/frontend/src/modelTypes/IBucket.ts +++ b/frontend/src/modelTypes/IBucket.ts @@ -1,6 +1,7 @@ import type {IAbstract} from './IAbstract' import type {IUser} from './IUser' import type {ITask} from './ITask' +import type {IProjectView} from '@/modelTypes/IProjectView' export interface IBucket extends IAbstract { id: number @@ -10,6 +11,7 @@ export interface IBucket extends IAbstract { tasks: ITask[] position: number count: number + projectViewId: IProjectView['id'] createdBy: IUser created: Date diff --git a/frontend/src/stores/kanban.ts b/frontend/src/stores/kanban.ts index d55102f43..600150c18 100644 --- a/frontend/src/stores/kanban.ts +++ b/frontend/src/stores/kanban.ts @@ -226,15 +226,15 @@ export const useKanbanStore = defineStore('kanban', () => { allTasksLoadedForBucket.value[bucketId] = true } - async function loadBucketsForProject({projectId, params}: { projectId: IProject['id'], params }) { + async function loadBucketsForProject(projectId: IProject['id'], viewId: IProjectView['id'], params) { const cancel = setModuleLoading(setIsLoading) // Clear everything to prevent having old buckets in the project if loading the buckets from this project takes a few moments setBuckets([]) - const bucketService = new BucketService() + const taskCollectionService = new TaskCollectionService() try { - const newBuckets = await bucketService.getAll({projectId}, { + const newBuckets = await taskCollectionService.getAll({projectId, viewId}, { ...params, per_page: TASKS_PER_BUCKET, }) @@ -311,7 +311,7 @@ export const useKanbanStore = defineStore('kanban', () => { const response = await bucketService.delete(bucket) removeBucket(bucket) // We reload all buckets because tasks are being moved from the deleted bucket - loadBucketsForProject({projectId: bucket.projectId, params}) + loadBucketsForProject(bucket.projectId, bucket.projectViewId, params) return response } finally { cancel() diff --git a/frontend/src/views/project/ProjectKanban.vue b/frontend/src/views/project/ProjectKanban.vue index a436366ad..4f928bcb7 100644 --- a/frontend/src/views/project/ProjectKanban.vue +++ b/frontend/src/views/project/ProjectKanban.vue @@ -396,13 +396,14 @@ watch( () => ({ params: params.value, projectId, + viewId: view.id, }), ({params}) => { if (projectId === undefined || Number(projectId) === 0) { return } collapsedBuckets.value = getCollapsedBucketState(projectId) - kanbanStore.loadBucketsForProject({projectId, params}) + kanbanStore.loadBucketsForProject(projectId, view.id, params) }, { immediate: true, -- 2.45.1 From 398c9f10565f1823ee9fa39132ebc1d38fa9ce32 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 23:31:28 +0100 Subject: [PATCH 34/97] fix(views): return tasks in their buckets --- pkg/models/error.go | 18 ++++++++-------- pkg/models/kanban.go | 6 +++++- pkg/models/task_search.go | 24 ++++++++++++++++------ pkg/models/tasks.go | 43 ++++++++++++++++++--------------------- 4 files changed, 52 insertions(+), 39 deletions(-) diff --git a/pkg/models/error.go b/pkg/models/error.go index e63f6fc00..99a18349f 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -1527,27 +1527,27 @@ func (err ErrBucketDoesNotExist) HTTPError() web.HTTPError { } } -// ErrBucketDoesNotBelongToProject represents an error where a kanban bucket does not belong to a project -type ErrBucketDoesNotBelongToProject struct { - BucketID int64 - ProjectID int64 +// ErrBucketDoesNotBelongToProjectView represents an error where a kanban bucket does not belong to a project +type ErrBucketDoesNotBelongToProjectView struct { + BucketID int64 + ProjectViewID int64 } -// IsErrBucketDoesNotBelongToProject checks if an error is ErrBucketDoesNotBelongToProject. +// IsErrBucketDoesNotBelongToProject checks if an error is ErrBucketDoesNotBelongToProjectView. func IsErrBucketDoesNotBelongToProject(err error) bool { - _, ok := err.(ErrBucketDoesNotBelongToProject) + _, ok := err.(ErrBucketDoesNotBelongToProjectView) return ok } -func (err ErrBucketDoesNotBelongToProject) Error() string { - return fmt.Sprintf("Bucket does not not belong to project [BucketID: %d, ProjectID: %d]", err.BucketID, err.ProjectID) +func (err ErrBucketDoesNotBelongToProjectView) Error() string { + return fmt.Sprintf("Bucket does not not belong to project view [BucketID: %d, ProjectViewID: %d]", err.BucketID, err.ProjectViewID) } // ErrCodeBucketDoesNotBelongToProject holds the unique world-error code of this error const ErrCodeBucketDoesNotBelongToProject = 10002 // HTTPError holds the http error description -func (err ErrBucketDoesNotBelongToProject) HTTPError() web.HTTPError { +func (err ErrBucketDoesNotBelongToProjectView) HTTPError() web.HTTPError { return web.HTTPError{ HTTPCode: http.StatusBadRequest, Code: ErrCodeBucketDoesNotBelongToProject, diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index 9cf024ea1..dc9d38a13 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -213,7 +213,7 @@ func GetTasksInBucketsForView(s *xorm.Session, view *ProjectView, opts *taskSear opts.sortby = []*sortParam{ { - projectViewID: view.ProjectID, + projectViewID: view.ID, orderBy: orderAscending, sortBy: taskPropertyPosition, }, @@ -260,6 +260,10 @@ func GetTasksInBucketsForView(s *xorm.Session, view *ProjectView, opts *taskSear return nil, err } + for _, t := range ts { + t.BucketID = bucket.ID + } + bucket.Count = total tasks = append(tasks, ts...) diff --git a/pkg/models/task_search.go b/pkg/models/task_search.go index 221721328..ba84af68a 100644 --- a/pkg/models/task_search.go +++ b/pkg/models/task_search.go @@ -209,6 +209,14 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo return nil, 0, err } + var joinTaskBuckets bool + for _, filter := range opts.parsedFilters { + if filter.field == taskPropertyBucketID { + joinTaskBuckets = true + break + } + } + filterCond, err := convertFiltersToDBFilterCond(opts.parsedFilters, opts.filterIncludeNulls) if err != nil { return nil, 0, err @@ -265,20 +273,24 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo } } + if joinTaskBuckets { + query = query.Join("LEFT", "task_buckets", "task_buckets.task_id = tasks.id") + } + tasks = []*Task{} - err = query.OrderBy(orderby).Find(&tasks) + err = query. + OrderBy(orderby). + Find(&tasks) if err != nil { return nil, totalCount, err } queryCount := d.s.Where(cond) + if joinTaskBuckets { + queryCount = queryCount.Join("LEFT", "task_buckets", "task_buckets.task_id = tasks.id") + } totalCount, err = queryCount. Count(&Task{}) - if err != nil { - return nil, totalCount, err - - } - return } diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 074086e64..7dbd88f6a 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -114,7 +114,7 @@ type Task struct { // The bucket id. Will only be populated when the task is accessed via a view with buckets. // Can be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one. - BucketID int64 `xorm:"bigint null" json:"bucket_id"` + BucketID int64 `xorm:"<-" json:"bucket_id"` // The position of the task - any task project can be sorted as usual by this parameter. // When accessing tasks via views with buckets, this is primarily used to sort them based on a range. @@ -204,6 +204,9 @@ func (t *Task) ReadAll(_ *xorm.Session, _ web.Auth, _ string, _ int, _ int) (res func getFilterCond(f *taskFilter, includeNulls bool) (cond builder.Cond, err error) { field := "`" + f.field + "`" + if f.field == taskPropertyBucketID { + field = "task_buckets.`bucket_id`" + } switch f.comparator { case taskFilterComparatorEquals: cond = &builder.Eq{field: f.value} @@ -633,15 +636,16 @@ func checkBucketLimit(s *xorm.Session, t *Task, bucket *Bucket) (err error) { } // Contains all the task logic to figure out what bucket to use for this task. -func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, view *ProjectView, targetBucket *TaskBucket) (err error) { +func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, view *ProjectView, targetBucketID int64) (err error) { + if view.BucketConfigurationMode == BucketConfigurationModeNone { + return + } var shouldChangeBucket = true - if targetBucket == nil { - targetBucket = &TaskBucket{ - BucketID: 0, - TaskID: task.ID, - ProjectViewID: view.ID, - } + targetBucket := &TaskBucket{ + BucketID: targetBucketID, + TaskID: task.ID, + ProjectViewID: view.ID, } oldTaskBucket := &TaskBucket{} @@ -678,15 +682,12 @@ func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, view *Projec } // If there is a bucket set, make sure they belong to the same project as the task - if task.ProjectID != bucket.ProjectID { - return ErrBucketDoesNotBelongToProject{ - ProjectID: task.ProjectID, - BucketID: bucket.ID, + if view.ID != bucket.ProjectViewID { + return ErrBucketDoesNotBelongToProjectView{ + ProjectViewID: view.ID, + BucketID: bucket.ID, } } - if err != nil { - return - } // Check the bucket limit // Only check the bucket limit if the task is being moved between buckets, allow reordering the task within a bucket @@ -811,7 +812,7 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err for _, view := range views { // Get the default bucket and move the task there - err = setTaskBucket(s, t, nil, view, &TaskBucket{}) + err = setTaskBucket(s, t, nil, view, 0) if err != nil { return } @@ -913,20 +914,16 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { Find(&buckets) for _, view := range views { - taskBucket := &TaskBucket{ - TaskID: t.ID, - ProjectViewID: view.ID, - } - // Only update the bucket when the current view + var targetBucketID int64 if t.BucketID != 0 { bucket, has := buckets[t.BucketID] if has && bucket.ProjectViewID == view.ID { - taskBucket.BucketID = t.BucketID + targetBucketID = t.BucketID } } - err = setTaskBucket(s, t, &ot, view, taskBucket) + err = setTaskBucket(s, t, &ot, view, targetBucketID) if err != nil { return err } -- 2.45.1 From dee78be57922e3fddc498689d49aa8c74da2dc96 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 23:36:31 +0100 Subject: [PATCH 35/97] fix(views): return buckets when fetching tasks via kanban view --- frontend/src/services/taskCollection.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/services/taskCollection.ts b/frontend/src/services/taskCollection.ts index 99f8c8ace..4b3812236 100644 --- a/frontend/src/services/taskCollection.ts +++ b/frontend/src/services/taskCollection.ts @@ -2,6 +2,7 @@ import AbstractService from '@/services/abstractService' import TaskModel from '@/models/task' import type {ITask} from '@/modelTypes/ITask' +import BucketModel from '@/models/bucket' export interface TaskFilterParams { sort_by: ('start_date' | 'end_date' | 'due_date' | 'done' | 'id' | 'position' | 'kanban_position')[], @@ -32,6 +33,9 @@ export default class TaskCollectionService extends AbstractService { } modelFactory(data) { + if (typeof data.project_view_id !== 'undefined') { + return new BucketModel(data) + } return new TaskModel(data) } } \ No newline at end of file -- 2.45.1 From c36fdb9f5de927dd23372dd275198b196d84d224 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 15 Mar 2024 23:42:32 +0100 Subject: [PATCH 36/97] chore(views): add fixme --- frontend/src/services/taskCollection.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/services/taskCollection.ts b/frontend/src/services/taskCollection.ts index 4b3812236..3304227f1 100644 --- a/frontend/src/services/taskCollection.ts +++ b/frontend/src/services/taskCollection.ts @@ -33,6 +33,7 @@ export default class TaskCollectionService extends AbstractService { } modelFactory(data) { + // FIXME: There must be a better way for this… if (typeof data.project_view_id !== 'undefined') { return new BucketModel(data) } -- 2.45.1 From 786e67f692a23ed041eed0ec23750eccd8d22d99 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sat, 16 Mar 2024 11:36:37 +0100 Subject: [PATCH 37/97] feat(views): save task position --- frontend/src/modelTypes/ITaskPosition.ts | 8 ++++++++ frontend/src/models/taskPosition.ts | 13 ++++++++++++ frontend/src/services/taskPosition.ts | 15 ++++++++++++++ frontend/src/views/project/ProjectKanban.vue | 21 +++++++++++++++++--- pkg/models/project_view.go | 1 + pkg/models/task_position.go | 5 ++++- 6 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 frontend/src/modelTypes/ITaskPosition.ts create mode 100644 frontend/src/models/taskPosition.ts create mode 100644 frontend/src/services/taskPosition.ts diff --git a/frontend/src/modelTypes/ITaskPosition.ts b/frontend/src/modelTypes/ITaskPosition.ts new file mode 100644 index 000000000..7ea71e1c0 --- /dev/null +++ b/frontend/src/modelTypes/ITaskPosition.ts @@ -0,0 +1,8 @@ +import type {IProjectView} from '@/modelTypes/IProjectView' +import type {IAbstract} from '@/modelTypes/IAbstract' + +export interface ITaskPosition extends IAbstract { + position: number + projectViewId: IProjectView['id'] + taskId: number +} \ No newline at end of file diff --git a/frontend/src/models/taskPosition.ts b/frontend/src/models/taskPosition.ts new file mode 100644 index 000000000..e1c37ae0b --- /dev/null +++ b/frontend/src/models/taskPosition.ts @@ -0,0 +1,13 @@ +import AbstractModel from '@/models/abstractModel' +import type {ITaskPosition} from '@/modelTypes/ITaskPosition' + +export default class TaskPositionModel extends AbstractModel implements ITaskPosition { + position = 0 + projectViewId = 0 + taskId = 0 + + constructor(data: Partial) { + super() + this.assignData(data) + } +} diff --git a/frontend/src/services/taskPosition.ts b/frontend/src/services/taskPosition.ts new file mode 100644 index 000000000..c74a8038a --- /dev/null +++ b/frontend/src/services/taskPosition.ts @@ -0,0 +1,15 @@ +import AbstractService from '@/services/abstractService' +import type {ITaskPosition} from '@/modelTypes/ITaskPosition' +import TaskPositionModel from '@/models/taskPosition' + +export default class TaskPositionService extends AbstractService { + constructor() { + super({ + update: '/tasks/{taskId}/position', + }) + } + + modelFactory(data: Partial) { + return new TaskPositionModel(data) + } +} \ No newline at end of file diff --git a/frontend/src/views/project/ProjectKanban.vue b/frontend/src/views/project/ProjectKanban.vue index 4f928bcb7..a627cf578 100644 --- a/frontend/src/views/project/ProjectKanban.vue +++ b/frontend/src/views/project/ProjectKanban.vue @@ -302,6 +302,8 @@ import {success} from '@/message' import {useProjectStore} from '@/stores/projects' import type {TaskFilterParams} from '@/services/taskCollection' import type {IProjectView} from '@/modelTypes/IProjectView' +import TaskPositionService from '@/services/taskPosition' +import TaskPositionModel from '@/models/taskPosition' const { projectId = undefined, @@ -328,6 +330,7 @@ const baseStore = useBaseStore() const kanbanStore = useKanbanStore() const taskStore = useTaskStore() const projectStore = useProjectStore() +const taskPositionService = ref(new TaskPositionService()) const taskContainerRefs = ref<{ [id: IBucket['id']]: HTMLElement }>({}) const bucketLimitInputRef = ref(null) @@ -390,7 +393,7 @@ const project = computed(() => projectId ? projectStore.projects[projectId] : nu const buckets = computed(() => kanbanStore.buckets) const loading = computed(() => kanbanStore.isLoading) -const taskLoading = computed(() => taskStore.isLoading) +const taskLoading = computed(() => taskStore.isLoading || taskPositionService.value.loading) watch( () => ({ @@ -478,7 +481,7 @@ async function updateTaskPosition(e) { const newTask = klona(task) // cloning the task to avoid pinia store manipulation newTask.bucketId = newBucket.id - newTask.kanbanPosition = calculateItemPosition( + const position = calculateItemPosition( taskBefore !== null ? taskBefore.kanbanPosition : null, taskAfter !== null ? taskAfter.kanbanPosition : null, ) @@ -488,6 +491,8 @@ async function updateTaskPosition(e) { ) { newTask.done = project.value?.doneBucketId === newBucket.id } + + let bucketHasChanged = false if ( oldBucket !== undefined && // This shouldn't actually be `undefined`, but let's play it safe. newBucket.id !== oldBucket.id @@ -500,10 +505,20 @@ async function updateTaskPosition(e) { ...newBucket, count: newBucket.count + 1, }) + bucketHasChanged = true } try { - await taskStore.update(newTask) + const newPosition = new TaskPositionModel({ + position, + projectViewId: view.id, + taskId: newTask.id, + }) + await taskPositionService.value.update(newPosition) + + if(bucketHasChanged) { + await taskStore.update(newTask) + } // Make sure the first and second task don't both get position 0 assigned if (newTaskIndex === 0 && taskAfter !== null && taskAfter.kanbanPosition === 0) { diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index 11cb83567..30415cdd5 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -306,6 +306,7 @@ func GetProjectViewByIDAndProject(s *xorm.Session, id, projectID int64) (view *P } func GetProjectViewByID(s *xorm.Session, id int64) (view *ProjectView, err error) { + view = &ProjectView{} exists, err := s. Where("id = ?", id). NoAutoCondition(). diff --git a/pkg/models/task_position.go b/pkg/models/task_position.go index c56141ae9..1994e0f3a 100644 --- a/pkg/models/task_position.go +++ b/pkg/models/task_position.go @@ -46,7 +46,10 @@ func (tp *TaskPosition) TableName() string { } func (tp *TaskPosition) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { - pv := &ProjectView{ID: tp.ProjectViewID} + pv, err := GetProjectViewByID(s, tp.ProjectViewID) + if err != nil { + return false, err + } return pv.CanUpdate(s, a) } -- 2.45.1 From f364f3bec8b0fa9e731506344703a00c9080db38 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sat, 16 Mar 2024 11:48:50 +0100 Subject: [PATCH 38/97] feat(views): return position when retriving tasks --- pkg/models/export.go | 2 +- pkg/models/kanban.go | 2 +- pkg/models/project_duplicate.go | 2 +- pkg/models/task_collection.go | 4 ++-- pkg/models/task_position.go | 8 ++++++++ pkg/models/tasks.go | 26 +++++++++++++++++++++----- pkg/models/typesense.go | 2 +- 7 files changed, 35 insertions(+), 11 deletions(-) diff --git a/pkg/models/export.go b/pkg/models/export.go index 05fe5a178..99a1d88c2 100644 --- a/pkg/models/export.go +++ b/pkg/models/export.go @@ -158,7 +158,7 @@ func exportProjectsAndTasks(s *xorm.Session, u *user.User, wr *zip.Writer) (task tasks, _, _, err := getTasksForProjects(s, rawProjects, u, &taskSearchOptions{ page: 0, perPage: -1, - }) + }, nil) if err != nil { return taskIDs, err } diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index dc9d38a13..371cc5b09 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -274,7 +274,7 @@ func GetTasksInBucketsForView(s *xorm.Session, view *ProjectView, opts *taskSear taskMap[t.ID] = t } - err = addMoreInfoToTasks(s, taskMap, auth) + err = addMoreInfoToTasks(s, taskMap, auth, view) if err != nil { return nil, err } diff --git a/pkg/models/project_duplicate.go b/pkg/models/project_duplicate.go index ee6882b63..03062f9f8 100644 --- a/pkg/models/project_duplicate.go +++ b/pkg/models/project_duplicate.go @@ -223,7 +223,7 @@ func duplicateProjectBackground(s *xorm.Session, pd *ProjectDuplicate, doer web. func duplicateTasks(s *xorm.Session, doer web.Auth, ld *ProjectDuplicate, bucketMap map[int64]int64) (err error) { // Get all tasks + all task details - tasks, _, _, err := getTasksForProjects(s, []*Project{{ID: ld.ProjectID}}, doer, &taskSearchOptions{}) + tasks, _, _, err := getTasksForProjects(s, []*Project{{ID: ld.ProjectID}}, doer, &taskSearchOptions{}, nil) if err != nil { return err } diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index f71b203e5..3573c5dc4 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -213,7 +213,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa if err != nil { return nil, 0, 0, err } - return getTasksForProjects(s, []*Project{project}, a, opts) + return getTasksForProjects(s, []*Project{project}, a, opts, view) } // If the project ID is not set, we get all tasks for the user. @@ -253,5 +253,5 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa } } - return getTasksForProjects(s, projects, a, opts) + return getTasksForProjects(s, projects, a, opts, view) } diff --git a/pkg/models/task_position.go b/pkg/models/task_position.go index 1994e0f3a..2c0a3a8de 100644 --- a/pkg/models/task_position.go +++ b/pkg/models/task_position.go @@ -134,3 +134,11 @@ func RecalculateTaskPositions(s *xorm.Session, view *ProjectView) (err error) { _, err = s.Insert(newPositions) return } + +func getPositionsForView(s *xorm.Session, view *ProjectView) (positions []*TaskPosition, err error) { + positions = []*TaskPosition{} + err = s. + Where("project_view_id = ?", view.ID). + Find(&positions) + return +} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 7dbd88f6a..b9fca067f 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -303,7 +303,7 @@ func getRawTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, op return tasks, len(tasks), totalItems, err } -func getTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts *taskSearchOptions) (tasks []*Task, resultCount int, totalItems int64, err error) { +func getTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts *taskSearchOptions, view *ProjectView) (tasks []*Task, resultCount int, totalItems int64, err error) { tasks, resultCount, totalItems, err = getRawTasksForProjects(s, projects, a, opts) if err != nil { @@ -315,7 +315,7 @@ func getTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts taskMap[t.ID] = t } - err = addMoreInfoToTasks(s, taskMap, a) + err = addMoreInfoToTasks(s, taskMap, a, view) if err != nil { return nil, 0, 0, err } @@ -392,7 +392,7 @@ func GetTasksByUIDs(s *xorm.Session, uids []string, a web.Auth) (tasks []*Task, taskMap[t.ID] = t } - err = addMoreInfoToTasks(s, taskMap, a) + err = addMoreInfoToTasks(s, taskMap, a, nil) return } @@ -533,7 +533,7 @@ func addRelatedTasksToTasks(s *xorm.Session, taskIDs []int64, taskMap map[int64] // 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 -func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth) (err error) { +func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth, view *ProjectView) (err error) { // No need to iterate over users and stuff if the project doesn't have tasks if len(taskMap) == 0 { @@ -591,6 +591,17 @@ func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth) (e return } + var positionsMap = make(map[int64]*TaskPosition) + if view != nil { + positions, err := getPositionsForView(s, view) + if err != nil { + return err + } + for _, position := range positions { + positionsMap[position.TaskID] = position + } + } + // Add all objects to their tasks for _, task := range taskMap { @@ -612,6 +623,11 @@ func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth) (e if has { task.Reactions = r } + + p, has := positionsMap[task.ID] + if has { + task.Position = p.Position + } } // Get all related tasks @@ -1487,7 +1503,7 @@ func (t *Task) ReadOne(s *xorm.Session, a web.Auth) (err error) { taskMap := make(map[int64]*Task, 1) taskMap[t.ID] = t - err = addMoreInfoToTasks(s, taskMap, a) + err = addMoreInfoToTasks(s, taskMap, a, nil) if err != nil { return } diff --git a/pkg/models/typesense.go b/pkg/models/typesense.go index 6f9097cd5..b876ff9cc 100644 --- a/pkg/models/typesense.go +++ b/pkg/models/typesense.go @@ -291,7 +291,7 @@ func reindexTasksInTypesense(s *xorm.Session, tasks map[int64]*Task) (err error) return } - err = addMoreInfoToTasks(s, tasks, &user.User{ID: 1}) + err = addMoreInfoToTasks(s, tasks, &user.User{ID: 1}, nil) if err != nil { return fmt.Errorf("could not fetch more task info: %s", err.Error()) } -- 2.45.1 From 4170f5468f1439418ff949e51ea67cdd648eac21 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sat, 16 Mar 2024 11:54:59 +0100 Subject: [PATCH 39/97] feat(views): save task position in list view --- frontend/src/views/project/ProjectList.vue | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/frontend/src/views/project/ProjectList.vue b/frontend/src/views/project/ProjectList.vue index a23f04091..31cd453b8 100644 --- a/frontend/src/views/project/ProjectList.vue +++ b/frontend/src/views/project/ProjectList.vue @@ -73,7 +73,7 @@ > @@ -118,6 +118,8 @@ import {useTaskStore} from '@/stores/tasks' import type {IProject} from '@/modelTypes/IProject' import type {IProjectView} from '@/modelTypes/IProjectView' +import TaskPositionService from '@/services/taskPosition' +import TaskPositionModel from '@/models/taskPosition' const { projectId, @@ -145,6 +147,8 @@ const { sortByParam, } = useTaskList(() => projectId, () => view.id, {position: 'asc'}) +const taskPositionService = ref(new TaskPositionService()) + const tasks = ref([]) watch( allTasks, @@ -234,13 +238,17 @@ async function saveTaskPosition(e) { const taskBefore = tasks.value[e.newIndex - 1] ?? null const taskAfter = tasks.value[e.newIndex + 1] ?? null - const newTask = { - ...task, - position: calculateItemPosition(taskBefore !== null ? taskBefore.position : null, taskAfter !== null ? taskAfter.position : null), - } + const position = calculateItemPosition(taskBefore !== null ? taskBefore.position : null, taskAfter !== null ? taskAfter.position : null) - const updatedTask = await taskStore.update(newTask) - tasks.value[e.newIndex] = updatedTask + await taskPositionService.value.update(new TaskPositionModel({ + position, + projectViewId: view.id, + taskId: task.id, + })) + tasks.value[e.newIndex] = { + ...task, + position, + } } function prepareFiltersAndLoadTasks() { -- 2.45.1 From a3714c74fdcea8ddfac1aff7a3b2262d855177d2 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sat, 16 Mar 2024 12:31:10 +0100 Subject: [PATCH 40/97] feat(views): load views when navigating with link share --- .../src/components/home/contentLinkShare.vue | 4 +++ .../src/components/sharing/linkSharing.vue | 28 ++++++++----------- frontend/src/types/ProjectView.ts | 8 ------ .../src/views/sharing/LinkSharingAuth.vue | 12 ++++---- 4 files changed, 21 insertions(+), 31 deletions(-) delete mode 100644 frontend/src/types/ProjectView.ts diff --git a/frontend/src/components/home/contentLinkShare.vue b/frontend/src/components/home/contentLinkShare.vue index b21ee1ea5..95cf3dc15 100644 --- a/frontend/src/components/home/contentLinkShare.vue +++ b/frontend/src/components/home/contentLinkShare.vue @@ -33,11 +33,15 @@ import {useBaseStore} from '@/stores/base' import Logo from '@/components/home/Logo.vue' import PoweredByLink from './PoweredByLink.vue' +import {useProjectStore} from '@/stores/projects' const baseStore = useBaseStore() const currentProject = computed(() => baseStore.currentProject) const background = computed(() => baseStore.background) const logoVisible = computed(() => baseStore.logoVisible) + +const projectStore = useProjectStore() +projectStore.loadAllProjects() \ No newline at end of file diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index b9dcefc4c..9d39969c0 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -381,6 +381,22 @@ "secret": "Secret", "secretHint": "If provided, all requests to the webhook target URL will be signed using HMAC.", "secretDocs": "Check out the docs for more details about how to use secrets." + }, + "views": { + "header": "Edit views", + "title": "Title", + "actions": "Actions", + "kind": "Kind", + "bucketConfigMode": "Bucket configuration mode", + "bucketConfig": "Bucket configuration", + "bucketConfigManual": "Manual", + "filter": "Filter", + "create": "Create view", + "createSuccess": "The view was created successfully.", + "titleRequired": "Please provide a title.", + "delete": "Delete this view", + "deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!", + "deleteSuccess": "The view was successfully deleted" } }, "filters": { @@ -1049,7 +1065,8 @@ "newProject": "New project", "createProject": "Create project", "cantArchiveIsDefault": "You cannot archive this because it is your default project.", - "cantDeleteIsDefault": "You cannot delete this because it is your default project." + "cantDeleteIsDefault": "You cannot delete this because it is your default project.", + "views": "Views" }, "apiConfig": { "url": "Vikunja URL", diff --git a/frontend/src/modelTypes/IProjectView.ts b/frontend/src/modelTypes/IProjectView.ts index 9d38ef6b1..6a003b8ff 100644 --- a/frontend/src/modelTypes/IProjectView.ts +++ b/frontend/src/modelTypes/IProjectView.ts @@ -1,24 +1,31 @@ import type {IAbstract} from './IAbstract' -import type {ITask} from './ITask' -import type {IUser} from './IUser' -import type {ISubscription} from './ISubscription' import type {IProject} from '@/modelTypes/IProject' +export const PROJECT_VIEW_KINDS = ['list', 'gantt', 'table', 'kanban'] +export type ProjectViewKind = typeof PROJECT_VIEW_KINDS[number] + +export const PROJECT_VIEW_BUCKET_CONFIGURATION_MODES = ['none', 'manual', 'filter'] +export type ProjectViewBucketConfigurationMode = typeof PROJECT_VIEW_BUCKET_CONFIGURATION_MODES[number] + +export interface IProjectViewBucketConfiguration { + title: string + filter: string +} export interface IProjectView extends IAbstract { id: number title: string projectId: IProject['id'] - viewKind: 'list' | 'gantt' | 'table' | 'kanban' - - fitler: string + viewKind: ProjectViewKind + + filter: string position: number - - bucketConfigurationMode: 'none' | 'manual' | 'filter' - bucketConfiguration: object + + bucketConfigurationMode: ProjectViewBucketConfigurationMode + bucketConfiguration: IProjectViewBucketConfiguration[] defaultBucketId: number doneBucketId: number - + created: Date updated: Date } \ No newline at end of file diff --git a/frontend/src/models/project.ts b/frontend/src/models/project.ts index 145262dc3..06c9e8ee7 100644 --- a/frontend/src/models/project.ts +++ b/frontend/src/models/project.ts @@ -7,6 +7,7 @@ import type {IProject} from '@/modelTypes/IProject' import type {IUser} from '@/modelTypes/IUser' import type {ITask} from '@/modelTypes/ITask' import type {ISubscription} from '@/modelTypes/ISubscription' +import ProjectViewModel from '@/models/projectView' export default class ProjectModel extends AbstractModel implements IProject { id = 0 @@ -25,6 +26,7 @@ export default class ProjectModel extends AbstractModel implements IPr parentProjectId = 0 doneBucketId = 0 defaultBucketId = 0 + views = [] created: Date = null updated: Date = null @@ -48,6 +50,8 @@ export default class ProjectModel extends AbstractModel implements IPr this.subscription = new SubscriptionModel(this.subscription) } + this.views = this.views.map(v => new ProjectViewModel(v)) + this.created = new Date(this.created) this.updated = new Date(this.updated) } diff --git a/frontend/src/models/projectView.ts b/frontend/src/models/projectView.ts new file mode 100644 index 000000000..736810c90 --- /dev/null +++ b/frontend/src/models/projectView.ts @@ -0,0 +1,30 @@ +import type {IProjectView, ProjectViewBucketConfigurationMode, ProjectViewKind} from '@/modelTypes/IProjectView' +import AbstractModel from '@/models/abstractModel' + +export default class ProjectViewModel extends AbstractModel implements IProjectView { + id = 0 + title = '' + projectId = 0 + viewKind: ProjectViewKind = 'list' + + filter = '' + position = 0 + + bucketConfiguration = [] + bucketConfigurationMode: ProjectViewBucketConfigurationMode = 'manual' + defaultBucketId = 0 + doneBucketId = 0 + + created: Date = new Date() + updated: Date = new Date() + + constructor(data: Partial) { + super() + this.assignData(data) + + + if (!this.bucketConfiguration) { + this.bucketConfiguration = [] + } + } +} \ No newline at end of file diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index a3a4a89f9..d85d2dec8 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -44,6 +44,7 @@ const ProjectSettingShare = () => import('@/views/project/settings/share.vue') const ProjectSettingWebhooks = () => import('@/views/project/settings/webhooks.vue') const ProjectSettingDelete = () => import('@/views/project/settings/delete.vue') const ProjectSettingArchive = () => import('@/views/project/settings/archive.vue') +const ProjectSettingViews = () => import('@/views/project/settings/views.vue') // Saved Filters const FilterNew = () => import('@/views/filters/FilterNew.vue') @@ -306,6 +307,15 @@ const router = createRouter({ showAsModal: true, }, }, + { + path: '/projects/:projectId/settings/views', + name: 'project.settings.views', + component: ProjectSettingViews, + meta: { + showAsModal: true, + }, + props: route => ({ projectId: Number(route.params.projectId as string) }), + }, { path: '/projects/:projectId/settings/edit', name: 'filter.settings.edit', diff --git a/frontend/src/services/projectViews.ts b/frontend/src/services/projectViews.ts new file mode 100644 index 000000000..a35d0f325 --- /dev/null +++ b/frontend/src/services/projectViews.ts @@ -0,0 +1,20 @@ +import AbstractService from '@/services/abstractService' +import type {IAbstract} from '@/modelTypes/IAbstract' +import ProjectViewModel from '@/models/projectView' +import type {IProjectView} from '@/modelTypes/IProjectView' + +export default class ProjectViewService extends AbstractService { + constructor() { + super({ + get: '/projects/{projectId}/views/{id}', + getAll: '/projects/{projectId}/views', + create: '/projects/{projectId}/views', + update: '/projects/{projectId}/views/{id}', + delete: '/projects/{projectId}/views/{id}', + }) + } + + modelFactory(data: Partial): ProjectViewModel { + return new ProjectViewModel(data) + } +} diff --git a/frontend/src/stores/projects.ts b/frontend/src/stores/projects.ts index 7a1992407..4448a657e 100644 --- a/frontend/src/stores/projects.ts +++ b/frontend/src/stores/projects.ts @@ -18,6 +18,7 @@ import ProjectModel from '@/models/project' import {success} from '@/message' import {useBaseStore} from '@/stores/base' import {getSavedFilterIdFromProjectId} from '@/services/savedFilter' +import type {IProjectView} from '@/modelTypes/IProjectView' const {add, remove, search, update} = createNewIndexer('projects', ['title', 'description']) @@ -210,7 +211,24 @@ export const useProjectStore = defineStore('project', () => { project, ] } - + + function setProjectView(view: IProjectView) { + const viewPos = projects.value[view.projectId].views.findIndex(v => v.id === view.id) + if (viewPos !== -1) { + projects.value[view.projectId].views[viewPos] = view + return + } + + projects.value[view.projectId].views.push(view) + } + + function removeProjectView(projectId: IProject['id'], viewId: IProjectView['id']) { + const viewPos = projects.value[projectId].views.findIndex(v => v.id === viewId) + if (viewPos !== -1) { + projects.value[projectId].views.splice(viewPos, 1) + } + } + return { isLoading: readonly(isLoading), projects: readonly(projects), @@ -235,6 +253,8 @@ export const useProjectStore = defineStore('project', () => { updateProject, deleteProject, getAncestors, + setProjectView, + removeProjectView, } }) diff --git a/frontend/src/views/project/settings/views.vue b/frontend/src/views/project/settings/views.vue new file mode 100644 index 000000000..64babf196 --- /dev/null +++ b/frontend/src/views/project/settings/views.vue @@ -0,0 +1,173 @@ + + + + + \ No newline at end of file -- 2.45.1 From 24fa3b206fa5c3bb1c9b9eeae23ba41ee042d499 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 18 Mar 2024 14:00:48 +0100 Subject: [PATCH 61/97] fix(views): create view --- pkg/models/project_view.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index c2eabc21e..4ad9fc01b 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -257,8 +257,7 @@ func (p *ProjectView) Delete(s *xorm.Session, a web.Auth) (err error) { // @Failure 500 {object} models.Message "Internal error" // @Router /projects/{project}/views [put] func (p *ProjectView) Create(s *xorm.Session, a web.Auth) (err error) { - _, err = s.Insert(p) - return + return createProjectView(s, p, a, true) } func createProjectView(s *xorm.Session, p *ProjectView, a web.Auth, createBacklogBucket bool) (err error) { -- 2.45.1 From 3f8c5a5feb6ee9409a701c0ba0e74f4d26dda7c4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 18 Mar 2024 14:07:08 +0100 Subject: [PATCH 62/97] fix(views): set correct default view --- frontend/src/models/projectView.ts | 1 - frontend/src/views/project/settings/views.vue | 7 +++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/models/projectView.ts b/frontend/src/models/projectView.ts index 736810c90..559f94818 100644 --- a/frontend/src/models/projectView.ts +++ b/frontend/src/models/projectView.ts @@ -22,7 +22,6 @@ export default class ProjectViewModel extends AbstractModel implem super() this.assignData(data) - if (!this.bucketConfiguration) { this.bucketConfiguration = [] } diff --git a/frontend/src/views/project/settings/views.vue b/frontend/src/views/project/settings/views.vue index 64babf196..07fe02d00 100644 --- a/frontend/src/views/project/settings/views.vue +++ b/frontend/src/views/project/settings/views.vue @@ -39,7 +39,11 @@ async function createView() { } try { + newView.value.bucketConfigurationMode = newView.value.viewKind === 'kanban' + ? newView.value.bucketConfigurationMode + : 'none' newView.value.projectId = projectId + const result: IProjectView = await projectViewService.value.create(newView.value) success({message: t('project.views.createSuccess')}) showCreateForm.value = false @@ -66,6 +70,9 @@ async function deleteView() { } async function saveView() { + if (viewToEdit.value?.viewKind !== 'kanban') { + viewToEdit.value.bucketConfigurationMode = 'none' + } const result = await projectViewService.value.update(viewToEdit.value) projectStore.setProjectView(result) viewToEdit.value = null -- 2.45.1 From bec9e3eb7d1b92a8aa647b9409852aeac5d32197 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 18 Mar 2024 14:21:52 +0100 Subject: [PATCH 63/97] fix(views): set current project after modifying views --- frontend/src/stores/projects.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/stores/projects.ts b/frontend/src/stores/projects.ts index 4448a657e..e6b436e6a 100644 --- a/frontend/src/stores/projects.ts +++ b/frontend/src/stores/projects.ts @@ -216,10 +216,13 @@ export const useProjectStore = defineStore('project', () => { const viewPos = projects.value[view.projectId].views.findIndex(v => v.id === view.id) if (viewPos !== -1) { projects.value[view.projectId].views[viewPos] = view + setProject(projects.value[view.projectId]) return } projects.value[view.projectId].views.push(view) + + setProject(projects.value[view.projectId]) } function removeProjectView(projectId: IProject['id'], viewId: IProjectView['id']) { -- 2.45.1 From 0f60a928739ff214b79804b4ba0ce8a95c2b2ab0 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 18 Mar 2024 20:59:30 +0100 Subject: [PATCH 64/97] fix(views): make kanban tests work again --- pkg/models/kanban_test.go | 84 ++++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/pkg/models/kanban_test.go b/pkg/models/kanban_test.go index c997d9d18..6fe4c7cd0 100644 --- a/pkg/models/kanban_test.go +++ b/pkg/models/kanban_test.go @@ -35,7 +35,10 @@ func TestBucket_ReadAll(t *testing.T) { defer s.Close() testuser := &user.User{ID: 1} - b := &Bucket{ProjectID: 1} + b := &TaskCollection{ + ProjectViewID: 4, + ProjectID: 1, + } bucketsInterface, _, _, err := b.ReadAll(s, testuser, "", 0, 0) require.NoError(t, err) @@ -78,11 +81,10 @@ func TestBucket_ReadAll(t *testing.T) { defer s.Close() testuser := &user.User{ID: 1} - b := &Bucket{ - ProjectID: 1, - TaskCollection: TaskCollection{ - Filter: "title ~ 'done'", - }, + b := &TaskCollection{ + ProjectViewID: 4, + ProjectID: 1, + Filter: "title ~ 'done'", } bucketsInterface, _, _, err := b.ReadAll(s, testuser, "", -1, 0) require.NoError(t, err) @@ -98,11 +100,10 @@ func TestBucket_ReadAll(t *testing.T) { defer s.Close() testuser := &user.User{ID: 1} - b := &Bucket{ - ProjectID: 1, - TaskCollection: TaskCollection{ - Filter: "title ~ 'task' && bucket_id = 2", - }, + b := &TaskCollection{ + ProjectViewID: 4, + ProjectID: 1, + Filter: "title ~ 'task' && bucket_id = 2", } bucketsInterface, _, _, err := b.ReadAll(s, testuser, "", -1, 0) require.NoError(t, err) @@ -126,7 +127,10 @@ func TestBucket_ReadAll(t *testing.T) { ProjectID: 1, Right: RightRead, } - b := &Bucket{ProjectID: 1} + b := &TaskCollection{ + ProjectID: 1, + ProjectViewID: 4, + } result, _, _, err := b.ReadAll(s, linkShare, "", 0, 0) require.NoError(t, err) buckets, _ := result.([]*Bucket) @@ -140,7 +144,10 @@ func TestBucket_ReadAll(t *testing.T) { defer s.Close() testuser := &user.User{ID: 12} - b := &Bucket{ProjectID: 23} + b := &TaskCollection{ + ProjectID: 23, + ProjectViewID: 92, + } result, _, _, err := b.ReadAll(s, testuser, "", 0, 0) require.NoError(t, err) buckets, _ := result.([]*Bucket) @@ -151,7 +158,7 @@ func TestBucket_ReadAll(t *testing.T) { } func TestBucket_Delete(t *testing.T) { - user := &user.User{ID: 1} + u := &user.User{ID: 1} t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) @@ -159,22 +166,23 @@ func TestBucket_Delete(t *testing.T) { defer s.Close() b := &Bucket{ - ID: 2, // The second bucket only has 3 tasks - ProjectID: 1, + ID: 2, // The second bucket only has 3 tasks + ProjectID: 1, + ProjectViewID: 4, } - err := b.Delete(s, user) + err := b.Delete(s, u) require.NoError(t, err) err = s.Commit() require.NoError(t, err) // Assert all tasks have been moved to bucket 1 as that one is the first - tasks := []*Task{} + tasks := []*TaskBucket{} err = s.Where("bucket_id = ?", 1).Find(&tasks) require.NoError(t, err) assert.Len(t, tasks, 15) db.AssertMissing(t, "buckets", map[string]interface{}{ - "id": 2, - "project_id": 1, + "id": 2, + "project_view_id": 4, }) }) t.Run("last bucket in project", func(t *testing.T) { @@ -183,18 +191,19 @@ func TestBucket_Delete(t *testing.T) { defer s.Close() b := &Bucket{ - ID: 34, - ProjectID: 18, + ID: 34, + ProjectID: 18, + ProjectViewID: 72, } - err := b.Delete(s, user) + err := b.Delete(s, u) require.Error(t, err) assert.True(t, IsErrCannotRemoveLastBucket(err)) err = s.Commit() require.NoError(t, err) db.AssertExists(t, "buckets", map[string]interface{}{ - "id": 34, - "project_id": 18, + "id": 34, + "project_view_id": 72, }, false) }) t.Run("done bucket should be reset", func(t *testing.T) { @@ -203,14 +212,15 @@ func TestBucket_Delete(t *testing.T) { defer s.Close() b := &Bucket{ - ID: 3, - ProjectID: 1, + ID: 3, + ProjectID: 1, + ProjectViewID: 4, } - err := b.Delete(s, user) + err := b.Delete(s, u) require.NoError(t, err) - db.AssertMissing(t, "projects", map[string]interface{}{ - "id": 1, + db.AssertMissing(t, "project_views", map[string]interface{}{ + "id": 4, "done_bucket_id": 3, }) }) @@ -238,9 +248,10 @@ func TestBucket_Update(t *testing.T) { defer s.Close() b := &Bucket{ - ID: 1, - Title: "New Name", - Limit: 2, + ID: 1, + Title: "New Name", + Limit: 2, + ProjectViewID: 4, } testAndAssertBucketUpdate(t, b, s) @@ -251,9 +262,10 @@ func TestBucket_Update(t *testing.T) { defer s.Close() b := &Bucket{ - ID: 1, - Title: "testbucket1", - Limit: 0, + ID: 1, + Title: "testbucket1", + Limit: 0, + ProjectViewID: 4, } testAndAssertBucketUpdate(t, b, s) -- 2.45.1 From 9cc273d9bdd50acc267399de3ea5b1760501275e Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 18 Mar 2024 20:59:49 +0100 Subject: [PATCH 65/97] fix(views): move all tasks to the default bucket when deleting a bucket --- pkg/models/kanban.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index fc9988f8e..c5e769ed2 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -412,7 +412,7 @@ func (b *Bucket) Delete(s *xorm.Session, a web.Auth) (err error) { _, err = s. Where("bucket_id = ?", b.ID). Cols("bucket_id"). - Update(&Task{BucketID: defaultBucketID}) + Update(&TaskBucket{BucketID: defaultBucketID}) if err != nil { return } -- 2.45.1 From d4bdd2d4e81bfcff672dc5bb6cfd35c421538369 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 18 Mar 2024 23:08:14 +0100 Subject: [PATCH 66/97] fix(views): duplicate all views and related entities when duplicating a project --- pkg/models/project.go | 12 +- pkg/models/project_duplicate.go | 157 ++++++++++++++++++++------- pkg/models/project_duplicate_test.go | 6 +- pkg/models/tasks.go | 22 ++-- 4 files changed, 139 insertions(+), 58 deletions(-) diff --git a/pkg/models/project.go b/pkg/models/project.go index 1311b6eaa..677ed34ff 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -738,7 +738,7 @@ func checkProjectBeforeUpdateOrDelete(s *xorm.Session, project *Project) (err er return nil } -func CreateProject(s *xorm.Session, project *Project, auth web.Auth, createBacklogBucket bool) (err error) { +func CreateProject(s *xorm.Session, project *Project, auth web.Auth, createBacklogBucket bool, createDefaultViews bool) (err error) { err = project.CheckIsArchived(s) if err != nil { return err @@ -775,9 +775,11 @@ func CreateProject(s *xorm.Session, project *Project, auth web.Auth, createBackl } } - err = CreateDefaultViewsForProject(s, project, auth, createBacklogBucket) - if err != nil { - return + if createDefaultViews { + err = CreateDefaultViewsForProject(s, project, auth, createBacklogBucket) + if err != nil { + return + } } return events.Dispatch(&ProjectCreatedEvent{ @@ -987,7 +989,7 @@ func updateProjectByTaskID(s *xorm.Session, taskID int64) (err error) { // @Failure 500 {object} models.Message "Internal error" // @Router /projects [put] func (p *Project) Create(s *xorm.Session, a web.Auth) (err error) { - err = CreateProject(s, p, a, true) + err = CreateProject(s, p, a, true, true) if err != nil { return } diff --git a/pkg/models/project_duplicate.go b/pkg/models/project_duplicate.go index 03062f9f8..1cfeede51 100644 --- a/pkg/models/project_duplicate.go +++ b/pkg/models/project_duplicate.go @@ -81,7 +81,8 @@ func (pd *ProjectDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) { pd.Project.ParentProjectID = pd.ParentProjectID // Set the owner to the current user pd.Project.OwnerID = doer.GetID() - if err := CreateProject(s, pd.Project, doer, false); err != nil { + err = CreateProject(s, pd.Project, doer, false, false) + if err != nil { // If there is no available unique project identifier, just reset it. if IsErrProjectIdentifierIsNotUnique(err) { pd.Project.Identifier = "" @@ -92,32 +93,20 @@ func (pd *ProjectDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) { log.Debugf("Duplicated project %d into new project %d", pd.ProjectID, pd.Project.ID) - // Duplicate kanban buckets - // Old bucket ID as key, new id as value - // Used to map the newly created tasks to their new buckets - bucketMap := make(map[int64]int64) - buckets := []*Bucket{} - err = s.Where("project_id = ?", pd.ProjectID).Find(&buckets) + newTaskIDs, err := duplicateTasks(s, doer, pd) if err != nil { return } - for _, b := range buckets { - oldID := b.ID - b.ID = 0 - b.ProjectID = pd.Project.ID - if err := b.Create(s, doer); err != nil { - return err - } - bucketMap[oldID] = b.ID - } - log.Debugf("Duplicated all buckets from project %d into %d", pd.ProjectID, pd.Project.ID) + log.Debugf("Duplicated all tasks from project %d into %d", pd.ProjectID, pd.Project.ID) - err = duplicateTasks(s, doer, pd, bucketMap) + err = duplicateViews(s, pd, doer, newTaskIDs) if err != nil { return } + log.Debugf("Duplicated all views, buckets and positions from project %d into %d", pd.ProjectID, pd.Project.ID) + err = duplicateProjectBackground(s, pd, doer) if err != nil { return @@ -173,6 +162,93 @@ func (pd *ProjectDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) { return } +func duplicateViews(s *xorm.Session, pd *ProjectDuplicate, doer web.Auth, taskMap map[int64]int64) (err error) { + // Duplicate Views + views := make(map[int64]*ProjectView) + err = s.Where("project_id = ?", pd.ProjectID).Find(&views) + if err != nil { + return + } + + oldViewIDs := []int64{} + viewMap := make(map[int64]int64) + for _, view := range views { + oldID := view.ID + oldViewIDs = append(oldViewIDs, oldID) + + view.ID = 0 + view.ProjectID = pd.Project.ID + err = view.Create(s, doer) + if err != nil { + return + } + + viewMap[oldID] = view.ID + } + + buckets := []*Bucket{} + err = s.In("project_view_id", oldViewIDs).Find(&buckets) + if err != nil { + return + } + + // Old bucket ID as key, new id as value + // Used to map the newly created tasks to their new buckets + bucketMap := make(map[int64]int64) + + oldBucketIDs := []int64{} + for _, b := range buckets { + oldID := b.ID + oldBucketIDs = append(oldBucketIDs, oldID) + + b.ID = 0 + b.ProjectID = pd.Project.ID + + err = b.Create(s, doer) + if err != nil { + return err + } + + bucketMap[oldID] = b.ID + } + + oldTaskBuckets := []*TaskBucket{} + err = s.In("bucket_id", oldBucketIDs).Find(&oldTaskBuckets) + if err != nil { + return err + } + + taskBuckets := []*TaskBucket{} + for _, tb := range oldTaskBuckets { + taskBuckets = append(taskBuckets, &TaskBucket{ + BucketID: bucketMap[tb.BucketID], + TaskID: taskMap[tb.TaskID], + }) + } + + _, err = s.Insert(&taskBuckets) + if err != nil { + return err + } + + oldTaskPositions := []*TaskPosition{} + err = s.In("project_view_id", oldViewIDs).Find(&oldTaskPositions) + if err != nil { + return + } + + taskPositions := []*TaskPosition{} + for _, tp := range oldTaskPositions { + taskPositions = append(taskPositions, &TaskPosition{ + ProjectViewID: viewMap[tp.ProjectViewID], + TaskID: taskMap[tp.TaskID], + Position: tp.Position, + }) + } + + return +} + func duplicateProjectBackground(s *xorm.Session, pd *ProjectDuplicate, doer web.Auth) (err error) { if pd.Project.BackgroundFileID == 0 { return @@ -221,33 +297,32 @@ func duplicateProjectBackground(s *xorm.Session, pd *ProjectDuplicate, doer web. return } -func duplicateTasks(s *xorm.Session, doer web.Auth, ld *ProjectDuplicate, bucketMap map[int64]int64) (err error) { +func duplicateTasks(s *xorm.Session, doer web.Auth, ld *ProjectDuplicate) (newTaskIDs map[int64]int64, err error) { // Get all tasks + all task details tasks, _, _, err := getTasksForProjects(s, []*Project{{ID: ld.ProjectID}}, doer, &taskSearchOptions{}, nil) if err != nil { - return err + return nil, err } if len(tasks) == 0 { - return nil + return } // This map contains the old task id as key and the new duplicated task id as value. // It is used to map old task items to new ones. - taskMap := make(map[int64]int64) + newTaskIDs = make(map[int64]int64, len(tasks)) // Create + update all tasks (includes reminders) oldTaskIDs := make([]int64, 0, len(tasks)) for _, t := range tasks { oldID := t.ID t.ID = 0 t.ProjectID = ld.Project.ID - t.BucketID = bucketMap[t.BucketID] t.UID = "" - err := createTask(s, t, doer, false) + err = createTask(s, t, doer, false, false) if err != nil { - return err + return nil, err } - taskMap[oldID] = t.ID + newTaskIDs[oldID] = t.ID oldTaskIDs = append(oldTaskIDs, oldID) } @@ -258,14 +333,14 @@ func duplicateTasks(s *xorm.Session, doer web.Auth, ld *ProjectDuplicate, bucket // file changes in the other project which is not something we want. attachments, err := getTaskAttachmentsByTaskIDs(s, oldTaskIDs) if err != nil { - return err + return nil, err } for _, attachment := range attachments { oldAttachmentID := attachment.ID attachment.ID = 0 var exists bool - attachment.TaskID, exists = taskMap[attachment.TaskID] + attachment.TaskID, exists = newTaskIDs[attachment.TaskID] if !exists { log.Debugf("Error duplicating attachment %d from old task %d to new task: Old task <-> new task does not seem to exist.", oldAttachmentID, attachment.TaskID) continue @@ -276,15 +351,15 @@ func duplicateTasks(s *xorm.Session, doer web.Auth, ld *ProjectDuplicate, bucket log.Debugf("Not duplicating attachment %d (file %d) because it does not exist from project %d into %d", oldAttachmentID, attachment.FileID, ld.ProjectID, ld.Project.ID) continue } - return err + return nil, err } if err := attachment.File.LoadFileByID(); err != nil { - return err + return nil, err } err := attachment.NewAttachment(s, attachment.File.File, attachment.File.Name, attachment.File.Size, doer) if err != nil { - return err + return nil, err } if attachment.File.File != nil { @@ -305,9 +380,9 @@ func duplicateTasks(s *xorm.Session, doer web.Auth, ld *ProjectDuplicate, bucket for _, lt := range labelTasks { lt.ID = 0 - lt.TaskID = taskMap[lt.TaskID] + lt.TaskID = newTaskIDs[lt.TaskID] if _, err := s.Insert(lt); err != nil { - return err + return nil, err } } @@ -322,14 +397,14 @@ func duplicateTasks(s *xorm.Session, doer web.Auth, ld *ProjectDuplicate, bucket } for _, a := range assignees { t := &Task{ - ID: taskMap[a.TaskID], + ID: newTaskIDs[a.TaskID], ProjectID: ld.Project.ID, } if err := t.addNewAssigneeByID(s, a.UserID, ld.Project, doer); err != nil { if IsErrUserDoesNotHaveAccessToProject(err) { continue } - return err + return nil, err } } @@ -343,9 +418,9 @@ func duplicateTasks(s *xorm.Session, doer web.Auth, ld *ProjectDuplicate, bucket } for _, c := range comments { c.ID = 0 - c.TaskID = taskMap[c.TaskID] + c.TaskID = newTaskIDs[c.TaskID] if _, err := s.Insert(c); err != nil { - return err + return nil, err } } @@ -360,19 +435,19 @@ func duplicateTasks(s *xorm.Session, doer web.Auth, ld *ProjectDuplicate, bucket return } for _, r := range relations { - otherTaskID, exists := taskMap[r.OtherTaskID] + otherTaskID, exists := newTaskIDs[r.OtherTaskID] if !exists { continue } r.ID = 0 r.OtherTaskID = otherTaskID - r.TaskID = taskMap[r.TaskID] + r.TaskID = newTaskIDs[r.TaskID] if _, err := s.Insert(r); err != nil { - return err + return nil, err } } log.Debugf("Duplicated all task relations from project %d into %d", ld.ProjectID, ld.Project.ID) - return nil + return } diff --git a/pkg/models/project_duplicate_test.go b/pkg/models/project_duplicate_test.go index 0f3957791..aa16d960a 100644 --- a/pkg/models/project_duplicate_test.go +++ b/pkg/models/project_duplicate_test.go @@ -48,11 +48,11 @@ func TestProjectDuplicate(t *testing.T) { require.NoError(t, err) // assert the new project has the same number of buckets as the old one - numberOfOriginalBuckets, err := s.Where("project_id = ?", l.ProjectID).Count(&Bucket{}) + numberOfOriginalViews, err := s.Where("project_id = ?", l.ProjectID).Count(&ProjectView{}) require.NoError(t, err) - numberOfDuplicatedBuckets, err := s.Where("project_id = ?", l.Project.ID).Count(&Bucket{}) + numberOfDuplicatedViews, err := s.Where("project_id = ?", l.Project.ID).Count(&ProjectView{}) require.NoError(t, err) - assert.Equal(t, numberOfOriginalBuckets, numberOfDuplicatedBuckets, "duplicated project does not have the same amount of buckets as the original one") + assert.Equal(t, numberOfOriginalViews, numberOfDuplicatedViews, "duplicated project does not have the same amount of views as the original one") // To make this test 100% useful, it would need to assert a lot more stuff, but it is good enough for now. // Also, we're lacking utility functions to do all needed assertions. diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 514020b6e..1823d165f 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -776,10 +776,10 @@ func getNextTaskIndex(s *xorm.Session, projectID int64) (nextIndex int64, err er // @Failure 500 {object} models.Message "Internal error" // @Router /projects/{id}/tasks [put] func (t *Task) Create(s *xorm.Session, a web.Auth) (err error) { - return createTask(s, t, a, true) + return createTask(s, t, a, true, true) } -func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err error) { +func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool, updateBucket bool) (err error) { t.ID = 0 @@ -827,10 +827,12 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err for _, view := range views { - // Get the default bucket and move the task there - err = setTaskBucket(s, t, nil, view, 0) - if err != nil { - return + if updateBucket { + // Get the default bucket and move the task there + err = setTaskBucket(s, t, nil, view, t.BucketID) + if err != nil { + return + } } positions = append(positions, &TaskPosition{ @@ -840,9 +842,11 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err }) } - _, err = s.Insert(&positions) - if err != nil { - return + if updateBucket { + _, err = s.Insert(&positions) + if err != nil { + return + } } t.CreatedBy = createdBy -- 2.45.1 From 9075a45cb86945527f11d732a13f5d502ed6a406 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 18 Mar 2024 23:09:02 +0100 Subject: [PATCH 67/97] fix(views): update test fixtures for new structure --- pkg/db/fixtures/buckets.yml | 80 +-- pkg/db/fixtures/project_views.yml | 954 +++++++++++++++++++++++++++++ pkg/db/fixtures/projects.yml | 4 - pkg/db/fixtures/task_buckets.yml | 138 +++++ pkg/db/fixtures/task_positions.yml | 138 +++++ pkg/db/fixtures/tasks.yml | 54 +- pkg/models/unit_tests.go | 3 + 7 files changed, 1274 insertions(+), 97 deletions(-) create mode 100644 pkg/db/fixtures/project_views.yml create mode 100644 pkg/db/fixtures/task_buckets.yml create mode 100644 pkg/db/fixtures/task_positions.yml diff --git a/pkg/db/fixtures/buckets.yml b/pkg/db/fixtures/buckets.yml index 4d565ae91..d5b23f4c0 100644 --- a/pkg/db/fixtures/buckets.yml +++ b/pkg/db/fixtures/buckets.yml @@ -1,6 +1,6 @@ - id: 1 title: testbucket1 - project_id: 1 + project_view_id: 4 created_by_id: 1 limit: 9999999 # This bucket has a limit we will never exceed in the tests to make sure the logic allows for buckets with limits position: 1 @@ -8,7 +8,7 @@ updated: 2020-04-18 21:13:52 - id: 2 title: testbucket2 - project_id: 1 + project_view_id: 4 created_by_id: 1 limit: 3 position: 2 @@ -16,14 +16,14 @@ updated: 2020-04-18 21:13:52 - id: 3 title: testbucket3 - project_id: 1 + project_view_id: 4 created_by_id: 1 position: 3 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 4 title: testbucket4 - other project - project_id: 2 + project_view_id: 8 created_by_id: 1 position: 1 created: 2020-04-18 21:13:52 @@ -31,221 +31,221 @@ # The following are not or only partly owned by user 1 - id: 5 title: testbucket5 - project_id: 20 + project_view_id: 80 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 6 title: testbucket6 - project_id: 6 + project_view_id: 24 created_by_id: 1 position: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 7 title: testbucket7 - project_id: 7 + project_view_id: 28 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 8 title: testbucket8 - project_id: 8 + project_view_id: 32 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 9 title: testbucket9 - project_id: 9 + project_view_id: 36 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 10 title: testbucket10 - project_id: 10 + project_view_id: 40 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 11 title: testbucket11 - project_id: 11 + project_view_id: 44 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 12 title: testbucket13 - project_id: 12 + project_view_id: 48 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 13 title: testbucket13 - project_id: 13 + project_view_id: 52 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 14 title: testbucket14 - project_id: 14 + project_view_id: 56 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 15 title: testbucket15 - project_id: 15 + project_view_id: 60 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 16 title: testbucket16 - project_id: 16 + project_view_id: 64 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 17 title: testbucket17 - project_id: 17 + project_view_id: 68 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 18 title: testbucket18 - project_id: 5 + project_view_id: 20 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 19 title: testbucket19 - project_id: 21 + project_view_id: 84 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 20 title: testbucket20 - project_id: 22 + project_view_id: 88 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 21 title: testbucket21 - project_id: 3 + project_view_id: 12 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 # Duplicate buckets to make deletion of one of them possible - id: 22 title: testbucket22 - project_id: 6 + project_view_id: 24 created_by_id: 1 position: 2 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 23 title: testbucket23 - project_id: 7 + project_view_id: 28 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 24 title: testbucket24 - project_id: 8 + project_view_id: 32 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 25 title: testbucket25 - project_id: 9 + project_view_id: 36 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 26 title: testbucket26 - project_id: 10 + project_view_id: 40 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 27 title: testbucket27 - project_id: 11 + project_view_id: 44 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 28 title: testbucket28 - project_id: 12 + project_view_id: 48 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 29 title: testbucket29 - project_id: 13 + project_view_id: 52 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 30 title: testbucket30 - project_id: 14 + project_view_id: 56 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 31 title: testbucket31 - project_id: 15 + project_view_id: 60 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 32 title: testbucket32 - project_id: 16 + project_view_id: 64 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 33 title: testbucket33 - project_id: 17 + project_view_id: 68 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 # This bucket is the last one in its project - id: 34 title: testbucket34 - project_id: 18 + project_view_id: 72 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 35 title: testbucket35 - project_id: 23 + project_view_id: 92 created_by_id: -2 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 36 title: testbucket36 - project_id: 33 + project_view_id: 132 created_by_id: 6 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 37 title: testbucket37 - project_id: 34 + project_view_id: 136 created_by_id: 6 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 38 title: testbucket36 - project_id: 36 + project_view_id: 144 created_by_id: 15 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 39 title: testbucket38 - project_id: 38 + project_view_id: 152 created_by_id: 15 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 40 title: testbucket40 - project_id: 2 + project_view_id: 8 created_by_id: 1 position: 10 created: 2020-04-18 21:13:52 diff --git a/pkg/db/fixtures/project_views.yml b/pkg/db/fixtures/project_views.yml new file mode 100644 index 000000000..09df1ae04 --- /dev/null +++ b/pkg/db/fixtures/project_views.yml @@ -0,0 +1,954 @@ +- id: 1 + title: List + project_id: 1 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 2 + title: Gantt + project_id: 1 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 3 + title: Table + project_id: 1 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 4 + title: Kanban + project_id: 1 + view_kind: 3 + done_bucket_id: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 5 + title: List + project_id: 2 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 6 + title: Gantt + project_id: 2 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 7 + title: Table + project_id: 2 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 8 + title: Kanban + project_id: 2 + view_kind: 3 + done_bucket_id: 4 + default_bucket_id: 40 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 9 + title: List + project_id: 3 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 10 + title: Gantt + project_id: 3 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 11 + title: Table + project_id: 3 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 12 + title: Kanban + project_id: 3 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 13 + title: List + project_id: 4 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 14 + title: Gantt + project_id: 4 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 15 + title: Table + project_id: 4 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 16 + title: Kanban + project_id: 4 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 17 + title: List + project_id: 5 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 18 + title: Gantt + project_id: 5 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 19 + title: Table + project_id: 5 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 20 + title: Kanban + project_id: 5 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 21 + title: List + project_id: 6 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 22 + title: Gantt + project_id: 6 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 23 + title: Table + project_id: 6 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 24 + title: Kanban + project_id: 6 + view_kind: 3 + default_bucket_id: 22 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 25 + title: List + project_id: 7 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 26 + title: Gantt + project_id: 7 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 27 + title: Table + project_id: 7 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 28 + title: Kanban + project_id: 7 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 29 + title: List + project_id: 8 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 30 + title: Gantt + project_id: 8 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 31 + title: Table + project_id: 8 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 32 + title: Kanban + project_id: 8 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 33 + title: List + project_id: 9 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 34 + title: Gantt + project_id: 9 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 35 + title: Table + project_id: 9 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 36 + title: Kanban + project_id: 9 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 37 + title: List + project_id: 10 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 38 + title: Gantt + project_id: 10 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 39 + title: Table + project_id: 10 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 40 + title: Kanban + project_id: 10 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 41 + title: List + project_id: 11 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 42 + title: Gantt + project_id: 11 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 43 + title: Table + project_id: 11 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 44 + title: Kanban + project_id: 11 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 45 + title: List + project_id: 12 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 46 + title: Gantt + project_id: 12 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 47 + title: Table + project_id: 12 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 48 + title: Kanban + project_id: 12 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 49 + title: List + project_id: 13 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 50 + title: Gantt + project_id: 13 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 51 + title: Table + project_id: 13 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 52 + title: Kanban + project_id: 13 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 53 + title: List + project_id: 14 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 54 + title: Gantt + project_id: 14 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 55 + title: Table + project_id: 14 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 56 + title: Kanban + project_id: 14 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 57 + title: List + project_id: 15 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 58 + title: Gantt + project_id: 15 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 59 + title: Table + project_id: 15 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 60 + title: Kanban + project_id: 15 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 61 + title: List + project_id: 16 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 62 + title: Gantt + project_id: 16 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 63 + title: Table + project_id: 16 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 64 + title: Kanban + project_id: 16 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 65 + title: List + project_id: 17 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 66 + title: Gantt + project_id: 17 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 67 + title: Table + project_id: 17 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 68 + title: Kanban + project_id: 17 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 69 + title: List + project_id: 18 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 70 + title: Gantt + project_id: 18 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 71 + title: Table + project_id: 18 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 72 + title: Kanban + project_id: 18 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 73 + title: List + project_id: 19 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 74 + title: Gantt + project_id: 19 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 75 + title: Table + project_id: 19 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 76 + title: Kanban + project_id: 19 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 77 + title: List + project_id: 20 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 78 + title: Gantt + project_id: 20 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 79 + title: Table + project_id: 20 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 80 + title: Kanban + project_id: 20 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 81 + title: List + project_id: 21 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 82 + title: Gantt + project_id: 21 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 83 + title: Table + project_id: 21 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 84 + title: Kanban + project_id: 21 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 85 + title: List + project_id: 22 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 86 + title: Gantt + project_id: 22 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 87 + title: Table + project_id: 22 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 88 + title: Kanban + project_id: 22 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 89 + title: List + project_id: 23 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 90 + title: Gantt + project_id: 23 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 91 + title: Table + project_id: 23 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 92 + title: Kanban + project_id: 23 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 93 + title: List + project_id: 24 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 94 + title: Gantt + project_id: 24 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 95 + title: Table + project_id: 24 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 96 + title: Kanban + project_id: 24 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 97 + title: List + project_id: 25 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 98 + title: Gantt + project_id: 25 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 99 + title: Table + project_id: 25 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 100 + title: Kanban + project_id: 25 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 101 + title: List + project_id: 26 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 102 + title: Gantt + project_id: 26 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 103 + title: Table + project_id: 26 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 104 + title: Kanban + project_id: 26 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 105 + title: List + project_id: 27 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 106 + title: Gantt + project_id: 27 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 107 + title: Table + project_id: 27 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 108 + title: Kanban + project_id: 27 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 109 + title: List + project_id: 28 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 110 + title: Gantt + project_id: 28 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 111 + title: Table + project_id: 28 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 112 + title: Kanban + project_id: 28 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 113 + title: List + project_id: 29 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 114 + title: Gantt + project_id: 29 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 115 + title: Table + project_id: 29 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 116 + title: Kanban + project_id: 29 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 117 + title: List + project_id: 30 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 118 + title: Gantt + project_id: 30 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 119 + title: Table + project_id: 30 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 120 + title: Kanban + project_id: 30 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 121 + title: List + project_id: 31 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 122 + title: Gantt + project_id: 31 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 123 + title: Table + project_id: 31 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 124 + title: Kanban + project_id: 31 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 125 + title: List + project_id: 32 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 126 + title: Gantt + project_id: 32 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 127 + title: Table + project_id: 32 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 128 + title: Kanban + project_id: 32 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 129 + title: List + project_id: 33 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 130 + title: Gantt + project_id: 33 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 131 + title: Table + project_id: 33 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 132 + title: Kanban + project_id: 33 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 133 + title: List + project_id: 34 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 134 + title: Gantt + project_id: 34 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 135 + title: Table + project_id: 34 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 136 + title: Kanban + project_id: 34 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 137 + title: List + project_id: 35 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 138 + title: Gantt + project_id: 35 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 139 + title: Table + project_id: 35 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 140 + title: Kanban + project_id: 35 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 141 + title: List + project_id: 36 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 142 + title: Gantt + project_id: 36 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 143 + title: Table + project_id: 36 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 144 + title: Kanban + project_id: 36 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 145 + title: List + project_id: 37 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 146 + title: Gantt + project_id: 37 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 147 + title: Table + project_id: 37 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 148 + title: Kanban + project_id: 37 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 149 + title: List + project_id: 38 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 150 + title: Gantt + project_id: 38 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 151 + title: Table + project_id: 38 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 152 + title: Kanban + project_id: 38 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 \ No newline at end of file diff --git a/pkg/db/fixtures/projects.yml b/pkg/db/fixtures/projects.yml index b42944d84..f16622d93 100644 --- a/pkg/db/fixtures/projects.yml +++ b/pkg/db/fixtures/projects.yml @@ -5,7 +5,6 @@ identifier: test1 owner_id: 1 position: 3 - done_bucket_id: 3 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - @@ -15,8 +14,6 @@ identifier: test2 owner_id: 3 position: 2 - done_bucket_id: 4 - default_bucket_id: 40 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - @@ -53,7 +50,6 @@ identifier: test6 owner_id: 6 position: 6 - default_bucket_id: 22 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - diff --git a/pkg/db/fixtures/task_buckets.yml b/pkg/db/fixtures/task_buckets.yml new file mode 100644 index 000000000..390a7d6ad --- /dev/null +++ b/pkg/db/fixtures/task_buckets.yml @@ -0,0 +1,138 @@ +- task_id: 1 + project_view_id: 4 + bucket_id: 1 +- task_id: 2 + project_view_id: 4 + bucket_id: 1 +- task_id: 3 + project_view_id: 4 + bucket_id: 2 +- task_id: 4 + project_view_id: 4 + bucket_id: 2 +- task_id: 5 + project_view_id: 4 + bucket_id: 2 +- task_id: 6 + project_view_id: 4 + bucket_id: 3 +- task_id: 7 + project_view_id: 4 + bucket_id: 3 +- task_id: 8 + project_view_id: 4 + bucket_id: 3 +- task_id: 9 + project_view_id: 4 + bucket_id: 1 +- task_id: 10 + project_view_id: 4 + bucket_id: 1 +- task_id: 11 + project_view_id: 4 + bucket_id: 1 +- task_id: 12 + project_view_id: 4 + bucket_id: 1 +- task_id: 13 + project_view_id: 8 + bucket_id: 4 +- task_id: 14 + project_view_id: 20 + bucket_id: 18 +- task_id: 15 + project_view_id: 24 + bucket_id: 6 +- task_id: 16 + project_view_id: 28 + bucket_id: 7 +- task_id: 17 + project_view_id: 32 + bucket_id: 8 +- task_id: 18 + project_view_id: 36 + bucket_id: 9 +- task_id: 19 + project_view_id: 40 + bucket_id: 10 +- task_id: 20 + project_view_id: 44 + bucket_id: 11 +- task_id: 21 + project_view_id: 128 + bucket_id: 12 +- task_id: 22 + project_view_id: 132 + bucket_id: 36 +- task_id: 23 + project_view_id: 136 + bucket_id: 37 +- task_id: 24 + project_view_id: 60 + bucket_id: 15 +- task_id: 25 + project_view_id: 64 + bucket_id: 16 +- task_id: 26 + project_view_id: 68 + bucket_id: 17 +- task_id: 27 + project_view_id: 4 + bucket_id: 1 +- task_id: 28 + project_view_id: 4 + bucket_id: 1 +- task_id: 29 + project_view_id: 4 + bucket_id: 1 +- task_id: 30 + project_view_id: 4 + bucket_id: 1 +- task_id: 31 + project_view_id: 4 + bucket_id: 1 +- task_id: 32 + project_view_id: 12 + bucket_id: 21 +- task_id: 33 + project_view_id: 4 + bucket_id: 1 +- task_id: 34 + project_view_id: 80 + bucket_id: 5 +- task_id: 35 + project_view_id: 84 + bucket_id: 19 +- task_id: 36 + project_view_id: 88 + bucket_id: 20 +#- task_id: 37 +# project_view_id: 8 +# bucket_id: null +#- task_id: 38 +# project_view_id: 88 +# bucket_id: null +#- task_id: 39 +# project_view_id: 100 +# bucket_id: null +- task_id: 40 + project_view_id: 144 + bucket_id: 38 +- task_id: 41 + project_view_id: 144 + bucket_id: 38 +- task_id: 42 + project_view_id: 144 + bucket_id: 38 +- task_id: 43 + project_view_id: 144 + bucket_id: 38 +- task_id: 44 + project_view_id: 152 + bucket_id: 38 +- task_id: 45 + project_view_id: 144 + bucket_id: 38 +- task_id: 46 + project_view_id: 152 + bucket_id: 38 diff --git a/pkg/db/fixtures/task_positions.yml b/pkg/db/fixtures/task_positions.yml new file mode 100644 index 000000000..08e118305 --- /dev/null +++ b/pkg/db/fixtures/task_positions.yml @@ -0,0 +1,138 @@ +- task_id: 1 + project_view_id: 1 + position: 2 +- task_id: 2 + project_view_id: 1 + position: 4 +#- task_id: 3 +# project_view_id: 1 +# position: null +#- task_id: 4 +# project_view_id: 1 +# position: null +#- task_id: 5 +# project_view_id: 1 +# position: null +#- task_id: 6 +# project_view_id: 1 +# position: null +#- task_id: 7 +# project_view_id: 1 +# position: null +#- task_id: 8 +# project_view_id: 1 +# position: null +#- task_id: 9 +# project_view_id: 1 +# position: null +#- task_id: 10 +# project_view_id: 1 +# position: null +#- task_id: 11 +# project_view_id: 1 +# position: null +#- task_id: 12 +# project_view_id: 1 +# position: null +#- task_id: 13 +# project_view_id: 2 +# position: null +#- task_id: 14 +# project_view_id: 5 +# position: null +#- task_id: 15 +# project_view_id: 6 +# position: null +#- task_id: 16 +# project_view_id: 7 +# position: null +#- task_id: 17 +# project_view_id: 8 +# position: null +#- task_id: 18 +# project_view_id: 9 +# position: null +#- task_id: 19 +# project_view_id: 10 +# position: null +#- task_id: 20 +# project_view_id: 11 +# position: null +#- task_id: 21 +# project_view_id: 32 +# position: null +#- task_id: 22 +# project_view_id: 33 +# position: null +#- task_id: 23 +# project_view_id: 34 +# position: null +#- task_id: 24 +# project_view_id: 15 +# position: null +#- task_id: 25 +# project_view_id: 16 +# position: null +#- task_id: 26 +# project_view_id: 17 +# position: null +#- task_id: 27 +# project_view_id: 1 +# position: null +#- task_id: 28 +# project_view_id: 1 +# position: null +#- task_id: 29 +# project_view_id: 1 +# position: null +#- task_id: 30 +# project_view_id: 1 +# position: null +#- task_id: 31 +# project_view_id: 1 +# position: null +#- task_id: 32 +# project_view_id: 3 +# position: null +#- task_id: 33 +# project_view_id: 1 +# position: null +#- task_id: 34 +# project_view_id: 20 +# position: null +- task_id: 35 + project_view_id: 21 + position: 0 +#- task_id: 36 +# project_view_id: 22 +# position: null +#- task_id: 37 +# project_view_id: 2 +# position: null +#- task_id: 38 +# project_view_id: 22 +# position: null +- task_id: 39 + project_view_id: 25 + position: 0 +- task_id: 40 + project_view_id: 36 + position: 39 +- task_id: 41 + project_view_id: 36 + position: 40 +- task_id: 42 + project_view_id: 36 + position: 41 +- task_id: 43 + project_view_id: 36 + position: 42 +- task_id: 44 + project_view_id: 38 + position: 43 +- task_id: 45 + project_view_id: 36 + position: 44 +- task_id: 46 + project_view_id: 38 + position: 45 diff --git a/pkg/db/fixtures/tasks.yml b/pkg/db/fixtures/tasks.yml index fe636fb10..7657c2bb6 100644 --- a/pkg/db/fixtures/tasks.yml +++ b/pkg/db/fixtures/tasks.yml @@ -7,8 +7,6 @@ index: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 1 - position: 2 - id: 2 title: 'task #2 done' done: true @@ -17,8 +15,6 @@ index: 2 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 1 - position: 4 - id: 3 title: 'task #3 high prio' done: false @@ -28,7 +24,6 @@ created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 priority: 100 - bucket_id: 2 - id: 4 title: 'task #4 low prio' done: false @@ -38,7 +33,6 @@ created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 priority: 1 - bucket_id: 2 - id: 5 title: 'task #5 higher due date' done: false @@ -48,7 +42,6 @@ created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 due_date: 2018-12-01 03:58:44 - bucket_id: 2 - id: 6 title: 'task #6 lower due date' done: false @@ -58,7 +51,6 @@ created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 due_date: 2018-11-30 22:25:24 - bucket_id: 3 - id: 7 title: 'task #7 with start date' done: false @@ -68,7 +60,6 @@ created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 start_date: 2018-12-12 07:33:20 - bucket_id: 3 - id: 8 title: 'task #8 with end date' done: false @@ -78,7 +69,6 @@ created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 end_date: 2018-12-13 11:20:00 - bucket_id: 3 - id: 9 title: 'task #9 with start and end date' done: false @@ -89,14 +79,12 @@ updated: 2018-12-01 01:12:04 start_date: 2018-12-12 07:33:20 end_date: 2018-12-13 11:20:00 - bucket_id: 1 - id: 10 title: 'task #10 basic' done: false created_by_id: 1 project_id: 1 index: 10 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 11 @@ -105,7 +93,6 @@ created_by_id: 1 project_id: 1 index: 11 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 12 @@ -114,7 +101,6 @@ created_by_id: 1 project_id: 1 index: 12 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 13 @@ -123,7 +109,6 @@ created_by_id: 1 project_id: 2 index: 1 - bucket_id: 4 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 14 @@ -132,7 +117,6 @@ created_by_id: 5 project_id: 5 index: 1 - bucket_id: 18 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 15 @@ -141,7 +125,6 @@ created_by_id: 6 project_id: 6 index: 1 - bucket_id: 6 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 16 @@ -150,7 +133,6 @@ created_by_id: 6 project_id: 7 index: 1 - bucket_id: 7 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 17 @@ -159,7 +141,6 @@ created_by_id: 6 project_id: 8 index: 1 - bucket_id: 8 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 18 @@ -168,7 +149,6 @@ created_by_id: 6 project_id: 9 index: 1 - bucket_id: 9 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 19 @@ -177,7 +157,6 @@ created_by_id: 6 project_id: 10 index: 1 - bucket_id: 10 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 20 @@ -186,7 +165,6 @@ created_by_id: 6 project_id: 11 index: 1 - bucket_id: 11 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 21 @@ -195,7 +173,6 @@ created_by_id: 6 project_id: 32 index: 1 - bucket_id: 12 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 22 @@ -204,7 +181,6 @@ created_by_id: 6 project_id: 33 index: 1 - bucket_id: 36 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 23 @@ -213,7 +189,6 @@ created_by_id: 6 project_id: 34 index: 1 - bucket_id: 37 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 24 @@ -222,7 +197,6 @@ created_by_id: 6 project_id: 15 index: 1 - bucket_id: 15 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 25 @@ -231,7 +205,6 @@ created_by_id: 6 project_id: 16 index: 1 - bucket_id: 16 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 26 @@ -240,7 +213,6 @@ created_by_id: 6 project_id: 17 index: 1 - bucket_id: 17 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 27 @@ -249,7 +221,6 @@ created_by_id: 1 project_id: 1 index: 12 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 start_date: 2018-11-30 22:25:24 @@ -260,7 +231,6 @@ repeat_after: 3600 project_id: 1 index: 13 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 29 @@ -269,7 +239,6 @@ created_by_id: 1 project_id: 1 index: 14 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 30 @@ -278,7 +247,6 @@ created_by_id: 1 project_id: 1 index: 15 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 31 @@ -288,7 +256,6 @@ project_id: 1 index: 16 hex_color: f0f0f0 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 32 @@ -297,7 +264,6 @@ created_by_id: 1 project_id: 3 index: 1 - bucket_id: 21 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 33 @@ -307,7 +273,6 @@ project_id: 1 index: 17 percent_done: 0.5 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 # This task is forbidden for user1 @@ -317,7 +282,6 @@ created_by_id: 13 project_id: 20 index: 20 - bucket_id: 5 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 35 @@ -326,7 +290,6 @@ created_by_id: 1 project_id: 21 index: 1 - bucket_id: 19 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 36 @@ -335,7 +298,6 @@ created_by_id: 1 project_id: 22 index: 1 - bucket_id: 20 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 due_date: 2018-10-30 22:25:24 @@ -374,8 +336,6 @@ due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 38 - position: 39 - id: 41 uid: 'uid-caldav-test-parent-task' title: 'Parent task for Caldav Test' @@ -388,8 +348,6 @@ due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 38 - position: 40 - id: 42 uid: 'uid-caldav-test-parent-task-2' title: 'Parent task for Caldav Test 2' @@ -402,8 +360,6 @@ due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 38 - position: 41 - id: 43 uid: 'uid-caldav-test-child-task' title: 'Child task for Caldav Test' @@ -416,8 +372,6 @@ due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 38 - position: 42 - id: 44 uid: 'uid-caldav-test-child-task-2' title: 'Child task for Caldav Test ' @@ -430,8 +384,6 @@ due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 38 - position: 43 - id: 45 uid: 'uid-caldav-test-parent-task-another-list' title: 'Parent task for Caldav Test' @@ -444,8 +396,6 @@ due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 38 - position: 44 - id: 46 uid: 'uid-caldav-test-child-task-another-list' title: 'Child task for Caldav Test ' @@ -457,6 +407,4 @@ index: 45 due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 - updated: 2018-12-01 01:12:04 - bucket_id: 38 - position: 45 \ No newline at end of file + updated: 2018-12-01 01:12:04 \ No newline at end of file diff --git a/pkg/models/unit_tests.go b/pkg/models/unit_tests.go index b8f0a99de..584fbe4ca 100644 --- a/pkg/models/unit_tests.go +++ b/pkg/models/unit_tests.go @@ -66,6 +66,9 @@ func SetupTests() { "favorites", "api_tokens", "reactions", + "project_views", + "task_positions", + "task_buckets", ) if err != nil { log.Fatal(err) -- 2.45.1 From 409f9a0cc6c2a2e07d0c6028f752f44a84c11a0e Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 18 Mar 2024 23:09:17 +0100 Subject: [PATCH 68/97] fix(views): test assertions --- pkg/models/kanban_test.go | 4 +- pkg/models/project_test.go | 19 +++- pkg/models/task_collection_sort_test.go | 1 - pkg/models/task_collection_test.go | 110 ++++++++---------------- pkg/models/tasks_test.go | 71 +++++++++------ 5 files changed, 102 insertions(+), 103 deletions(-) diff --git a/pkg/models/kanban_test.go b/pkg/models/kanban_test.go index 6fe4c7cd0..c2ac935a2 100644 --- a/pkg/models/kanban_test.go +++ b/pkg/models/kanban_test.go @@ -220,8 +220,8 @@ func TestBucket_Delete(t *testing.T) { require.NoError(t, err) db.AssertMissing(t, "project_views", map[string]interface{}{ - "id": 4, - "done_bucket_id": 3, + "id": b.ProjectViewID, + "done_bucket_id": 0, }) }) } diff --git a/pkg/models/project_test.go b/pkg/models/project_test.go index 8d79badd8..8c60694c3 100644 --- a/pkg/models/project_test.go +++ b/pkg/models/project_test.go @@ -53,8 +53,25 @@ func TestProject_CreateOrUpdate(t *testing.T) { "description": project.Description, "parent_project_id": 0, }, false) - db.AssertExists(t, "buckets", map[string]interface{}{ + db.AssertExists(t, "project_views", map[string]interface{}{ "project_id": project.ID, + "view_kind": ProjectViewKindList, + }, false) + db.AssertExists(t, "project_views", map[string]interface{}{ + "project_id": project.ID, + "view_kind": ProjectViewKindGantt, + }, false) + db.AssertExists(t, "project_views", map[string]interface{}{ + "project_id": project.ID, + "view_kind": ProjectViewKindTable, + }, false) + db.AssertExists(t, "project_views", map[string]interface{}{ + "project_id": project.ID, + "view_kind": ProjectViewKindKanban, + "bucket_configuration_mode": BucketConfigurationModeManual, + }, false) + db.AssertExists(t, "buckets", map[string]interface{}{ + "project_view_id": project.ID * 4, // FIXME: Dirty hack to get the project view id }, false) }) t.Run("nonexistant parent project", func(t *testing.T) { diff --git a/pkg/models/task_collection_sort_test.go b/pkg/models/task_collection_sort_test.go index d8385cbd3..6b28d95b9 100644 --- a/pkg/models/task_collection_sort_test.go +++ b/pkg/models/task_collection_sort_test.go @@ -61,7 +61,6 @@ func TestSortParamValidation(t *testing.T) { taskPropertyUID, taskPropertyCreated, taskPropertyUpdated, - taskPropertyPosition, } { t.Run(test, func(t *testing.T) { s := &sortParam{ diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go index 63a8ab766..b321a1d48 100644 --- a/pkg/models/task_collection_test.go +++ b/pkg/models/task_collection_test.go @@ -95,9 +95,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedByID: 1, CreatedBy: user1, ProjectID: 1, - BucketID: 1, IsFavorite: true, - Position: 2, Reactions: ReactionMap{ "👋": []*user.User{user1}, }, @@ -112,7 +110,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { Index: 14, CreatedByID: 1, ProjectID: 1, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), }, @@ -170,8 +167,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedByID: 1, CreatedBy: user1, ProjectID: 1, - BucketID: 1, - Position: 4, Labels: []*Label{ label4, }, @@ -199,7 +194,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), Priority: 100, - BucketID: 2, } task4 := &Task{ ID: 4, @@ -213,7 +207,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), Priority: 1, - BucketID: 2, } task5 := &Task{ ID: 5, @@ -227,7 +220,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), DueDate: time.Unix(1543636724, 0).In(loc), - BucketID: 2, } task6 := &Task{ ID: 6, @@ -241,7 +233,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), DueDate: time.Unix(1543616724, 0).In(loc), - BucketID: 3, } task7 := &Task{ ID: 7, @@ -255,7 +246,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), StartDate: time.Unix(1544600000, 0).In(loc), - BucketID: 3, } task8 := &Task{ ID: 8, @@ -269,7 +259,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), EndDate: time.Unix(1544700000, 0).In(loc), - BucketID: 3, } task9 := &Task{ ID: 9, @@ -280,7 +269,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user1, ProjectID: 1, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), StartDate: time.Unix(1544600000, 0).In(loc), @@ -295,7 +283,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user1, ProjectID: 1, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -308,7 +295,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user1, ProjectID: 1, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -321,7 +307,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user1, ProjectID: 1, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -335,7 +320,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { ProjectID: 6, IsFavorite: true, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 6, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -348,7 +332,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 7, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 7, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -361,7 +344,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 8, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 8, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -374,7 +356,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 9, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 9, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -387,7 +368,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 10, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 10, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -400,7 +380,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 11, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 11, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -413,7 +392,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 32, // parent project is shared to user 1 via direct share RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 12, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -426,7 +404,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 33, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 36, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -439,7 +416,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 34, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 37, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -452,7 +428,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 15, // parent project is shared to user 1 via team RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 15, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -465,7 +440,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 16, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 16, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -478,7 +452,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 17, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 17, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -507,7 +480,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { }, StartDate: time.Unix(1543616724, 0).In(loc), ProjectID: 1, - BucketID: 1, RelatedTasks: map[RelationKind][]*Task{}, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), @@ -522,7 +494,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { ProjectID: 1, RelatedTasks: map[RelationKind][]*Task{}, RepeatAfter: 3600, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -546,14 +517,11 @@ func TestTaskCollection_ReadAll(t *testing.T) { IsFavorite: true, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), - BucketID: 1, - Position: 2, }, }, }, - BucketID: 1, - Created: time.Unix(1543626724, 0).In(loc), - Updated: time.Unix(1543626724, 0).In(loc), + Created: time.Unix(1543626724, 0).In(loc), + Updated: time.Unix(1543626724, 0).In(loc), } task30 := &Task{ ID: 30, @@ -568,7 +536,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { user2, }, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -582,7 +549,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user1, ProjectID: 1, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -595,7 +561,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user1, ProjectID: 3, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 21, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -609,7 +574,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { ProjectID: 1, PercentDone: 0.5, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -639,8 +603,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { IsFavorite: true, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), - BucketID: 1, - Position: 2, }, { ID: 1, @@ -652,14 +614,11 @@ func TestTaskCollection_ReadAll(t *testing.T) { IsFavorite: true, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), - BucketID: 1, - Position: 2, }, }, }, - BucketID: 19, - Created: time.Unix(1543626724, 0).In(loc), - Updated: time.Unix(1543626724, 0).In(loc), + Created: time.Unix(1543626724, 0).In(loc), + Updated: time.Unix(1543626724, 0).In(loc), } task39 := &Task{ ID: 39, @@ -669,16 +628,16 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user1, ProjectID: 25, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 0, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } type fields struct { - ProjectID int64 - Projects []*Project - SortBy []string // Is a string, since this is the place where a query string comes from the user - OrderBy []string + ProjectID int64 + ProjectViewID int64 + Projects []*Project + SortBy []string // Is a string, since this is the place where a query string comes from the user + OrderBy []string FilterIncludeNulls bool Filter string @@ -705,6 +664,13 @@ func TestTaskCollection_ReadAll(t *testing.T) { page: 0, } + taskWithPosition := func(task *Task, position float64) *Task { + newTask := &Task{} + *newTask = *task + newTask.Position = position + return newTask + } + tests := []testcase{ { name: "ReadAll Tasks normally", @@ -1258,16 +1224,18 @@ func TestTaskCollection_ReadAll(t *testing.T) { { name: "order by position", fields: fields{ - SortBy: []string{"position", "id"}, - OrderBy: []string{"asc", "asc"}, + SortBy: []string{"position", "id"}, + OrderBy: []string{"asc", "asc"}, + ProjectViewID: 1, + ProjectID: 1, }, args: args{ a: &user.User{ID: 1}, }, want: []*Task{ // The only tasks with a position set - task1, - task2, + taskWithPosition(task1, 2), + taskWithPosition(task2, 4), // the other ones don't have a position set task3, task4, @@ -1279,27 +1247,24 @@ func TestTaskCollection_ReadAll(t *testing.T) { task10, task11, task12, - task15, - task16, - task17, - task18, - task19, - task20, - task21, - task22, - task23, - task24, - task25, - task26, + //task15, + //task16, + //task17, + //task18, + //task19, + //task20, + //task21, + //task22, + //task23, + //task24, + //task25, + //task26, task27, task28, task29, task30, task31, - task32, task33, - task35, - task39, }, }, { @@ -1414,9 +1379,10 @@ func TestTaskCollection_ReadAll(t *testing.T) { defer s.Close() lt := &TaskCollection{ - ProjectID: tt.fields.ProjectID, - SortBy: tt.fields.SortBy, - OrderBy: tt.fields.OrderBy, + ProjectID: tt.fields.ProjectID, + ProjectViewID: tt.fields.ProjectViewID, + SortBy: tt.fields.SortBy, + OrderBy: tt.fields.OrderBy, FilterIncludeNulls: tt.fields.FilterIncludeNulls, diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index 558ef8838..10e15c4e7 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -55,8 +55,6 @@ func TestTask_Create(t *testing.T) { // Assert getting a new index assert.NotEmpty(t, task.Index) assert.Equal(t, int64(18), task.Index) - // Assert moving it into the default bucket - assert.Equal(t, int64(1), task.BucketID) err = s.Commit() require.NoError(t, err) @@ -66,7 +64,10 @@ func TestTask_Create(t *testing.T) { "description": "Lorem Ipsum Dolor", "project_id": 1, "created_by_id": 1, - "bucket_id": 1, + }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": task.ID, + "bucket_id": 1, }, false) events.AssertDispatched(t, &TaskCreatedEvent{}) @@ -183,8 +184,8 @@ func TestTask_Create(t *testing.T) { } err := task.Create(s, usr) require.NoError(t, err) - db.AssertExists(t, "tasks", map[string]interface{}{ - "id": task.ID, + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": task.ID, "bucket_id": 22, // default bucket of project 6 but with a position of 2 }, false) }) @@ -276,7 +277,7 @@ func TestTask_Update(t *testing.T) { } err := task.Update(s, u) require.Error(t, err) - assert.True(t, IsErrBucketDoesNotBelongToProject(err)) + assert.True(t, IsErrBucketDoesNotExist(err)) }) t.Run("moving a task to the done bucket", func(t *testing.T) { db.LoadAndAssertFixtures(t) @@ -296,11 +297,12 @@ func TestTask_Update(t *testing.T) { assert.True(t, task.Done) db.AssertExists(t, "tasks", map[string]interface{}{ - "id": 1, - "done": true, - "title": "test", - "project_id": 1, - "bucket_id": 3, + "id": 1, + "done": true, + }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 1, + "bucket_id": 3, }, false) }) t.Run("moving a repeating task to the done bucket", func(t *testing.T) { @@ -320,14 +322,15 @@ func TestTask_Update(t *testing.T) { err = s.Commit() require.NoError(t, err) assert.False(t, task.Done) - assert.Equal(t, int64(1), task.BucketID) // Bucket should not be updated + assert.Equal(t, int64(3), task.BucketID) db.AssertExists(t, "tasks", map[string]interface{}{ - "id": 28, - "done": false, - "title": "test updated", - "project_id": 1, - "bucket_id": 1, + "id": 1, + "done": false, + }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 1, + "bucket_id": 1, }, false) }) t.Run("default bucket when moving a task between projects", func(t *testing.T) { @@ -344,7 +347,11 @@ func TestTask_Update(t *testing.T) { err = s.Commit() require.NoError(t, err) - assert.Equal(t, int64(40), task.BucketID) // bucket 40 is the default bucket on project 2 + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": task.ID, + // bucket 40 is the default bucket on project 2 + "bucket_id": 40, + }, false) }) t.Run("marking a task as done should move it to the done bucket", func(t *testing.T) { db.LoadAndAssertFixtures(t) @@ -360,11 +367,13 @@ func TestTask_Update(t *testing.T) { err = s.Commit() require.NoError(t, err) assert.True(t, task.Done) - assert.Equal(t, int64(3), task.BucketID) db.AssertExists(t, "tasks", map[string]interface{}{ - "id": 1, - "done": true, + "id": 1, + "done": true, + }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 1, "bucket_id": 3, }, false) }) @@ -385,7 +394,10 @@ func TestTask_Update(t *testing.T) { db.AssertExists(t, "tasks", map[string]interface{}{ "id": 1, "project_id": 2, - "bucket_id": 40, + }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 1, + "bucket_id": 40, }, false) }) t.Run("move done task to another project with a done bucket", func(t *testing.T) { @@ -404,11 +416,14 @@ func TestTask_Update(t *testing.T) { require.NoError(t, err) db.AssertExists(t, "tasks", map[string]interface{}{ - "id": 2, + "id": task.ID, "project_id": 2, - "bucket_id": 4, // 4 is the done bucket "done": true, }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": task.ID, + "bucket_id": 4, // 4 is the done bucket + }, false) }) t.Run("repeating tasks should not be moved to the done bucket", func(t *testing.T) { db.LoadAndAssertFixtures(t) @@ -425,11 +440,13 @@ func TestTask_Update(t *testing.T) { err = s.Commit() require.NoError(t, err) assert.False(t, task.Done) - assert.Equal(t, int64(1), task.BucketID) db.AssertExists(t, "tasks", map[string]interface{}{ - "id": 28, - "done": false, + "id": 28, + "done": false, + }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 28, "bucket_id": 1, }, false) }) -- 2.45.1 From b7b316916995fcaa924a9c69ca2889e6e30df0d4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 18 Mar 2024 23:09:31 +0100 Subject: [PATCH 69/97] fix(views): count task buckets --- pkg/models/tasks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 1823d165f..5bde2ce3c 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -640,7 +640,7 @@ func checkBucketLimit(s *xorm.Session, t *Task, bucket *Bucket) (err error) { if bucket.Limit > 0 { taskCount, err := s. Where("bucket_id = ?", bucket.ID). - Count(&Task{}) + Count(&TaskBucket{}) if err != nil { return err } -- 2.45.1 From 803f58f402bb14d6da8a31c53be5778cef818a9e Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 18 Mar 2024 23:09:50 +0100 Subject: [PATCH 70/97] fix(views): return correct error --- pkg/models/tasks.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 5bde2ce3c..4c38b31a5 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -938,6 +938,9 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { var targetBucketID int64 if t.BucketID != 0 { bucket, has := buckets[t.BucketID] + if !has { + return ErrBucketDoesNotExist{BucketID: t.BucketID} + } if has && bucket.ProjectViewID == view.ID { targetBucketID = t.BucketID } -- 2.45.1 From 8b90eb4a152c40ea71c443ed61d0c12270cd0439 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 18 Mar 2024 23:53:21 +0100 Subject: [PATCH 71/97] fix(views): integration tests --- pkg/integrations/kanban_test.go | 184 +++++++++++++++++++---- pkg/integrations/task_collection_test.go | 30 ++-- pkg/integrations/task_test.go | 2 +- pkg/models/error.go | 4 +- 4 files changed, 169 insertions(+), 51 deletions(-) diff --git a/pkg/integrations/kanban_test.go b/pkg/integrations/kanban_test.go index e874be316..47790d4e7 100644 --- a/pkg/integrations/kanban_test.go +++ b/pkg/integrations/kanban_test.go @@ -52,7 +52,10 @@ func TestBucket(t *testing.T) { } t.Run("ReadAll", func(t *testing.T) { t.Run("Normal", func(t *testing.T) { - rec, err := testHandler.testReadAllWithUser(nil, map[string]string{"project": "1"}) + rec, err := testHandler.testReadAllWithUser(nil, map[string]string{ + "project": "1", + "view": "4", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `testbucket1`) assert.Contains(t, rec.Body.String(), `testbucket2`) @@ -63,87 +66,151 @@ func TestBucket(t *testing.T) { t.Run("Update", func(t *testing.T) { t.Run("Normal", func(t *testing.T) { // Check the project was loaded successfully afterwards, see testReadOneWithUser - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "1"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "1", + "project": "1", + "view": "4", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Nonexisting Bucket", func(t *testing.T) { - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "9999"}, `{"title":"TestLoremIpsum"}`) + _, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "9999", + "project": "1", + "view": "4", + }, `{"title":"TestLoremIpsum"}`) require.Error(t, err) assertHandlerErrorCode(t, err, models.ErrCodeBucketDoesNotExist) }) t.Run("Empty title", func(t *testing.T) { - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "1"}, `{"title":""}`) + _, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "1", + "project": "1", + "view": "4", + }, `{"title":""}`) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message.(models.ValidationHTTPError).InvalidFields, "title: non zero value required") }) t.Run("Rights check", func(t *testing.T) { t.Run("Forbidden", func(t *testing.T) { // Owned by user13 - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "5"}, `{"title":"TestLoremIpsum"}`) + _, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "5", + "project": "20", + "view": "80", + }, `{"title":"TestLoremIpsum"}`) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via Team readonly", func(t *testing.T) { - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "6"}, `{"title":"TestLoremIpsum"}`) + _, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "6", + "project": "6", + "view": "24", + }, `{"title":"TestLoremIpsum"}`) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via Team write", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "7"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "7", + "project": "7", + "view": "28", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Shared Via Team admin", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "8"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "8", + "project": "8", + "view": "32", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Shared Via User readonly", func(t *testing.T) { - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "9"}, `{"title":"TestLoremIpsum"}`) + _, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "9", + "project": "9", + "view": "36", + }, `{"title":"TestLoremIpsum"}`) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via User write", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "10"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "10", + "project": "10", + "view": "40", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Shared Via User admin", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "11"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "11", + "project": "11", + "view": "44", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Shared Via Parent Project User readonly", func(t *testing.T) { - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "12"}, `{"title":"TestLoremIpsum"}`) + _, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "12", + "project": "12", + "view": "48", + }, `{"title":"TestLoremIpsum"}`) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via Parent Project User write", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "13"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "13", + "project": "13", + "view": "52", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Shared Via Parent Project User admin", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "14"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "14", + "project": "14", + "view": "56", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Shared Via Parent Project Team readonly", func(t *testing.T) { - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "15"}, `{"title":"TestLoremIpsum"}`) + _, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "15", + "project": "15", + "view": "60", + }, `{"title":"TestLoremIpsum"}`) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via Parent Project Team write", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "16"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "16", + "project": "16", + "view": "64", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Shared Via Parent Project Team admin", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "17"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "17", + "project": "17", + "view": "68", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) @@ -151,7 +218,11 @@ func TestBucket(t *testing.T) { }) t.Run("Delete", func(t *testing.T) { t.Run("Normal", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "1", "bucket": "1"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "1", + "bucket": "1", + "view": "4", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) @@ -173,60 +244,104 @@ func TestBucket(t *testing.T) { assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via Team write", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "7", "bucket": "7"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "7", + "bucket": "7", + "view": "28", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) t.Run("Shared Via Team admin", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "8", "bucket": "8"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "8", + "bucket": "8", + "view": "32", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) t.Run("Shared Via User readonly", func(t *testing.T) { - _, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "9", "bucket": "9"}) + _, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "9", + "bucket": "9", + "view": "36", + }) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via User write", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "10", "bucket": "10"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "10", + "bucket": "10", + "view": "40", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) t.Run("Shared Via User admin", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "11", "bucket": "11"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "11", + "bucket": "11", + "view": "44", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) t.Run("Shared Via Parent Project Team readonly", func(t *testing.T) { - _, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "12", "bucket": "12"}) + _, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "12", + "bucket": "12", + "view": "48", + }) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via Parent Project Team write", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "13", "bucket": "13"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "13", + "bucket": "13", + "view": "52", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) t.Run("Shared Via Parent Project Team admin", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "14", "bucket": "14"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "14", + "bucket": "14", + "view": "56", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) t.Run("Shared Via Parent Project User readonly", func(t *testing.T) { - _, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "15", "bucket": "15"}) + _, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "15", + "bucket": "15", + "view": "60", + }) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via Parent Project User write", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "16", "bucket": "16"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "16", + "bucket": "16", + "view": "64", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) t.Run("Shared Via Parent Project User admin", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "17", "bucket": "17"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "17", + "bucket": "17", + "view": "68", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) @@ -315,13 +430,16 @@ func TestBucket(t *testing.T) { }) }) t.Run("Link Share", func(t *testing.T) { - rec, err := testHandlerLinkShareWrite.testCreateWithLinkShare(nil, map[string]string{"project": "2"}, `{"title":"Lorem Ipsum"}`) + rec, err := testHandlerLinkShareWrite.testCreateWithLinkShare(nil, map[string]string{ + "project": "2", + "view": "8", + }, `{"title":"Lorem Ipsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`) db.AssertExists(t, "buckets", map[string]interface{}{ - "project_id": 2, - "created_by_id": -2, - "title": "Lorem Ipsum", + "project_view_id": 8, + "created_by_id": -2, + "title": "Lorem Ipsum", }, false) }) }) diff --git a/pkg/integrations/task_collection_test.go b/pkg/integrations/task_collection_test.go index 6ae87f52e..6b7ea6b8e 100644 --- a/pkg/integrations/task_collection_test.go +++ b/pkg/integrations/task_collection_test.go @@ -115,49 +115,49 @@ func TestTaskCollection(t *testing.T) { t.Run("by priority", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) + assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) }) t.Run("by priority desc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"desc"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":1`) + assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":1`) }) t.Run("by priority asc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"asc"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) + assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) }) // should equal duedate asc t.Run("by due_date", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) + assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) }) t.Run("by duedate desc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"desc"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`) + assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`) }) // Due date without unix suffix t.Run("by duedate asc without suffix", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"asc"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) + assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) }) t.Run("by due_date without suffix", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) + assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) }) t.Run("by duedate desc without suffix", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"desc"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`) + assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`) }) t.Run("by duedate asc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"asc"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) + assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) }) t.Run("invalid sort parameter", func(t *testing.T) { _, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"loremipsum"}}, urlParams) @@ -358,33 +358,33 @@ func TestTaskCollection(t *testing.T) { t.Run("by priority", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}}, nil) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"reactions":null,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"reactions":null,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":19,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":39,"title":"task #39","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":25,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"#0","index":0,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) + assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":39,"title":"task #39","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":25,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"#0","index":0,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) }) t.Run("by priority desc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"desc"}}, nil) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":1`) + assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":1`) }) t.Run("by priority asc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"asc"}}, nil) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"reactions":null,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"reactions":null,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":19,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":39,"title":"task #39","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":25,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"#0","index":0,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) + assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":39,"title":"task #39","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":25,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"#0","index":0,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) }) // should equal duedate asc t.Run("by due_date", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}}, nil) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) + assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) }) t.Run("by duedate desc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"desc"}}, nil) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`) + assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`) }) t.Run("by duedate asc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"asc"}}, nil) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) + assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) }) t.Run("invalid parameter", func(t *testing.T) { // Invalid parameter should not sort at all diff --git a/pkg/integrations/task_test.go b/pkg/integrations/task_test.go index 761941d3c..099a7b5ce 100644 --- a/pkg/integrations/task_test.go +++ b/pkg/integrations/task_test.go @@ -317,7 +317,7 @@ func TestTask(t *testing.T) { t.Run("Different Project", func(t *testing.T) { _, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "1"}, `{"bucket_id":4}`) require.Error(t, err) - assertHandlerErrorCode(t, err, models.ErrCodeBucketDoesNotBelongToProject) + assertHandlerErrorCode(t, err, models.ErrCodeBucketDoesNotExist) }) t.Run("Nonexisting Bucket", func(t *testing.T) { _, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "1"}, `{"bucket_id":9999}`) diff --git a/pkg/models/error.go b/pkg/models/error.go index 99a18349f..25cb0a0b9 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -1568,7 +1568,7 @@ func IsErrCannotRemoveLastBucket(err error) bool { } func (err ErrCannotRemoveLastBucket) Error() string { - return fmt.Sprintf("Cannot remove last bucket of project [BucketID: %d, ProjectID: %d]", err.BucketID, err.ProjectViewID) + return fmt.Sprintf("Cannot remove last bucket of project view [BucketID: %d, ProjectViewID: %d]", err.BucketID, err.ProjectViewID) } // ErrCodeCannotRemoveLastBucket holds the unique world-error code of this error @@ -1579,7 +1579,7 @@ func (err ErrCannotRemoveLastBucket) HTTPError() web.HTTPError { return web.HTTPError{ HTTPCode: http.StatusPreconditionFailed, Code: ErrCodeCannotRemoveLastBucket, - Message: "You cannot remove the last bucket on this project.", + Message: "You cannot remove the last bucket on this project view.", } } -- 2.45.1 From f3cdd7d15f7d3e34c88142dfb3419c4c3f9564c6 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 19 Mar 2024 00:24:26 +0100 Subject: [PATCH 72/97] fix(views): import --- pkg/models/kanban.go | 1 + pkg/models/project_view.go | 7 +++ .../migration/create_from_structure.go | 52 ++++++++++++++++++- .../migration/create_from_structure_test.go | 7 ++- 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index c5e769ed2..c2c79d10b 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -349,6 +349,7 @@ func (b *Bucket) Update(s *xorm.Session, _ web.Auth) (err error) { "title", "limit", "position", + "project_view_id", ). Update(b) return diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index 4ad9fc01b..84c95eab0 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -422,5 +422,12 @@ func CreateDefaultViewsForProject(s *xorm.Session, project *Project, a web.Auth, return } + project.Views = []*ProjectView{ + list, + gantt, + table, + kanban, + } + return } diff --git a/pkg/modules/migration/create_from_structure.go b/pkg/modules/migration/create_from_structure.go index 66bdeefa1..12bad93dd 100644 --- a/pkg/modules/migration/create_from_structure.go +++ b/pkg/modules/migration/create_from_structure.go @@ -126,6 +126,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas originalBuckets := project.Buckets originalBackgroundInformation := project.BackgroundInformation needsDefaultBucket := false + oldViews := project.Views // Saving the archived status to archive the project again after creating it var wasArchived bool @@ -182,6 +183,47 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas log.Debugf("[creating structure] Created bucket %d, old ID was %d", bucket.ID, oldID) } + // Create all views, create default views if we don't have any + if len(oldViews) > 0 { + for _, view := range oldViews { + view.ID = 0 + + if view.DefaultBucketID != 0 { + bucket, has := buckets[view.DefaultBucketID] + if has { + view.DefaultBucketID = bucket.ID + } + } + + if view.DoneBucketID != 0 { + bucket, has := buckets[view.DoneBucketID] + if has { + view.DoneBucketID = bucket.ID + } + } + + err = view.Create(s, user) + if err != nil { + return + } + } + } else { + // Only using the default views + // Add all buckets to the default kanban view + for _, view := range project.Views { + if view.ViewKind == models.ProjectViewKindKanban { + for _, b := range buckets { + b.ProjectViewID = view.ID + err = b.Update(s, user) + if err != nil { + return + } + } + break + } + } + } + log.Debugf("[creating structure] Creating %d tasks", len(tasks)) setBucketOrDefault := func(task *models.Task) { @@ -205,7 +247,6 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas oldid := t.ID t.ProjectID = project.ID err = t.Create(s, user) - if err != nil && models.IsErrTaskCannotBeEmpty(err) { continue } @@ -332,6 +373,14 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas // All tasks brought their own bucket with them, therefore the newly created default bucket is just extra space if !needsDefaultBucket { b := &models.Bucket{ProjectID: project.ID} + + for _, view := range project.Views { + if view.ViewKind == models.ProjectViewKindKanban { + b.ProjectViewID = view.ID + break + } + } + bucketsIn, _, _, err := b.ReadAll(s, user, "", 1, 1) if err != nil { return err @@ -341,6 +390,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas for _, b := range buckets { if b.Title == "Backlog" { newBacklogBucket = b + newBacklogBucket.ProjectID = project.ID break } } diff --git a/pkg/modules/migration/create_from_structure_test.go b/pkg/modules/migration/create_from_structure_test.go index 41395436a..8e0c63572 100644 --- a/pkg/modules/migration/create_from_structure_test.go +++ b/pkg/modules/migration/create_from_structure_test.go @@ -142,12 +142,11 @@ func TestInsertFromStructure(t *testing.T) { "title": testStructure[1].Title, "description": testStructure[1].Description, }, false) - db.AssertExists(t, "tasks", map[string]interface{}{ - "title": testStructure[1].Tasks[5].Title, + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": testStructure[1].Tasks[5].ID, "bucket_id": testStructure[1].Buckets[0].ID, }, false) - db.AssertMissing(t, "tasks", map[string]interface{}{ - "title": testStructure[1].Tasks[6].Title, + db.AssertMissing(t, "task_buckets", map[string]interface{}{ "bucket_id": 1111, // No task with that bucket should exist }) db.AssertExists(t, "tasks", map[string]interface{}{ -- 2.45.1 From 30b41bd14316328a2e72ed0e8776c634f58150ac Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 19 Mar 2024 00:36:33 +0100 Subject: [PATCH 73/97] fix(views): lint --- pkg/migration/20240313230538.go | 3 ++- pkg/migration/20240314214802.go | 2 +- pkg/models/kanban.go | 2 +- pkg/models/project_duplicate.go | 1 + pkg/models/project_view.go | 9 +++++---- pkg/models/project_view_rights.go | 24 ++++++------------------ pkg/models/task_position.go | 3 ++- pkg/models/tasks.go | 3 +++ 8 files changed, 21 insertions(+), 26 deletions(-) diff --git a/pkg/migration/20240313230538.go b/pkg/migration/20240313230538.go index 04abf05fa..3f712017b 100644 --- a/pkg/migration/20240313230538.go +++ b/pkg/migration/20240313230538.go @@ -17,8 +17,9 @@ package migration import ( - "src.techknowlogick.com/xormigrate" "time" + + "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) diff --git a/pkg/migration/20240314214802.go b/pkg/migration/20240314214802.go index 6797528ad..c266bbaa1 100644 --- a/pkg/migration/20240314214802.go +++ b/pkg/migration/20240314214802.go @@ -46,7 +46,7 @@ func (task20240314214802) TableName() string { func init() { migrations = append(migrations, &xormigrate.Migration{ ID: "20240314214802", - Description: "make task position seperate", + Description: "make task position separate", Migrate: func(tx *xorm.Engine) error { err := tx.Sync2(taskPositions20240314214802{}) if err != nil { diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index c2c79d10b..d420452ad 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -121,7 +121,7 @@ func getDefaultBucketID(s *xorm.Session, view *ProjectView) (bucketID int64, err // @Success 200 {array} models.Bucket "The buckets" // @Failure 500 {object} models.Message "Internal server error" // @Router /projects/{id}/views/{view}/buckets [get] -func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) { +func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, _ string, _ int, _ int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) { view, err := GetProjectViewByIDAndProject(s, b.ProjectViewID, b.ProjectID) if err != nil { diff --git a/pkg/models/project_duplicate.go b/pkg/models/project_duplicate.go index 1cfeede51..dbc38d2af 100644 --- a/pkg/models/project_duplicate.go +++ b/pkg/models/project_duplicate.go @@ -246,6 +246,7 @@ func duplicateViews(s *xorm.Session, pd *ProjectDuplicate, doer web.Auth, taskMa }) } + _, err = s.Insert(&taskPositions) return } diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index 84c95eab0..6d6bd5fbe 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -17,10 +17,11 @@ package models import ( - "code.vikunja.io/web" "encoding/json" "fmt" "time" + + "code.vikunja.io/web" "xorm.io/xorm" ) @@ -58,7 +59,7 @@ func (p *ProjectViewKind) UnmarshalJSON(bytes []byte) error { case "kanban": *p = ProjectViewKindKanban default: - return fmt.Errorf("unkown project view kind: %s", value) + return fmt.Errorf("unknown project view kind: %s", value) } return nil @@ -107,7 +108,7 @@ func (p *BucketConfigurationModeKind) UnmarshalJSON(bytes []byte) error { case "filter": *p = BucketConfigurationModeFilter default: - return fmt.Errorf("unkown bucket configuration mode kind: %s", value) + return fmt.Errorf("unknown bucket configuration mode kind: %s", value) } return nil @@ -236,7 +237,7 @@ func (p *ProjectView) ReadOne(s *xorm.Session, _ web.Auth) (err error) { // @Failure 403 {object} web.HTTPError "The user does not have access to the project view" // @Failure 500 {object} models.Message "Internal error" // @Router /projects/{project}/views/{id} [delete] -func (p *ProjectView) Delete(s *xorm.Session, a web.Auth) (err error) { +func (p *ProjectView) Delete(s *xorm.Session, _ web.Auth) (err error) { _, err = s. Where("id = ? AND project_id = ?", p.ID, p.ProjectID). Delete(&ProjectView{}) diff --git a/pkg/models/project_view_rights.go b/pkg/models/project_view_rights.go index 6c3e4bed9..39dad870b 100644 --- a/pkg/models/project_view_rights.go +++ b/pkg/models/project_view_rights.go @@ -22,37 +22,25 @@ import ( ) func (p *ProjectView) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { - pp, err := p.getProject(s) - if err != nil { - return false, 0, err - } + pp := p.getProject() return pp.CanRead(s, a) } func (p *ProjectView) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { - pp, err := p.getProject(s) - if err != nil { - return false, err - } + pp := p.getProject() return pp.CanUpdate(s, a) } func (p *ProjectView) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { - pp, err := p.getProject(s) - if err != nil { - return false, err - } + pp := p.getProject() return pp.CanUpdate(s, a) } func (p *ProjectView) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { - pp, err := p.getProject(s) - if err != nil { - return false, err - } + pp := p.getProject() return pp.CanUpdate(s, a) } -func (p *ProjectView) getProject(s *xorm.Session) (pp *Project, err error) { - return &Project{ID: p.ProjectID}, nil +func (p *ProjectView) getProject() (pp *Project) { + return &Project{ID: p.ProjectID} } diff --git a/pkg/models/task_position.go b/pkg/models/task_position.go index 470acd635..9f558e67a 100644 --- a/pkg/models/task_position.go +++ b/pkg/models/task_position.go @@ -17,8 +17,9 @@ package models import ( - "code.vikunja.io/web" "math" + + "code.vikunja.io/web" "xorm.io/xorm" ) diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 4c38b31a5..d9452c861 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -932,6 +932,9 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { Where(builder.Eq{"project_id": t.ProjectID}), ). Find(&buckets) + if err != nil { + return err + } for _, view := range views { // Only update the bucket when the current view -- 2.45.1 From 4b903c4f48a4636c3b91e72fe8436ce22e2ceafa Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 19 Mar 2024 00:46:18 +0100 Subject: [PATCH 74/97] fix(views): lint --- .../src/components/project/ProjectWrapper.vue | 7 +- .../project/partials/FilterInput.vue | 5 +- .../project/views/ProjectKanban.vue | 2 +- .../components/project/views/ProjectList.vue | 6 +- .../components/project/views/ProjectTable.vue | 2 +- .../components/project/views/viewEditForm.vue | 13 +- frontend/src/composables/useTaskList.ts | 2 +- frontend/src/helpers/projectView.ts | 1 - frontend/src/stores/kanban.ts | 2 - frontend/src/stores/tasks.ts | 3 +- frontend/src/views/project/ProjectView.vue | 13 +- frontend/src/views/project/settings/views.vue | 114 +++++++++--------- .../src/views/sharing/LinkSharingAuth.vue | 2 +- 13 files changed, 81 insertions(+), 91 deletions(-) diff --git a/frontend/src/components/project/ProjectWrapper.vue b/frontend/src/components/project/ProjectWrapper.vue index dd843629c..8d23f7629 100644 --- a/frontend/src/components/project/ProjectWrapper.vue +++ b/frontend/src/components/project/ProjectWrapper.vue @@ -11,6 +11,7 @@
- + - + @@ -109,7 +110,7 @@ watch( return } - console.debug(`Loading project, $route.params =`, route.params, `, loadedProjectId = ${loadedProjectId.value}, currentProject = `, currentProject.value) + console.debug('Loading project, $route.params =', route.params, `, loadedProjectId = ${loadedProjectId.value}, currentProject = `, currentProject.value) // Set the current project to the one we're about to load so that the title is already shown at the top loadedProjectId.value = 0 diff --git a/frontend/src/components/project/partials/FilterInput.vue b/frontend/src/components/project/partials/FilterInput.vue index 46f6008bb..560fe8438 100644 --- a/frontend/src/components/project/partials/FilterInput.vue +++ b/frontend/src/components/project/partials/FilterInput.vue @@ -21,11 +21,8 @@ import { LABEL_FIELDS, } from '@/helpers/filters' import {useDebounceFn} from '@vueuse/core' -import {useI18n} from 'vue-i18n' import {createRandomID} from '@/helpers/randomId' -const {t} = useI18n() - const { modelValue, projectId, @@ -270,9 +267,9 @@ const blurDebounced = useDebounceFn(() => emit('blur'), 500) >