feat: decouple views from projects (#2217)

This PR decouples views from projects. On the surface, everything stays the same - by default, there are the same views as right now in main - List, Gantt, Table, Kanban. With this feature, it is possible to modify these or create new ones. That means you can remove views you never need or create multiple ones if you need different configurations.

Each view can have an optional filter to change what you see in the frontend on that view. For kanban, you can either set it to "manual" mode, where you can create buckets and move tasks around, or "filter" mode, where each bucket is the result of a filter (and you cannot move them around).

All positions and buckets are now tied to the view, not the project. This means you can (finally!) have views for saved filters.

Reviewed-on: vikunja/vikunja#2217
This commit is contained in:
konrad 2024-03-19 19:16:11 +00:00
commit 7230db1603
100 changed files with 7246 additions and 2206 deletions

View File

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

View File

@ -1,15 +1,50 @@
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {ProjectViewFactory} from "../../factories/project_view";
export function createDefaultViews(projectId) {
ProjectViewFactory.truncate()
const list = ProjectViewFactory.create(1, {
id: 1,
project_id: projectId,
view_kind: 0,
}, false)
const gantt = ProjectViewFactory.create(1, {
id: 2,
project_id: projectId,
view_kind: 1,
}, false)
const table = ProjectViewFactory.create(1, {
id: 3,
project_id: projectId,
view_kind: 2,
}, false)
const kanban = ProjectViewFactory.create(1, {
id: 4,
project_id: projectId,
view_kind: 3,
bucket_configuration_mode: 1,
}, false)
return [
list[0],
gantt[0],
table[0],
kanban[0],
]
}
export function createProjects() {
const projects = ProjectFactory.create(1, {
title: 'First Project'
})
TaskFactory.truncate()
projects.views = createDefaultViews(projects[0].id)
return projects
}
export function prepareProjects(setProjects = (...args: any[]) => {}) {
export function prepareProjects(setProjects = (...args: any[]) => {
}) {
beforeEach(() => {
const projects = createProjects()
setProjects(projects)

View File

@ -2,6 +2,7 @@ import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
import {ProjectViewFactory} from '../../factories/project_view'
describe('Project History', () => {
createFakeUserAndLogin()
@ -12,23 +13,28 @@ describe('Project History', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
const projects = ProjectFactory.create(6)
ProjectViewFactory.truncate()
projects.forEach(p => ProjectViewFactory.create(1, {
id: p.id,
project_id: p.id,
}, false))
cy.visit('/')
cy.wait('@loadProjectArray')
cy.get('body')
.should('not.contain', 'Last viewed')
cy.visit(`/projects/${projects[0].id}`)
cy.visit(`/projects/${projects[0].id}/${projects[0].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[1].id}`)
cy.visit(`/projects/${projects[1].id}/${projects[1].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[2].id}`)
cy.visit(`/projects/${projects[2].id}/${projects[2].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[3].id}`)
cy.visit(`/projects/${projects[3].id}/${projects[3].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[4].id}`)
cy.visit(`/projects/${projects[4].id}/${projects[4].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[5].id}`)
cy.visit(`/projects/${projects[5].id}/${projects[5].id}`)
cy.wait('@loadProject')
// cy.visit('/')

View File

@ -11,7 +11,7 @@ describe('Project View Gantt', () => {
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/gantt')
cy.visit('/projects/1/2')
cy.get('.g-gantt-rows-container')
.should('not.contain', tasks[0].title)
@ -25,7 +25,7 @@ describe('Project View Gantt', () => {
nextMonth.setDate(1)
nextMonth.setMonth(9)
cy.visit('/projects/1/gantt')
cy.visit('/projects/1/2')
cy.get('.g-timeunits-container')
.should('contain', format(now, 'MMMM'))
@ -38,7 +38,7 @@ describe('Project View Gantt', () => {
start_date: now.toISOString(),
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
})
cy.visit('/projects/1/gantt')
cy.visit('/projects/1/2')
cy.get('.g-gantt-rows-container')
.should('not.be.empty')
@ -50,7 +50,7 @@ describe('Project View Gantt', () => {
start_date: null,
end_date: null,
})
cy.visit('/projects/1/gantt')
cy.visit('/projects/1/2')
cy.get('.gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set')
@ -69,7 +69,7 @@ describe('Project View Gantt', () => {
start_date: now.toISOString(),
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
})
cy.visit('/projects/1/gantt')
cy.visit('/projects/1/2')
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
.first()
@ -83,7 +83,7 @@ describe('Project View Gantt', () => {
const now = Date.UTC(2022, 10, 9)
cy.clock(now, ['Date'])
cy.visit('/projects/1/gantt')
cy.visit('/projects/1/2')
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
.click()
@ -99,7 +99,7 @@ describe('Project View Gantt', () => {
})
it('Should change the date range based on date query parameters', () => {
cy.visit('/projects/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.visit('/projects/1/2?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.get('.g-timeunits-container')
.should('contain', 'September 2022')
@ -115,7 +115,7 @@ describe('Project View Gantt', () => {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
})
cy.visit('/projects/1/gantt')
cy.visit('/projects/1/2')
cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar')
.dblclick()

View File

@ -4,35 +4,65 @@ import {BucketFactory} from '../../factories/bucket'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
import {ProjectViewFactory} from "../../factories/project_view";
import {TaskBucketFactory} from "../../factories/task_buckets";
function createSingleTaskInBucket(count = 1, attrs = {}) {
const projects = ProjectFactory.create(1)
const buckets = BucketFactory.create(2, {
const views = ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = BucketFactory.create(2, {
project_view_id: views[0].id,
})
const tasks = TaskFactory.create(count, {
project_id: projects[0].id,
bucket_id: buckets[0].id,
...attrs,
})
return tasks[0]
TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
return {
task: tasks[0],
view: views[0],
project: projects[0],
}
}
function createTaskWithBuckets(buckets, count = 1) {
const data = TaskFactory.create(10, {
project_id: 1,
})
TaskBucketFactory.truncate()
data.forEach(t => TaskBucketFactory.create(count, {
task_id: t.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false))
return data
}
describe('Project View Kanban', () => {
createFakeUserAndLogin()
prepareProjects()
let buckets
beforeEach(() => {
buckets = BucketFactory.create(2)
buckets = BucketFactory.create(2, {
project_view_id: 4,
})
})
it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
const data = createTaskWithBuckets(buckets, 10)
cy.visit('/projects/1/4')
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
@ -46,11 +76,8 @@ describe('Project View Kanban', () => {
})
it('Can add a new task to a bucket', () => {
TaskFactory.create(2, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
createTaskWithBuckets(buckets, 2)
cy.visit('/projects/1/4')
cy.get('.kanban .bucket')
.contains(buckets[0].title)
@ -68,7 +95,7 @@ describe('Project View Kanban', () => {
})
it('Can create a new bucket', () => {
cy.visit('/projects/1/kanban')
cy.visit('/projects/1/4')
cy.get('.kanban .bucket.new-bucket .button')
.click()
@ -82,7 +109,7 @@ describe('Project View Kanban', () => {
})
it('Can set a bucket limit', () => {
cy.visit('/projects/1/kanban')
cy.visit('/projects/1/4')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
@ -103,7 +130,7 @@ describe('Project View Kanban', () => {
})
it('Can rename a bucket', () => {
cy.visit('/projects/1/kanban')
cy.visit('/projects/1/4')
cy.get('.kanban .bucket .bucket-header .title')
.first()
@ -114,7 +141,7 @@ describe('Project View Kanban', () => {
})
it('Can delete a bucket', () => {
cy.visit('/projects/1/kanban')
cy.visit('/projects/1/4')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
@ -137,17 +164,14 @@ describe('Project View Kanban', () => {
})
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
const tasks = createTaskWithBuckets(buckets, 2)
cy.visit('/projects/1/4')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
cy.get('.kanban .bucket:nth-child(1) .tasks')
@ -155,12 +179,8 @@ describe('Project View Kanban', () => {
})
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
const tasks = createTaskWithBuckets(buckets, 5)
cy.visit('/projects/1/4')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
@ -168,28 +188,33 @@ describe('Project View Kanban', () => {
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
.should('contain', `/tasks/${tasks[0].id}`, {timeout: 1000})
})
it('Should remove a task from the kanban board when moving it to another project', () => {
const projects = ProjectFactory.create(2)
BucketFactory.create(2, {
const views = ProjectViewFactory.create(2, {
project_id: '{increment}',
view_kind: 3,
bucket_configuration_mode: 1,
})
BucketFactory.create(2)
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
bucket_id: 1,
})
TaskBucketFactory.create(5, {
project_view_id: 1,
})
const task = tasks[0]
cy.visit('/projects/1/kanban')
cy.visit('/projects/1/'+views[0].id)
cy.get('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
cy.get('.task-view .action-buttons .button', { timeout: 3000 })
cy.get('.task-view .action-buttons .button', {timeout: 3000})
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
@ -201,27 +226,23 @@ describe('Project View Kanban', () => {
.first()
.click()
cy.get('.global-notification', { timeout: 1000 })
cy.get('.global-notification', {timeout: 1000})
.should('contain', 'Success')
cy.go('back')
cy.get('.kanban .bucket')
.should('not.contain', task.title)
})
it('Shows a button to filter the kanban board', () => {
const data = TaskFactory.create(10, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.visit('/projects/1/4')
cy.get('.project-kanban .filter-container .base-button')
.should('exist')
})
it('Should remove a task from the board when deleting it', () => {
const task = createSingleTaskInBucket(5)
cy.visit('/projects/1/kanban')
const {task, view} = createSingleTaskInBucket(5)
cy.visit(`/projects/1/${view.id}`)
cy.get('.kanban .bucket .tasks .task')
.contains(task.title)
@ -239,18 +260,18 @@ describe('Project View Kanban', () => {
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.kanban .bucket .tasks')
.should('not.contain', task.title)
})
it('Should show a task description icon if the task has a description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
const task = createSingleTaskInBucket(1, {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
const {task, view} = createSingleTaskInBucket(1, {
description: 'Lorem Ipsum',
})
cy.visit(`/projects/${task.project_id}/kanban`)
cy.visit(`/projects/${task.project_id}/${view.id}`)
cy.wait('@loadTasks')
cy.get('.bucket .tasks .task .footer .icon svg')
@ -258,12 +279,12 @@ describe('Project View Kanban', () => {
})
it('Should not show a task description icon if the task has an empty description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
const task = createSingleTaskInBucket(1, {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
const {task, view} = createSingleTaskInBucket(1, {
description: '',
})
cy.visit(`/projects/${task.project_id}/kanban`)
cy.visit(`/projects/${task.project_id}/${view.id}`)
cy.wait('@loadTasks')
cy.get('.bucket .tasks .task .footer .icon svg')
@ -271,15 +292,15 @@ describe('Project View Kanban', () => {
})
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
const task = createSingleTaskInBucket(1, {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
const {task, view} = createSingleTaskInBucket(1, {
description: '<p></p>',
})
cy.visit(`/projects/${task.project_id}/kanban`)
cy.visit(`/projects/${task.project_id}/${view.id}`)
cy.wait('@loadTasks')
cy.get('.bucket .tasks .task .footer .icon svg')
.should('not.exist')
})
})
})

View File

@ -5,15 +5,16 @@ import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
import {BucketFactory} from '../../factories/bucket'
describe('Project View Project', () => {
describe('Project View List', () => {
createFakeUserAndLogin()
prepareProjects()
it('Should be an empty project', () => {
cy.visit('/projects/1')
cy.url()
.should('contain', '/projects/1/list')
.should('contain', '/projects/1/1')
cy.get('.project-title')
.should('contain', 'First Project')
cy.get('.project-title-dropdown')
@ -24,6 +25,10 @@ describe('Project View Project', () => {
})
it('Should create a new task', () => {
BucketFactory.create(2, {
project_view_id: 4,
})
const newTaskTitle = 'New task'
cy.visit('/projects/1')
@ -38,7 +43,7 @@ describe('Project View Project', () => {
id: '{increment}',
project_id: 1,
})
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.get('.tasks .task .tasktext')
.contains(tasks[0].title)
@ -88,10 +93,10 @@ describe('Project View Project', () => {
title: i => `task${i}`,
project_id: 1,
})
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.get('.tasks')
.should('contain', tasks[1].title)
.should('contain', tasks[20].title)
cy.get('.tasks')
.should('not.contain', tasks[99].title)
@ -104,6 +109,6 @@ describe('Project View Project', () => {
cy.get('.tasks')
.should('contain', tasks[99].title)
cy.get('.tasks')
.should('not.contain', tasks[1].title)
.should('not.contain', tasks[20].title)
})
})

View File

@ -1,13 +1,15 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
describe('Project View Table', () => {
createFakeUserAndLogin()
prepareProjects()
it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/table')
cy.visit('/projects/1/3')
cy.get('.project-table table.table')
.should('exist')
@ -17,7 +19,7 @@ describe('Project View Table', () => {
it('Should have working column switches', () => {
TaskFactory.create(1)
cy.visit('/projects/1/table')
cy.visit('/projects/1/3')
cy.get('.project-table .filter-container .items .button')
.contains('Columns')
@ -42,7 +44,7 @@ describe('Project View Table', () => {
id: '{increment}',
project_id: 1,
})
cy.visit('/projects/1/table')
cy.visit('/projects/1/3')
cy.get('.project-table table.table')
.contains(tasks[0].title)

View File

@ -33,14 +33,14 @@ describe('Projects', () => {
})
it('Should redirect to a specific project view after visited', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/*/buckets*').as('loadBuckets')
cy.visit('/projects/1/kanban')
cy.intercept(Cypress.env('API_URL') + '/projects/*/views/*/tasks**').as('loadBuckets')
cy.visit('/projects/1/4')
cy.url()
.should('contain', '/projects/1/kanban')
.should('contain', '/projects/1/4')
cy.wait('@loadBuckets')
cy.visit('/projects/1')
cy.url()
.should('contain', '/projects/1/kanban')
.should('contain', '/projects/1/4')
})
it('Should rename the project in all places', () => {

View File

@ -1,9 +1,9 @@
import {LinkShareFactory} from '../../factories/link_sharing'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {createProjects} from '../project/prepareProjects'
function prepareLinkShare() {
const projects = ProjectFactory.create(1)
const projects = createProjects()
const tasks = TaskFactory.create(10, {
project_id: projects[0].id
})
@ -32,13 +32,13 @@ describe('Link shares', () => {
cy.get('.tasks')
.should('contain', tasks[0].title)
cy.url().should('contain', `/projects/${project.id}/list#share-auth-token=${share.hash}`)
cy.url().should('contain', `/projects/${project.id}/1#share-auth-token=${share.hash}`)
})
it('Should work when directly viewing a project with share hash present', () => {
const {share, project, tasks} = prepareLinkShare()
cy.visit(`/projects/${project.id}/list#share-auth-token=${share.hash}`)
cy.visit(`/projects/${project.id}/1#share-auth-token=${share.hash}`)
cy.get('h1.title')
.should('contain', project.title)

View File

@ -5,11 +5,13 @@ import {seed} from '../../support/seed'
import {TaskFactory} from '../../factories/task'
import {BucketFactory} from '../../factories/bucket'
import {updateUserSettings} from '../../support/updateUserSettings'
import {createDefaultViews} from "../project/prepareProjects";
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
const project = ProjectFactory.create()[0]
const views = createDefaultViews(project.id)
BucketFactory.create(1, {
project_id: project.id,
project_view_id: views[3].id,
})
const tasks = []
let dueDate = startDueDate
@ -60,7 +62,7 @@ describe('Home Page Task Overview', () => {
})
it('Should show a new task with a very soon due date at the top', () => {
const {tasks} = seedTasks()
const {tasks} = seedTasks(49)
const newTaskTitle = 'New Task'
cy.visit('/')
@ -71,9 +73,8 @@ describe('Home Page Task Overview', () => {
due_date: new Date().toISOString(),
}, false)
cy.visit(`/projects/${tasks[0].project_id}/list`)
cy.visit(`/projects/${tasks[0].project_id}/1`)
cy.get('.tasks .task')
.first()
.should('contain.text', newTaskTitle)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
@ -88,7 +89,7 @@ describe('Home Page Task Overview', () => {
cy.visit('/')
cy.visit(`/projects/${tasks[0].project_id}/list`)
cy.visit(`/projects/${tasks[0].project_id}/1`)
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.visit('/')

View File

@ -12,6 +12,7 @@ import {BucketFactory} from '../../factories/bucket'
import {TaskAttachmentFactory} from '../../factories/task_attachments'
import {TaskReminderFactory} from '../../factories/task_reminders'
import {createDefaultViews} from "../project/prepareProjects";
function addLabelToTaskAndVerify(labelTitle: string) {
cy.get('.task-view .action-buttons .button')
@ -53,15 +54,16 @@ describe('Task', () => {
beforeEach(() => {
// UserFactory.create(1)
projects = ProjectFactory.create(1)
const views = createDefaultViews(projects[0].id)
buckets = BucketFactory.create(1, {
project_id: projects[0].id,
project_view_id: views[3].id,
})
TaskFactory.truncate()
UserProjectFactory.truncate()
})
it('Should be created new', () => {
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.get('.input[placeholder="Add a new task…"')
.type('New Task')
cy.get('.button')
@ -75,7 +77,7 @@ describe('Task', () => {
it('Inserts new tasks at the top of the project', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.get('.project-is-empty-notice')
.should('not.exist')
cy.get('.input[placeholder="Add a new task…"')
@ -93,7 +95,7 @@ describe('Task', () => {
it('Marks a task as done', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.get('.tasks .task .fancycheckbox')
.first()
.click()
@ -104,7 +106,7 @@ describe('Task', () => {
it('Can add a task to favorites', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.get('.tasks .task .favorite')
.first()
.click()
@ -113,12 +115,12 @@ describe('Task', () => {
})
it('Should show a task description icon if the task has a description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
TaskFactory.create(1, {
description: 'Lorem Ipsum',
})
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.wait('@loadTasks')
cy.get('.tasks .task .project-task-icon')
@ -126,12 +128,12 @@ describe('Task', () => {
})
it('Should not show a task description icon if the task has an empty description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
TaskFactory.create(1, {
description: '',
})
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.wait('@loadTasks')
cy.get('.tasks .task .project-task-icon')
@ -139,12 +141,12 @@ describe('Task', () => {
})
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
TaskFactory.create(1, {
description: '<p></p>',
})
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.wait('@loadTasks')
cy.get('.tasks .task .project-task-icon')
@ -314,8 +316,9 @@ describe('Task', () => {
it('Can move a task to another project', () => {
const projects = ProjectFactory.create(2)
const views = createDefaultViews(projects[0].id)
BucketFactory.create(2, {
project_id: '{increment}',
project_view_id: views[3].id,
})
const tasks = TaskFactory.create(1, {
id: 1,
@ -469,7 +472,7 @@ describe('Task', () => {
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.visit(`/projects/${projects[0].id}/4`)
cy.get('.bucket .task')
.contains(tasks[0].title)
@ -836,7 +839,7 @@ describe('Task', () => {
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.visit(`/projects/${projects[0].id}/4`)
cy.get('.bucket .task')
.contains(tasks[0].title)

View File

@ -10,7 +10,7 @@ export class BucketFactory extends Factory {
return {
id: '{increment}',
title: faker.lorem.words(3),
project_id: 1,
project_view_id: '{increment}',
created_by_id: 1,
created: now.toISOString(),
updated: now.toISOString(),

View File

@ -0,0 +1,19 @@
import {Factory} from '../support/factory'
import {faker} from '@faker-js/faker'
export class ProjectViewFactory extends Factory {
static table = 'project_views'
static factory() {
const now = new Date()
return {
id: '{increment}',
title: faker.lorem.words(3),
project_id: '{increment}',
view_kind: 0,
created: now.toISOString(),
updated: now.toISOString(),
}
}
}

View File

@ -14,7 +14,6 @@ export class TaskFactory extends Factory {
project_id: 1,
created_by_id: 1,
index: '{increment}',
position: '{increment}',
created: now.toISOString(),
updated: now.toISOString()
}

View File

@ -0,0 +1,13 @@
import {Factory} from '../support/factory'
export class TaskBucketFactory extends Factory {
static table = 'task_buckets'
static factory() {
return {
task_id: '{increment}',
bucket_id: '{increment}',
project_view_id: '{increment}',
}
}
}

View File

@ -37,7 +37,7 @@
v-slot="{ Component }"
:route="routeWithModal"
>
<keep-alive :include="['project.list', 'project.gantt', 'project.table', 'project.kanban']">
<keep-alive :include="['project.view']">
<component :is="Component" />
</keep-alive>
</router-view>

View File

@ -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()
</script>
<style lang="scss" scoped>

View File

@ -62,7 +62,7 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
},
{
title: 'project.kanban.title',
available: (route) => route.name === 'project.kanban',
available: (route) => route.name === 'project.view',
shortcuts: [
{
title: 'keyboardShortcuts.task.done',

View File

@ -6,44 +6,17 @@
<h1 class="project-title-print">
{{ getProjectTitle(currentProject) }}
</h1>
<div class="switch-view-container d-print-none">
<div class="switch-view">
<BaseButton
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.project.switchToListView')"
v-for="v in views"
:key="v.id"
class="switch-view-button"
:class="{'is-active': viewName === 'project'}"
:to="{ name: 'project.list', params: { projectId } }"
:class="{'is-active': v.id === viewId}"
:to="{ name: 'project.view', params: { projectId, viewId: v.id } }"
>
{{ $t('project.list.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g g'"
:title="$t('keyboardShortcuts.project.switchToGanttView')"
class="switch-view-button"
:class="{'is-active': viewName === 'gantt'}"
:to="{ name: 'project.gantt', params: { projectId } }"
>
{{ $t('project.gantt.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g t'"
:title="$t('keyboardShortcuts.project.switchToTableView')"
class="switch-view-button"
:class="{'is-active': viewName === 'table'}"
:to="{ name: 'project.table', params: { projectId } }"
>
{{ $t('project.table.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g k'"
:title="$t('keyboardShortcuts.project.switchToKanbanView')"
class="switch-view-button"
:class="{'is-active': viewName === 'kanban'}"
:to="{ name: 'project.kanban', params: { projectId } }"
>
{{ $t('project.kanban.title') }}
{{ getViewTitle(v) }}
</BaseButton>
</div>
<slot name="header" />
@ -63,7 +36,7 @@
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {computed, ref, watch} from 'vue'
import {useRoute} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
@ -79,26 +52,27 @@ import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import type {IProject} from '@/modelTypes/IProject'
import type {IProjectView} from '@/modelTypes/IProjectView'
import {useI18n} from 'vue-i18n'
const props = defineProps({
projectId: {
type: Number,
required: true,
},
viewName: {
type: String,
required: true,
},
})
const {
projectId,
viewId,
} = defineProps<{
projectId: IProject['id'],
viewId: IProjectView['id'],
}>()
const route = useRoute()
const {t} = useI18n()
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const projectService = ref(new ProjectService())
const loadedProjectId = ref(0)
const currentProject = computed(() => {
const currentProject = computed<IProject>(() => {
return typeof baseStore.currentProject === 'undefined' ? {
id: 0,
title: '',
@ -108,13 +82,15 @@ const currentProject = computed(() => {
})
useTitle(() => currentProject.value?.id ? getProjectTitle(currentProject.value) : '')
const views = computed(() => currentProject.value?.views)
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
// This resulted in loading and setting the project multiple times, even when navigating away from it.
// This caused wired bugs where the project background would be set on the home page but only right after setting a new
// project background and then navigating to home. It also highlighted the project in the menu and didn't allow changing any
// of it, most likely due to the rights not being properly populated.
watch(
() => props.projectId,
() => projectId,
// loadProject
async (projectIdToLoad: number) => {
const projectData = {id: projectIdToLoad}
@ -130,11 +106,11 @@ watch(
)
&& typeof currentProject.value !== 'undefined' && currentProject.value.maxRight !== null
) {
loadedProjectId.value = props.projectId
loadedProjectId.value = projectId
return
}
console.debug(`Loading project, props.viewName = ${props.viewName}, $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
@ -149,31 +125,46 @@ watch(
const loadedProject = await projectService.value.get(project)
baseStore.handleSetCurrentProject({project: loadedProject})
} finally {
loadedProjectId.value = props.projectId
loadedProjectId.value = projectId
}
},
{immediate: true},
)
function getViewTitle(view: IProjectView) {
switch (view.title) {
case 'List':
return t('project.list.title')
case 'Gantt':
return t('project.gantt.title')
case 'Table':
return t('project.table.title')
case 'Kanban':
return t('project.kanban.title')
}
return view.title
}
</script>
<style lang="scss" scoped>
.switch-view-container {
@media screen and (max-width: $tablet) {
display: flex;
justify-content: center;
flex-direction: column;
}
@media screen and (max-width: $tablet) {
display: flex;
justify-content: center;
flex-direction: column;
}
}
.switch-view {
background: var(--white);
display: inline-flex;
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
height: $switch-view-height;
margin: 0 auto 1rem;
padding: .5rem;
background: var(--white);
display: inline-flex;
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
height: $switch-view-height;
margin: 0 auto 1rem;
padding: .5rem;
}
.switch-view-button {
@ -201,7 +192,7 @@ watch(
// FIXME: this should be in notification and set via a prop
.is-archived .notification.is-warning {
margin-bottom: 1rem;
margin-bottom: 1rem;
}
.project-title-print {
@ -209,7 +200,7 @@ watch(
font-size: 1.75rem;
text-align: center;
margin-bottom: .5rem;
@media print {
display: block;
}

View File

@ -21,13 +21,16 @@ import {
LABEL_FIELDS,
} from '@/helpers/filters'
import {useDebounceFn} from '@vueuse/core'
import {createRandomID} from '@/helpers/randomId'
const {
modelValue,
projectId,
inputLabel = undefined,
} = defineProps<{
modelValue: string,
projectId?: number,
inputLabel?: string,
}>()
const emit = defineEmits(['update:modelValue', 'blur'])
@ -38,6 +41,8 @@ const {
height,
} = useAutoHeightTextarea(filterQuery)
const id = ref(createRandomID())
watch(
() => modelValue,
() => {
@ -246,7 +251,12 @@ const blurDebounced = useDebounceFn(() => emit('blur'), 500)
<template>
<div class="field">
<label class="label">{{ $t('filters.query.title') }}</label>
<label
class="label"
:for="id"
>
{{ inputLabel ?? $t('filters.query.title') }}
</label>
<AutocompleteDropdown
:options="autocompleteResults"
@blur="filterInput?.blur()"
@ -257,10 +267,10 @@ const blurDebounced = useDebounceFn(() => emit('blur'), 500)
>
<div class="control filter-input">
<textarea
:id
ref="filterInput"
v-model="filterQuery"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"

View File

@ -19,7 +19,7 @@
</Fancycheckbox>
</div>
<FilterInputDocs/>
<FilterInputDocs />
<template
v-if="hasFooter"

View File

@ -47,6 +47,12 @@
>
{{ $t('menu.edit') }}
</DropdownItem>
<DropdownItem
:to="{ name: 'project.settings.views', params: { projectId: project.id } }"
icon="eye"
>
{{ $t('menu.views') }}
</DropdownItem>
<DropdownItem
v-if="backgroundsEnabled"
:to="{ name: 'project.settings.background', params: { projectId: project.id } }"

View File

@ -1,8 +1,24 @@
<!-- 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 <https://www.gnu.org/licenses/>. -->
<template>
<ProjectWrapper
class="project-gantt"
:project-id="filters.projectId"
view-name="gantt"
:view
>
<template #header>
<card :has-content="false">
@ -87,15 +103,19 @@ import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import TaskForm from '@/components/tasks/TaskForm.vue'
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
import {useGanttFilters} from './helpers/useGanttFilters'
import {useGanttFilters} from '../../../views/project/helpers/useGanttFilters'
import {RIGHTS} from '@/constants/rights'
import type {DateISO} from '@/types/DateISO'
import type {ITask} from '@/modelTypes/ITask'
import type {IProjectView} from '@/modelTypes/IProjectView'
type Options = Flatpickr.Options.Options
const props = defineProps<{route: RouteLocationNormalized}>()
const props = defineProps<{
route: RouteLocationNormalized
viewId: IProjectView['id']
}>()
const GanttChart = createAsyncComponent(() => import('@/components/tasks/GanttChart.vue'))
@ -111,7 +131,7 @@ const {
isLoading,
addTask,
updateTask,
} = useGanttFilters(route)
} = useGanttFilters(route, props.viewId)
const DEFAULT_DATE_RANGE_DAYS = 7

View File

@ -2,7 +2,7 @@
<ProjectWrapper
class="project-kanban"
:project-id="projectId"
view-name="kanban"
:view-id
>
<template #header>
<div class="filter-container">
@ -277,7 +277,6 @@ import {RIGHTS as Rights} from '@/constants/rights'
import BucketModel from '@/models/bucket'
import type {IBucket} from '@/modelTypes/IBucket'
import type {IProject} from '@/modelTypes/IProject'
import type {ITask} from '@/modelTypes/ITask'
import {useBaseStore} from '@/stores/base'
@ -301,11 +300,17 @@ import {isSavedFilter} from '@/services/savedFilter'
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'
import {i18n} from '@/i18n'
const {
projectId = undefined,
projectId,
viewId,
} = defineProps<{
projectId: number,
viewId: IProjectView['id'],
}>()
const DRAG_OPTIONS = {
@ -325,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<HTMLInputElement | null>(null)
@ -363,7 +369,7 @@ const params = ref<TaskFilterParams>({
const getTaskDraggableTaskComponentData = computed(() => (bucket: IBucket) => {
return {
ref: (el: HTMLElement) => setTaskContainerRef(bucket.id, el),
onScroll: (event: Event) => handleTaskContainerScroll(bucket.id, bucket.projectId, event.target as HTMLElement),
onScroll: (event: Event) => handleTaskContainerScroll(bucket.id, event.target as HTMLElement),
type: 'transition-group',
name: !drag.value ? 'move-card' : null,
class: [
@ -387,19 +393,20 @@ 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(
() => ({
params: params.value,
projectId,
viewId,
}),
({params}) => {
if (projectId === undefined || Number(projectId) === 0) {
return
}
collapsedBuckets.value = getCollapsedBucketState(projectId)
kanbanStore.loadBucketsForProject({projectId, params})
kanbanStore.loadBucketsForProject(projectId, viewId, params)
},
{
immediate: true,
@ -412,7 +419,7 @@ function setTaskContainerRef(id: IBucket['id'], el: HTMLElement) {
taskContainerRefs.value[id] = el
}
function handleTaskContainerScroll(id: IBucket['id'], projectId: IProject['id'], el: HTMLElement) {
function handleTaskContainerScroll(id: IBucket['id'], el: HTMLElement) {
if (!el) {
return
}
@ -424,6 +431,7 @@ function handleTaskContainerScroll(id: IBucket['id'], projectId: IProject['id'],
kanbanStore.loadNextTasksForBucket(
projectId,
viewId,
params.value,
id,
)
@ -473,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,
)
@ -483,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
@ -495,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: viewId,
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) {
@ -556,6 +576,7 @@ async function createNewBucket() {
await kanbanStore.createBucket(new BucketModel({
title: newBucketTitle.value,
projectId: project.value.id,
projectViewId: viewId,
}))
newBucketTitle.value = ''
}
@ -575,6 +596,7 @@ async function deleteBucket() {
bucket: new BucketModel({
id: bucketToDelete.value,
projectId: project.value.id,
projectViewId: viewId,
}),
params: params.value,
})
@ -593,10 +615,19 @@ async function focusBucketTitle(e: Event) {
}
async function saveBucketTitle(bucketId: IBucket['id'], bucketTitle: string) {
await kanbanStore.updateBucketTitle({
const bucket = kanbanStore.getBucketById(bucketId)
if (bucket?.title === bucketTitle) {
bucketTitleEditable.value = false
return
}
await kanbanStore.updateBucket({
id: bucketId,
title: bucketTitle,
projectId,
})
success({message: i18n.global.t('project.kanban.bucketTitleSavedSuccess')})
bucketTitleEditable.value = false
}
@ -616,6 +647,7 @@ function updateBucketPosition(e: { newIndex: number }) {
kanbanStore.updateBucket({
id: bucket.id,
projectId,
position: calculateItemPosition(
bucketBefore !== null ? bucketBefore.position : null,
bucketAfter !== null ? bucketAfter.position : null,
@ -630,6 +662,7 @@ async function saveBucketLimit(bucketId: IBucket['id'], limit: number) {
await kanbanStore.updateBucket({
...kanbanStore.getBucketById(bucketId),
projectId,
limit,
})
success({message: t('project.kanban.bucketLimitSavedSuccess')})

View File

@ -2,7 +2,7 @@
<ProjectWrapper
class="project-list"
:project-id="projectId"
view-name="project"
:view-id
>
<template #header>
<div class="filter-container">
@ -114,14 +114,18 @@ import type {ITask} from '@/modelTypes/ITask'
import {isSavedFilter} from '@/services/savedFilter'
import {useBaseStore} from '@/stores/base'
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,
viewId,
} = defineProps<{
projectId: IProject['id'],
viewId: IProjectView['id'],
}>()
const ctaVisible = ref(false)
@ -140,7 +144,9 @@ const {
loadTasks,
params,
sortByParam,
} = useTaskList(() => projectId, {position: 'asc'})
} = useTaskList(() => projectId, () => viewId, {position: 'asc'})
const taskPositionService = ref(new TaskPositionService())
const tasks = ref<ITask[]>([])
watch(
@ -182,7 +188,6 @@ const firstNewPosition = computed(() => {
return calculateItemPosition(null, tasks.value[0].position)
})
const taskStore = useTaskStore()
const baseStore = useBaseStore()
const project = computed(() => baseStore.currentProject)
@ -231,13 +236,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: viewId,
taskId: task.id,
}))
tasks.value[e.newIndex] = {
...task,
position,
}
}
function prepareFiltersAndLoadTasks() {

View File

@ -2,7 +2,7 @@
<ProjectWrapper
class="project-table"
:project-id="projectId"
view-name="table"
:view-id
>
<template #header>
<div class="filter-container">
@ -289,11 +289,14 @@ import {useTaskList} from '@/composables/useTaskList'
import type {ITask} from '@/modelTypes/ITask'
import type {IProject} from '@/modelTypes/IProject'
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
import type {IProjectView} from '@/modelTypes/IProjectView'
const {
projectId,
viewId,
} = defineProps<{
projectId: IProject['id'],
viewId: IProjectView['id'],
}>()
const ACTIVE_COLUMNS_DEFAULT = {
@ -320,7 +323,7 @@ const SORT_BY_DEFAULT: SortBy = {
const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT})
const sortBy = useStorage<SortBy>('tableViewSortBy', {...SORT_BY_DEFAULT})
const taskList = useTaskList(() => projectId, sortBy.value)
const taskList = useTaskList(() => projectId, () => viewId, sortBy.value)
const {
loading,

View File

@ -0,0 +1,180 @@
<script setup lang="ts">
import type {IProjectView} from '@/modelTypes/IProjectView'
import XButton from '@/components/input/button.vue'
import FilterInput from '@/components/project/partials/FilterInput.vue'
import {ref} from 'vue'
const model = defineModel<IProjectView>()
const titleValid = ref(true)
function validateTitle() {
titleValid.value = model.value.title !== ''
}
</script>
<template>
<form>
<div class="field">
<label
class="label"
for="title"
>
{{ $t('project.views.title') }}
</label>
<div class="control">
<input
id="title"
v-model="model.title"
v-focus
class="input"
:placeholder="$t('project.share.links.namePlaceholder')"
@blur="validateTitle"
>
</div>
<p
v-if="!titleValid"
class="help is-danger"
>
{{ $t('project.views.titleRequired') }}
</p>
</div>
<div class="field">
<label
class="label"
for="kind"
>
{{ $t('project.views.kind') }}
</label>
<div class="control">
<div class="select">
<select
id="kind"
v-model="model.viewKind"
>
<option value="list">
{{ $t('project.list.title') }}
</option>
<option value="gantt">
{{ $t('project.gantt.title') }}
</option>
<option value="table">
{{ $t('project.table.title') }}
</option>
<option value="kanban">
{{ $t('project.kanban.title') }}
</option>
</select>
</div>
</div>
</div>
<FilterInput
v-model="model.filter"
:input-label="$t('project.views.filter')"
/>
<div
v-if="model.viewKind === 'kanban'"
class="field"
>
<label
class="label"
for="configMode"
>
{{ $t('project.views.bucketConfigMode') }}
</label>
<div class="control">
<div class="select">
<select
id="configMode"
v-model="model.bucketConfigurationMode"
>
<option value="manual">
{{ $t('project.views.bucketConfigManual') }}
</option>
<option value="filter">
{{ $t('project.views.filter') }}
</option>
</select>
</div>
</div>
</div>
<div
v-if="model.viewKind === 'kanban' && model.bucketConfigurationMode === 'filter'"
class="field"
>
<label class="label">
{{ $t('project.views.bucketConfig') }}
</label>
<div class="control">
<div
v-for="(b, index) in model.bucketConfiguration"
:key="'bucket_'+index"
class="filter-bucket"
>
<button
class="is-danger"
@click.prevent="() => model.bucketConfiguration.splice(index, 1)"
>
<icon icon="trash-alt" />
</button>
<div class="filter-bucket-form">
<div class="field">
<label
class="label"
:for="'bucket_'+index+'_title'"
>
{{ $t('project.views.title') }}
</label>
<div class="control">
<input
:id="'bucket_'+index+'_title'"
v-model="model.bucketConfiguration[index].title"
class="input"
:placeholder="$t('project.share.links.namePlaceholder')"
>
</div>
</div>
<FilterInput
v-model="model.bucketConfiguration[index].filter"
:input-label="$t('project.views.filter')"
/>
</div>
</div>
<div class="is-flex is-justify-content-end">
<XButton
variant="secondary"
icon="plus"
@click="() => model.bucketConfiguration.push({title: '', filter: ''})"
>
{{ $t('project.kanban.addBucket') }}
</XButton>
</div>
</div>
</div>
</form>
</template>
<style scoped lang="scss">
.filter-bucket {
display: flex;
button {
background: transparent;
border: none;
color: var(--danger);
padding-right: .75rem;
cursor: pointer;
}
&-form {
margin-bottom: .5rem;
padding: .5rem;
border: 1px solid var(--grey-200);
border-radius: $radius;
width: 100%;
}
}
</style>

View File

@ -173,11 +173,11 @@
<div class="select">
<select v-model="selectedView[s.id]">
<option
v-for="(title, key) in availableViews"
:key="key"
:value="key"
v-for="(view) in availableViews"
:key="view.id"
:value="view.id"
>
{{ title }}
{{ view.title }}
</option>
</select>
</div>
@ -230,9 +230,9 @@ import LinkShareService from '@/services/linkShare'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {success} from '@/message'
import {getDisplayName} from '@/models/user'
import type {ProjectView} from '@/types/ProjectView'
import {PROJECT_VIEWS} from '@/types/ProjectView'
import {useConfigStore} from '@/stores/config'
import {useProjectStore} from '@/stores/projects'
import type {IProjectView} from '@/modelTypes/IProjectView'
const props = defineProps({
projectId: {
@ -252,17 +252,13 @@ const showDeleteModal = ref(false)
const linkIdToDelete = ref(0)
const showNewForm = ref(false)
type SelectedViewMapper = Record<IProject['id'], ProjectView>
type SelectedViewMapper = Record<IProject['id'], IProjectView['id']>
const selectedView = ref<SelectedViewMapper>({})
const availableViews = computed<Record<ProjectView, string>>(() => ({
list: t('project.list.title'),
gantt: t('project.gantt.title'),
table: t('project.table.title'),
kanban: t('project.kanban.title'),
}))
const projectStore = useProjectStore()
const availableViews = computed<IProjectView[]>(() => projectStore.projects[props.projectId]?.views || [])
const copy = useCopyToClipboard()
watch(
() => props.projectId,
@ -281,7 +277,7 @@ async function load(projectId: IProject['id']) {
const links = await linkShareService.getAll({projectId})
links.forEach((l: ILinkShare) => {
selectedView.value[l.id] = 'list'
selectedView.value[l.id] = availableViews.value[0].id
})
linkShares.value = links
}
@ -315,8 +311,8 @@ async function remove(projectId: IProject['id']) {
}
}
function getShareLink(hash: string, view: ProjectView = PROJECT_VIEWS.LIST) {
return frontendUrl.value + 'share/' + hash + '/auth?view=' + view
function getShareLink(hash: string, viewId: IProjectView['id']) {
return frontendUrl.value + 'share/' + hash + '/auth?view=' + viewId
}
</script>

View File

@ -30,7 +30,7 @@
<router-link
v-if="showProject && typeof project !== 'undefined'"
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
:to="{ name: 'project.index', params: { projectId: task.projectId } }"
class="task-project mr-1"
:class="{'mr-2': task.hexColor !== ''}"
>
@ -136,7 +136,7 @@
<router-link
v-if="showProjectSeparately"
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
:to="{ name: 'project.index', params: { projectId: task.projectId } }"
class="task-project"
>
{{ project.title }}

View File

@ -1,12 +1,14 @@
import {computed, shallowRef, watchEffect, h, type VNode} from 'vue'
import {computed, h, shallowRef, type VNode, watchEffect} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
export function useRouteWithModal() {
const router = useRouter()
const route = useRoute()
const backdropView = computed(() => route.fullPath && window.history.state.backdropView)
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const routeWithModal = computed(() => {
return backdropView.value
@ -29,7 +31,7 @@ export function useRouteWithModal() {
if (routePropsOption === true) {
routeProps = route.params
} else {
if(typeof routePropsOption === 'function') {
if (typeof routePropsOption === 'function') {
routeProps = routePropsOption(route)
} else {
routeProps = routePropsOption
@ -52,7 +54,7 @@ export function useRouteWithModal() {
}
currentModal.value = h(component, routeProps)
})
const historyState = computed(() => route.fullPath && window.history.state)
function closeModal() {
@ -60,12 +62,23 @@ export function useRouteWithModal() {
// If the current project was changed because the user moved the currently opened task while coming from kanban,
// we need to reflect that change in the route when they close the task modal.
// The last route is only available as resolved string, therefore we need to use a regex for matching here
const kanbanRouteMatch = new RegExp('\\/projects\\/\\d+\\/kanban', 'g')
const kanbanRouter = {name: 'project.kanban', params: {projectId: baseStore.currentProject?.id}}
if (kanbanRouteMatch.test(historyState.value.back)
&& baseStore.currentProject
&& historyState.value.back !== router.resolve(kanbanRouter).fullPath) {
router.push(kanbanRouter)
const routeMatch = new RegExp('\\/projects\\/\\d+\\/(\\d+)', 'g')
const match = routeMatch.exec(historyState.value.back)
if (match !== null && baseStore.currentProject) {
let viewId: string | number = match[1]
if (!viewId) {
viewId = projectStore.projects[baseStore.currentProject?.id].views[0]?.id
}
const newRoute = {
name: 'project.view',
params: {
projectId: baseStore.currentProject?.id,
viewId,
},
}
router.push(newRoute)
return
}

View File

@ -7,6 +7,7 @@ import type {ITask} from '@/modelTypes/ITask'
import {error} from '@/message'
import type {IProject} from '@/modelTypes/IProject'
import {useAuthStore} from '@/stores/auth'
import type {IProjectView} from '@/modelTypes/IProjectView'
export type Order = 'asc' | 'desc' | 'none'
@ -54,9 +55,14 @@ const SORT_BY_DEFAULT: SortBy = {
/**
* This mixin provides a base set of methods and properties to get tasks.
*/
export function useTaskList(projectIdGetter: ComputedGetter<IProject['id']>, sortByDefault: SortBy = SORT_BY_DEFAULT) {
export function useTaskList(
projectIdGetter: ComputedGetter<IProject['id']>,
projectViewIdGetter: ComputedGetter<IProjectView['id']>,
sortByDefault: SortBy = SORT_BY_DEFAULT,
) {
const projectId = computed(() => projectIdGetter())
const projectViewId = computed(() => projectViewIdGetter())
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
@ -87,7 +93,10 @@ export function useTaskList(projectIdGetter: ComputedGetter<IProject['id']>, sor
const getAllTasksParams = computed(() => {
return [
{projectId: projectId.value},
{
projectId: projectId.value,
viewId: projectViewId.value,
},
{
...allParams.value,
filter_timezone: authStore.settings.timezone,

View File

@ -1,64 +1,17 @@
import type { RouteRecordName } from 'vue-router'
import router from '@/router'
import type {IProject} from '@/modelTypes/IProject'
export type ProjectRouteName = Extract<RouteRecordName, string>
export type ProjectViewSettings = Record<
IProject['id'],
Extract<RouteRecordName, ProjectRouteName>
>
export type ProjectViewSettings = Record<IProject['id'], number>
const SETTINGS_KEY_PROJECT_VIEW = 'projectView'
// TODO: remove migration when releasing 1.0
type ListViewSettings = ProjectViewSettings
const SETTINGS_KEY_DEPRECATED_LIST_VIEW = 'listView'
function migrateStoredProjectRouteSettings() {
try {
const listViewSettingsString = localStorage.getItem(SETTINGS_KEY_DEPRECATED_LIST_VIEW)
if (listViewSettingsString === null) {
return
}
// A) the first version stored one setting for all lists in a string
if (listViewSettingsString.startsWith('list.')) {
const projectView = listViewSettingsString.replace('list.', 'project.')
if (!router.hasRoute(projectView)) {
return
}
return projectView as RouteRecordName
}
// B) the last version used a 'list.' prefix
const listViewSettings: ListViewSettings = JSON.parse(listViewSettingsString)
const projectViewSettingEntries = Object.entries(listViewSettings).map(([id, value]) => {
return [id, value.replace('list.', 'project.')]
})
const projectViewSettings = Object.fromEntries(projectViewSettingEntries)
localStorage.setItem(SETTINGS_KEY_PROJECT_VIEW, JSON.stringify(projectViewSettings))
} catch(e) {
//
} finally {
localStorage.removeItem(SETTINGS_KEY_DEPRECATED_LIST_VIEW)
}
}
/**
* Save the current project view to local storage
*/
export function saveProjectView(projectId: IProject['id'], routeName: string) {
if (routeName.includes('settings.')) {
export function saveProjectView(projectId: IProject['id'], viewId: number) {
if (!projectId || !viewId) {
return
}
if (!projectId) {
return
}
// We use local storage and not the store here to make it persistent across reloads.
const savedProjectView = localStorage.getItem(SETTINGS_KEY_PROJECT_VIEW)
let savedProjectViewSettings: ProjectViewSettings | false = false
@ -71,30 +24,19 @@ export function saveProjectView(projectId: IProject['id'], routeName: string) {
projectViewSettings = savedProjectViewSettings
}
projectViewSettings[projectId] = routeName
projectViewSettings[projectId] = viewId
localStorage.setItem(SETTINGS_KEY_PROJECT_VIEW, JSON.stringify(projectViewSettings))
}
export const getProjectView = (projectId: IProject['id']) => {
// TODO: remove migration when releasing 1.0
const migratedProjectView = migrateStoredProjectRouteSettings()
if (migratedProjectView !== undefined && router.hasRoute(migratedProjectView)) {
return migratedProjectView
export function getProjectViewId(projectId: IProject['id']): number {
const projectViewSettingsString = localStorage.getItem(SETTINGS_KEY_PROJECT_VIEW)
if (!projectViewSettingsString) {
return 0
}
try {
const projectViewSettingsString = localStorage.getItem(SETTINGS_KEY_PROJECT_VIEW)
if (!projectViewSettingsString) {
throw new Error()
}
const projectViewSettings = JSON.parse(projectViewSettingsString) as ProjectViewSettings
if (!router.hasRoute(projectViewSettings[projectId])) {
throw new Error()
}
return projectViewSettings[projectId]
} catch (e) {
return
}
const projectViewSettings = JSON.parse(projectViewSettingsString) as ProjectViewSettings
if (isNaN(projectViewSettings[projectId])) {
return 0
}
return projectViewSettings[projectId]
}

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import type {IAbstract} from './IAbstract'
import type {ITask} from './ITask'
import type {IUser} from './IUser'
import type {ISubscription} from './ISubscription'
import type {IProjectView} from '@/modelTypes/IProjectView'
export interface IProject extends IAbstract {
@ -21,6 +22,7 @@ export interface IProject extends IAbstract {
parentProjectId: number
doneBucketId: number
defaultBucketId: number
views: IProjectView[]
created: Date
updated: Date

View File

@ -0,0 +1,31 @@
import type {IAbstract} from './IAbstract'
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: ProjectViewKind
filter: string
position: number
bucketConfigurationMode: ProjectViewBucketConfigurationMode
bucketConfiguration: IProjectViewBucketConfiguration[]
defaultBucketId: number
doneBucketId: number
created: Date
updated: Date
}

View File

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

View File

@ -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<IProject> implements IProject {
id = 0
@ -25,6 +26,7 @@ export default class ProjectModel extends AbstractModel<IProject> 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<IProject> 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)
}

View File

@ -0,0 +1,29 @@
import type {IProjectView, ProjectViewBucketConfigurationMode, ProjectViewKind} from '@/modelTypes/IProjectView'
import AbstractModel from '@/models/abstractModel'
export default class ProjectViewModel extends AbstractModel<IProjectView> 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<IProjectView>) {
super()
this.assignData(data)
if (!this.bucketConfiguration) {
this.bucketConfiguration = []
}
}
}

View File

@ -0,0 +1,13 @@
import AbstractModel from '@/models/abstractModel'
import type {ITaskPosition} from '@/modelTypes/ITaskPosition'
export default class TaskPositionModel extends AbstractModel<ITaskPosition> implements ITaskPosition {
position = 0
projectViewId = 0
taskId = 0
constructor(data: Partial<ITaskPosition>) {
super()
this.assignData(data)
}
}

View File

@ -2,13 +2,11 @@ import { createRouter, createWebHistory } from 'vue-router'
import type { RouteLocation } from 'vue-router'
import {saveLastVisited} from '@/helpers/saveLastVisited'
import {saveProjectView, getProjectView} from '@/helpers/projectView'
import {saveProjectView, getProjectViewId} from '@/helpers/projectView'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import {getNextWeekDate} from '@/helpers/time/getNextWeekDate'
import {setTitle} from '@/helpers/setTitle'
import {LINK_SHARE_HASH_PREFIX} from '@/constants/linkShareHash'
import {useProjectStore} from '@/stores/projects'
import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
@ -33,15 +31,8 @@ const NewLabelComponent = () => import('@/views/labels/NewLabel.vue')
// Migration
const MigrationComponent = () => import('@/views/migrate/Migration.vue')
const MigrationHandlerComponent = () => import('@/views/migrate/MigrationHandler.vue')
// Project Views
const ProjectList = () => import('@/views/project/ProjectList.vue')
const ProjectGantt = () => import('@/views/project/ProjectGantt.vue')
const ProjectTable = () => import('@/views/project/ProjectTable.vue')
// If we load the component async, using it as a backdrop view will not work. Instead, everything explodes
// with an error from the core saying "Cannot read properties of undefined (reading 'parentNode')"
// Of course, with no clear indicator of where the problem comes from.
// const ProjectKanban = () => import('@/views/project/ProjectKanban.vue')
import ProjectKanban from '@/views/project/ProjectKanban.vue'
// Project View
import ProjectView from '@/views/project/ProjectView.vue'
const ProjectInfo = () => import('@/views/project/ProjectInfo.vue')
// Project Settings
@ -53,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')
@ -315,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',
@ -346,55 +347,31 @@ const router = createRouter({
path: '/projects/:projectId',
name: 'project.index',
redirect(to) {
// Redirect the user to list view by default
const savedProjectView = getProjectView(Number(to.params.projectId as string))
const viewId = getProjectViewId(Number(to.params.projectId as string))
console.log(viewId)
if (savedProjectView) {
console.log('Replaced list view with', savedProjectView)
if (viewId) {
console.debug('Replaced list view with', viewId)
}
return {
name: savedProjectView || 'project.list',
params: {projectId: to.params.projectId},
name: 'project.view',
params: {
projectId: parseInt(to.params.projectId as string),
viewId: viewId ?? 0,
},
}
},
},
{
path: '/projects/:projectId/list',
name: 'project.list',
component: ProjectList,
beforeEnter: (to) => saveProjectView(to.params.projectId, to.name),
props: route => ({ projectId: Number(route.params.projectId as string) }),
},
{
path: '/projects/:projectId/gantt',
name: 'project.gantt',
component: ProjectGantt,
beforeEnter: (to) => saveProjectView(to.params.projectId, to.name),
// FIXME: test if `useRoute` would be the same. If it would use it instead.
props: route => ({route}),
},
{
path: '/projects/:projectId/table',
name: 'project.table',
component: ProjectTable,
beforeEnter: (to) => saveProjectView(to.params.projectId, to.name),
props: route => ({ projectId: Number(route.params.projectId as string) }),
},
{
path: '/projects/:projectId/kanban',
name: 'project.kanban',
component: ProjectKanban,
beforeEnter: (to) => {
saveProjectView(to.params.projectId, to.name)
// Properly set the page title when a task popup is closed
const projectStore = useProjectStore()
const projectFromStore = projectStore.projects[Number(to.params.projectId)]
if(projectFromStore) {
setTitle(projectFromStore.title)
}
},
props: route => ({ projectId: Number(route.params.projectId as string) }),
path: '/projects/:projectId/:viewId',
name: 'project.view',
component: ProjectView,
beforeEnter: (to) => saveProjectView(parseInt(to.params.projectId as string), parseInt(to.params.viewId as string)),
props: route => ({
projectId: parseInt(route.params.projectId as string),
viewId: route.params.viewId ? parseInt(route.params.viewId as string): undefined,
}),
},
{
path: '/teams',

View File

@ -6,10 +6,10 @@ import type { IBucket } from '@/modelTypes/IBucket'
export default class BucketService extends AbstractService<IBucket> {
constructor() {
super({
getAll: '/projects/{projectId}/buckets',
create: '/projects/{projectId}/buckets',
update: '/projects/{projectId}/buckets/{id}',
delete: '/projects/{projectId}/buckets/{id}',
getAll: '/projects/{projectId}/views/{projectViewId}/buckets',
create: '/projects/{projectId}/views/{projectViewId}/buckets',
update: '/projects/{projectId}/views/{projectViewId}/buckets/{id}',
delete: '/projects/{projectId}/views/{projectViewId}/buckets/{id}',
})
}

View File

@ -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<IProjectView> {
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<IAbstract>): ProjectViewModel {
return new ProjectViewModel(data)
}
}

View File

@ -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')[],
@ -27,11 +28,15 @@ export function getDefaultTaskFilterParams(): TaskFilterParams {
export default class TaskCollectionService extends AbstractService<ITask> {
constructor() {
super({
getAll: '/projects/{projectId}/tasks',
getAll: '/projects/{projectId}/views/{viewId}/tasks',
})
}
modelFactory(data) {
// FIXME: There must be a better way for this…
if (typeof data.project_view_id !== 'undefined') {
return new BucketModel(data)
}
return new TaskModel(data)
}
}

View File

@ -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<ITaskPosition> {
constructor() {
super({
update: '/tasks/{taskId}/position',
})
}
modelFactory(data: Partial<ITaskPosition>) {
return new TaskPositionModel(data)
}
}

View File

@ -3,8 +3,6 @@ import {acceptHMRUpdate, defineStore} from 'pinia'
import {klona} from 'klona/lite'
import {findById, findIndexById} from '@/helpers/utils'
import {i18n} from '@/i18n'
import {success} from '@/message'
import BucketService from '@/services/bucket'
import TaskCollectionService, {type TaskFilterParams} from '@/services/taskCollection'
@ -15,6 +13,7 @@ import type {ITask} from '@/modelTypes/ITask'
import type {IProject} from '@/modelTypes/IProject'
import type {IBucket} from '@/modelTypes/IBucket'
import {useAuthStore} from '@/stores/auth'
import type {IProjectView} from '@/modelTypes/IProjectView'
const TASKS_PER_BUCKET = 25
@ -176,10 +175,7 @@ export const useKanbanStore = defineStore('kanban', () => {
buckets.value[bucketIndex] = newBucket
}
function addTasksToBucket({tasks, bucketId}: {
tasks: ITask[];
bucketId: IBucket['id'];
}) {
function addTasksToBucket(tasks: ITask[], bucketId: IBucket['id']) {
const bucketIndex = findIndexById(buckets.value, bucketId)
const oldBucket = buckets.value[bucketIndex]
const newBucket = {
@ -225,15 +221,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,
})
@ -247,6 +243,7 @@ export const useKanbanStore = defineStore('kanban', () => {
async function loadNextTasksForBucket(
projectId: IProject['id'],
viewId: IProjectView['id'],
ps: TaskFilterParams,
bucketId: IBucket['id'],
) {
@ -267,7 +264,7 @@ export const useKanbanStore = defineStore('kanban', () => {
const params: TaskFilterParams = JSON.parse(JSON.stringify(ps))
params.sort_by = ['kanban_position']
params.sort_by = ['position']
params.order_by = ['asc']
params.filter = `${params.filter === '' ? '' : params.filter + ' && '}bucket_id = ${bucketId}`
params.filter_timezone = authStore.settings.timezone
@ -275,8 +272,8 @@ export const useKanbanStore = defineStore('kanban', () => {
const taskService = new TaskCollectionService()
try {
const tasks = await taskService.getAll({projectId}, params, page)
addTasksToBucket({tasks, bucketId: bucketId})
const tasks = await taskService.getAll({projectId, viewId}, params, page)
addTasksToBucket(tasks, bucketId)
setTasksLoadedForBucketPage({bucketId, page})
if (taskService.totalPages <= page) {
setAllTasksLoadedForBucket(bucketId)
@ -309,7 +306,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()
@ -344,18 +341,6 @@ export const useKanbanStore = defineStore('kanban', () => {
}
}
async function updateBucketTitle({id, title}: { id: IBucket['id'], title: IBucket['title'] }) {
const bucket = findById(buckets.value, id)
if (bucket?.title === title) {
// bucket title has not changed
return
}
await updateBucket({id, title})
success({message: i18n.global.t('project.kanban.bucketTitleSavedSuccess')})
}
return {
buckets,
isLoading: readonly(isLoading),
@ -374,7 +359,6 @@ export const useKanbanStore = defineStore('kanban', () => {
createBucket,
deleteBucket,
updateBucket,
updateBucketTitle,
}
})

View File

@ -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,27 @@ 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
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']) {
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 +256,8 @@ export const useProjectStore = defineStore('project', () => {
updateProject,
deleteProject,
getAncestors,
setProjectView,
removeProjectView,
}
})

View File

@ -28,7 +28,7 @@ import {useKanbanStore} from '@/stores/kanban'
import {useBaseStore} from '@/stores/base'
import ProjectUserService from '@/services/projectUsers'
import {useAuthStore} from '@/stores/auth'
import TaskCollectionService, {type TaskFilterParams} from '@/services/taskCollection'
import {type TaskFilterParams} from '@/services/taskCollection'
import {getRandomColorHex} from '@/helpers/color/randomColor'
interface MatchedAssignee extends IUser {
@ -124,21 +124,23 @@ export const useTaskStore = defineStore('task', () => {
})
}
async function loadTasks(params: TaskFilterParams, projectId: IProject['id'] | null = null) {
async function loadTasks(
params: TaskFilterParams,
projectId: IProject['id'] | null = null,
) {
if (!params.filter_timezone || params.filter_timezone === '') {
params.filter_timezone = authStore.settings.timezone
}
if (projectId !== null) {
params.filter = 'project = '+projectId+' && (' + params.filter +')'
}
const cancel = setModuleLoading(setIsLoading)
try {
if (projectId === null) {
const taskService = new TaskService()
tasks.value = await taskService.getAll({}, params)
} else {
const taskCollectionService = new TaskCollectionService()
tasks.value = await taskCollectionService.getAll({projectId}, params)
}
const taskService = new TaskService()
tasks.value = await taskService.getAll({}, params)
baseStore.setHasTasks(tasks.value.length > 0)
return tasks.value
} finally {

View File

@ -1,8 +0,0 @@
export const PROJECT_VIEWS = {
LIST: 'list',
GANTT: 'gantt',
TABLE: 'table',
KANBAN: 'kanban',
} as const
export type ProjectView = typeof PROJECT_VIEWS[keyof typeof PROJECT_VIEWS]

View File

@ -0,0 +1,80 @@
<script setup lang="ts">
import {computed, watch} from 'vue'
import {useProjectStore} from '@/stores/projects'
import {useRoute, useRouter} from 'vue-router'
import ProjectList from '@/components/project/views/ProjectList.vue'
import ProjectGantt from '@/components/project/views/ProjectGantt.vue'
import ProjectTable from '@/components/project/views/ProjectTable.vue'
import ProjectKanban from '@/components/project/views/ProjectKanban.vue'
const {
projectId,
viewId,
} = defineProps<{
projectId: number,
viewId: number,
}>()
const router = useRouter()
const projectStore = useProjectStore()
const currentView = computed(() => {
const project = projectStore.projects[projectId]
return project?.views.find(v => v.id === viewId)
})
function redirectToFirstViewIfNecessary() {
if (viewId === 0) {
// Ideally, we would do that in the router redirect, but the projects (and therefore, the views)
// are not always loaded then.
const firstViewId = projectStore.projects[projectId]?.views[0].id
if (firstViewId) {
router.replace({
name: 'project.view',
params: {
projectId,
viewId: firstViewId,
},
})
}
}
}
watch(
() => viewId,
redirectToFirstViewIfNecessary,
{immediate: true},
)
watch(
() => projectStore.projects[projectId],
redirectToFirstViewIfNecessary,
)
const route = useRoute()
</script>
<template>
<ProjectList
v-if="currentView?.viewKind === 'list'"
:project-id="projectId"
:view-id
/>
<ProjectGantt
v-if="currentView?.viewKind === 'gantt'"
:route
:view-id
/>
<ProjectTable
v-if="currentView?.viewKind === 'table'"
:project-id="projectId"
:view-id
/>
<ProjectKanban
v-if="currentView?.viewKind === 'kanban'"
:project-id="projectId"
:view-id
/>
</template>

View File

@ -12,10 +12,12 @@ 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 {
projectId: IProject['id']
viewId: IProjectView['id'],
dateFrom: DateISO
dateTo: DateISO
showTasksWithoutDates: boolean
@ -41,6 +43,7 @@ function ganttRouteToFilters(route: Partial<RouteLocationNormalized>): GanttFilt
const ganttRoute = route
return {
projectId: Number(ganttRoute.params?.projectId),
viewId: Number(ganttRoute.params?.viewId),
dateFrom: parseDateProp(ganttRoute.query?.dateFrom as DateKebab) || getDefaultDateFrom(),
dateTo: parseDateProp(ganttRoute.query?.dateTo as DateKebab) || getDefaultDateTo(),
showTasksWithoutDates: parseBooleanProp(ganttRoute.query?.showTasksWithoutDates as string) || DEFAULT_SHOW_TASKS_WITHOUT_DATES,
@ -69,8 +72,11 @@ function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw {
}
return {
name: 'project.gantt',
params: {projectId: filters.projectId},
name: 'project.view',
params: {
projectId: filters.projectId,
viewId: filters.viewId,
},
query,
}
}
@ -88,7 +94,7 @@ export type UseGanttFiltersReturn =
ReturnType<typeof useRouteFilters<GanttFilters>> &
ReturnType<typeof useGanttTaskList<GanttFilters>>
export function useGanttFilters(route: Ref<RouteLocationNormalized>): UseGanttFiltersReturn {
export function useGanttFilters(route: Ref<RouteLocationNormalized>, viewId: IProjectView['id']): UseGanttFiltersReturn {
const {
filters,
hasDefaultFilters,
@ -98,7 +104,7 @@ export function useGanttFilters(route: Ref<RouteLocationNormalized>): UseGanttFi
ganttGetDefaultFilters,
ganttRouteToFilters,
ganttFiltersToRoute,
['project.gantt'],
['project.view'],
)
const {
@ -108,7 +114,7 @@ export function useGanttFilters(route: Ref<RouteLocationNormalized>): UseGanttFi
isLoading,
addTask,
updateTask,
} = useGanttTaskList<GanttFilters>(filters, ganttFiltersToApiParams)
} = useGanttTaskList<GanttFilters>(filters, ganttFiltersToApiParams, viewId)
return {
filters,

View File

@ -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<F extends Filters>(
filters: Ref<F>,
filterToApiParams: (filters: F) => TaskFilterParams,
options: {
loadAll?: boolean,
} = {
loadAll: true,
}) {
viewId: IProjectView['id'],
loadAll: boolean = true,
) {
const taskCollectionService = shallowReactive(new TaskCollectionService())
const taskService = shallowReactive(new TaskService())
const authStore = useAuthStore()
@ -29,13 +28,13 @@ export function useGanttTaskList<F extends Filters>(
const tasks = ref<Map<ITask['id'], ITask>>(new Map())
async function fetchTasks(params: TaskFilterParams, page = 1): Promise<ITask[]> {
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) {
const tasks = await taskCollectionService.getAll({projectId: filters.value.projectId, viewId}, params, page) as ITask[]
if (loadAll && page < taskCollectionService.totalPages) {
const nextTasks = await fetchTasks(params, page + 1)
return tasks.concat(nextTasks)
}

View File

@ -0,0 +1,178 @@
<script setup lang="ts">
import CreateEdit from '@/components/misc/create-edit.vue'
import {computed, ref} from 'vue'
import {useProjectStore} from '@/stores/projects'
import ProjectViewModel from '@/models/projectView'
import type {IProjectView} from '@/modelTypes/IProjectView'
import ViewEditForm from '@/components/project/views/viewEditForm.vue'
import ProjectViewService from '@/services/projectViews'
import XButton from '@/components/input/button.vue'
import {error, success} from '@/message'
import {useI18n} from 'vue-i18n'
const {
projectId,
} = defineProps<{
projectId: number
}>()
const projectStore = useProjectStore()
const {t} = useI18n()
const views = computed(() => projectStore.projects[projectId]?.views)
const showCreateForm = ref(false)
const projectViewService = ref(new ProjectViewService())
const newView = ref<IProjectView>(new ProjectViewModel({}))
const viewIdToDelete = ref<number | null>(null)
const showDeleteModal = ref(false)
const viewToEdit = ref<IProjectView | null>(null)
async function createView() {
if (!showCreateForm.value) {
showCreateForm.value = true
return
}
if (newView.value.title === '') {
return
}
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
projectStore.setProjectView(result)
newView.value = new ProjectViewModel({})
} catch (e) {
error(e)
}
}
async function deleteView() {
if (!viewIdToDelete.value) {
return
}
await projectViewService.value.delete(new ProjectViewModel({
id: viewIdToDelete.value,
projectId,
}))
projectStore.removeProjectView(projectId, viewIdToDelete.value)
showDeleteModal.value = false
}
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
}
</script>
<template>
<CreateEdit
:title="$t('project.views.header')"
:primary-label="$t('misc.save')"
>
<ViewEditForm
v-if="showCreateForm"
v-model="newView"
class="mb-4"
/>
<div class="is-flex is-justify-content-end">
<XButton
:loading="projectViewService.loading"
@click="createView"
>
{{ $t('project.views.create') }}
</XButton>
</div>
<table
v-if="views?.length > 0"
class="table has-actions is-striped is-hoverable is-fullwidth"
>
<thead>
<tr>
<th>{{ $t('project.views.title') }}</th>
<th>{{ $t('project.views.kind') }}</th>
<th class="has-text-right">
{{ $t('project.views.actions') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="v in views"
:key="v.id"
>
<template v-if="viewToEdit !== null && viewToEdit.id === v.id">
<td colspan="3">
<ViewEditForm
v-model="viewToEdit"
class="mb-4"
/>
<div class="is-flex is-justify-content-end">
<XButton
variant="tertiary"
class="mr-2"
@click="viewToEdit = null"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
:loading="projectViewService.loading"
@click="saveView"
>
{{ $t('misc.save') }}
</XButton>
</div>
</td>
</template>
<template v-else>
<td>{{ v.title }}</td>
<td>{{ v.viewKind }}</td>
<td class="has-text-right">
<XButton
class="is-danger mr-2"
icon="trash-alt"
@click="() => {
viewIdToDelete = v.id
showDeleteModal = true
}"
/>
<XButton
icon="pen"
@click="viewToEdit = {...v}"
/>
</td>
</template>
</tr>
</tbody>
</table>
</CreateEdit>
<modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="deleteView"
>
<template #header>
<span>{{ $t('project.views.delete') }}</span>
</template>
<template #text>
<p>{{ $t('project.views.deleteText') }}</p>
</template>
</modal>
</template>

View File

@ -49,7 +49,6 @@ import {useI18n} from 'vue-i18n'
import {useTitle} from '@vueuse/core'
import Message from '@/components/misc/message.vue'
import {PROJECT_VIEWS, type ProjectView} from '@/types/ProjectView'
import {LINK_SHARE_HASH_PREFIX} from '@/constants/linkShareHash'
import {useBaseStore} from '@/stores/base'
@ -96,10 +95,6 @@ function useAuth() {
: true
baseStore.setLogoVisible(logoVisible)
const view = route.query.view && Object.values(PROJECT_VIEWS).includes(route.query.view as ProjectView)
? route.query.view
: 'list'
const hash = LINK_SHARE_HASH_PREFIX + route.params.share
const last = getLastVisitedRoute()
@ -111,8 +106,10 @@ function useAuth() {
}
return router.push({
name: `project.${view}`,
params: {projectId},
name: 'project.index',
params: {
projectId,
},
hash,
})
} catch (e) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
updated: 2018-12-01 01:12:04

View File

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

View File

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

View File

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

View File

@ -0,0 +1,148 @@
// 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 <https://www.gnu.org/licenses/>.
package migration
import (
"time"
"src.techknowlogick.com/xormigrate"
"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"`
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 {
return "project_views"
}
type projects20240313230538 struct {
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view"`
}
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",
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,
}
if kind == 3 {
view.BucketConfigurationMode = 1
}
_, 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
}
}
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 {
return nil
},
})
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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 separate",
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
},
})
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
},
})
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
},
})
}

View File

@ -0,0 +1,184 @@
// 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 <https://www.gnu.org/licenses/>.
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"`
ProjectViewID 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
},
})
}

View File

@ -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
// ==============
@ -1087,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
// ============
@ -1481,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,
@ -1511,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.
@ -1522,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 view [BucketID: %d, ProjectViewID: %d]", err.BucketID, err.ProjectViewID)
}
// ErrCodeCannotRemoveLastBucket holds the unique world-error code of this error
@ -1533,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.",
}
}

View File

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

View File

@ -34,7 +34,9 @@ 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.
Tasks []*Task `xorm:"-" json:"tasks"`
@ -68,6 +70,16 @@ func (b *Bucket) TableName() string {
return "buckets"
}
type TaskBucket struct {
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) {
b = &Bucket{}
exists, err := s.Where("id = ?", id).Get(b)
@ -80,14 +92,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 {
@ -97,31 +109,26 @@ 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"
// @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]
func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) {
// @Router /projects/{id}/views/{view}/buckets [get]
func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, _ string, _ int, _ int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) {
project, err := GetProjectSimpleByID(s, b.ProjectID)
view, err := GetProjectViewByIDAndProject(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
}
@ -129,16 +136,61 @@ 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).
Where("project_view_id = ?", b.ProjectViewID).
OrderBy("position").
Find(&buckets)
if err != nil {
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, projects []*Project, 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_view_id = ?", view.ID).
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,
ProjectViewID: view.ID,
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 +211,13 @@ 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,
projectViewID: view.ID,
orderBy: orderAscending,
sortBy: taskPropertyPosition,
},
}
opts.page = page
opts.perPage = perPage
opts.search = search
for _, filter := range opts.parsedFilters {
if filter.field == taskPropertyBucketID {
@ -192,11 +237,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 {
@ -204,9 +255,13 @@ 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)
ts, _, total, err := getRawTasksForProjects(s, projects, auth, opts)
if err != nil {
return nil, 0, 0, err
return nil, err
}
for _, t := range ts {
t.BucketID = bucket.ID
}
bucket.Count = total
@ -219,9 +274,9 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int
taskMap[t.ID] = t
}
err = addMoreInfoToTasks(s, taskMap, auth)
err = addMoreInfoToTasks(s, taskMap, auth, view)
if err != nil {
return nil, 0, 0, err
return nil, err
}
// Put all tasks in their buckets
@ -230,13 +285,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
@ -247,12 +302,13 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int
// @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 {
@ -279,12 +335,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).
@ -292,6 +349,7 @@ func (b *Bucket) Update(s *xorm.Session, _ web.Auth) (err error) {
"title",
"limit",
"position",
"project_view_id",
).
Update(b)
return
@ -306,26 +364,27 @@ 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,
}
}
// Get the default bucket
p, err := GetProjectSimpleByID(s, b.ProjectID)
p, err := GetProjectViewByIDAndProject(s, b.ProjectViewID, b.ProjectID)
if err != nil {
return
}
@ -354,7 +413,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
}

View File

@ -23,8 +23,11 @@ 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,
ProjectID: b.ProjectID,
}
return pv.CanUpdate(s, a)
}
// CanUpdate checks if a user can update an existing bucket
@ -43,6 +46,9 @@ 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,
ProjectID: b.ProjectID,
}
return pv.CanUpdate(s, a)
}

View File

@ -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,23 +100,19 @@ 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)
taskIn, _, _, err := b.ReadAll(s, testuser, "", -1, 0)
require.NoError(t, err)
buckets := bucketsInterface.([]*Bucket)
assert.Len(t, buckets, 3)
assert.Empty(t, buckets[0].Tasks, 0)
assert.Len(t, buckets[1].Tasks, 3)
assert.Empty(t, buckets[2].Tasks, 0)
assert.Equal(t, int64(3), buckets[1].Tasks[0].ID)
assert.Equal(t, int64(4), buckets[1].Tasks[1].ID)
assert.Equal(t, int64(5), buckets[1].Tasks[2].ID)
tasks := taskIn.([]*Task)
assert.Len(t, tasks, 3)
assert.Equal(t, int64(3), tasks[0].ID)
assert.Equal(t, int64(4), tasks[1].ID)
assert.Equal(t, int64(5), tasks[2].ID)
})
t.Run("accessed by link share", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
@ -126,7 +124,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 +141,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 +155,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 +163,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 +188,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,15 +209,16 @@ 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,
"done_bucket_id": 3,
db.AssertMissing(t, "project_views", map[string]interface{}{
"id": b.ProjectViewID,
"done_bucket_id": 0,
})
})
}
@ -238,9 +245,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 +259,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)

View File

@ -62,6 +62,9 @@ func GetTables() []interface{} {
&TypesenseSync{},
&Webhook{},
&Reaction{},
&ProjectView{},
&TaskPosition{},
&TaskBucket{},
}
}

View File

@ -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.
@ -80,6 +78,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 +266,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 +590,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 +624,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 {
@ -713,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
@ -750,13 +775,8 @@ 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 createDefaultViews {
err = CreateDefaultViewsForProject(s, project, auth, createBacklogBucket)
if err != nil {
return
}
@ -969,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
}

View File

@ -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,94 @@ 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,
})
}
_, err = s.Insert(&taskPositions)
return
}
func duplicateProjectBackground(s *xorm.Session, pd *ProjectDuplicate, doer web.Auth) (err error) {
if pd.Project.BackgroundFileID == 0 {
return
@ -221,33 +298,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{})
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 +334,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 +352,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 +381,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 +398,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 +419,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 +436,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
}

View File

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

View File

@ -118,6 +118,16 @@ func (p *Project) CanUpdate(s *xorm.Session, a web.Auth) (canUpdate bool, err er
return false, nil
}
fid := getSavedFilterIDFromProjectID(p.ID)
if fid > 0 {
sf, err := getSavedFilterSimpleByID(s, fid)
if err != nil {
return false, err
}
return sf.CanUpdate(s, a)
}
// Get the project
ol, err := GetProjectSimpleByID(s, p.ID)
if err != nil {
@ -137,16 +147,6 @@ func (p *Project) CanUpdate(s *xorm.Session, a web.Auth) (canUpdate bool, err er
}
}
fid := getSavedFilterIDFromProjectID(p.ID)
if fid > 0 {
sf, err := getSavedFilterSimpleByID(s, fid)
if err != nil {
return false, err
}
return sf.CanUpdate(s, a)
}
canUpdate, err = p.CanWrite(s, a)
// If the project is archived and the user tries to un-archive it, let the request through
archivedErr := ErrProjectIsArchived{}

View File

@ -53,8 +53,29 @@ 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)
kanbanView := &ProjectView{}
_, err = s.Where("project_id = ? AND view_kind = ?", project.ID, ProjectViewKindKanban).Get(kanbanView)
require.NoError(t, err)
db.AssertExists(t, "buckets", map[string]interface{}{
"project_view_id": kanbanView.ID,
}, false)
})
t.Run("nonexistant parent project", func(t *testing.T) {

434
pkg/models/project_view.go Normal file
View File

@ -0,0 +1,434 @@
// 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 <https://www.gnu.org/licenses/>.
package models
import (
"encoding/json"
"fmt"
"time"
"code.vikunja.io/web"
"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
default:
return fmt.Errorf("unknown project view kind: %s", value)
}
return nil
}
const (
ProjectViewKindList ProjectViewKind = iota
ProjectViewKindGantt
ProjectViewKindTable
ProjectViewKindKanban
)
type BucketConfigurationModeKind int
const (
BucketConfigurationModeNone BucketConfigurationModeKind = iota
BucketConfigurationModeManual
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
default:
return fmt.Errorf("unknown bucket configuration mode kind: %s", value)
}
return nil
}
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"`
// The title of this view
Title string `xorm:"varchar(255) not null" json:"title" valid:"required,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"`
// 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"`
// 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"`
// 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"
}
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
// @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, err := getViewsForProject(s, p.ProjectID)
if err != nil {
return nil, 0, 0, err
}
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 := GetProjectViewByIDAndProject(s, p.ID, p.ProjectID)
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, _ web.Auth) (err error) {
_, err = s.
Where("id = ? AND project_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) {
return createProjectView(s, p, a, true)
}
func createProjectView(s *xorm.Session, p *ProjectView, a web.Auth, createBacklogBucket bool) (err error) {
_, err = s.Insert(p)
if err != nil {
return
}
if createBacklogBucket && p.BucketConfigurationMode == BucketConfigurationModeManual {
// Create a new first bucket for this project
b := &Bucket{
ProjectViewID: p.ID,
Title: "Backlog",
}
err = b.Create(s, a)
if err != nil {
return
}
// Move all tasks into the new bucket when the project already has tasks
c := &TaskCollection{
ProjectID: p.ProjectID,
}
ts, _, _, err := c.ReadAll(s, a, "", 0, -1)
if err != nil {
return err
}
tasks := ts.([]*Task)
if len(tasks) == 0 {
return nil
}
taskBuckets := []*TaskBucket{}
for _, task := range tasks {
taskBuckets = append(taskBuckets, &TaskBucket{
TaskID: task.ID,
BucketID: b.ID,
ProjectViewID: p.ID,
})
}
_, err = s.Insert(&taskBuckets)
if err != nil {
return err
}
}
return RecalculateTaskPositions(s, p, a)
}
// 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 = GetProjectViewByIDAndProject(s, p.ID, p.ProjectID)
if err != nil {
return
}
_, err = s.ID(p.ID).Update(p)
if err != nil {
return
}
return
}
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().
Get(view)
if err != nil {
return nil, err
}
if !exists {
return nil, &ErrProjectViewDoesNotExist{
ProjectViewID: id,
}
}
return
}
func GetProjectViewByID(s *xorm.Session, id int64) (view *ProjectView, err error) {
view = &ProjectView{}
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,
Title: "List",
ViewKind: ProjectViewKindList,
Position: 100,
}
err = createProjectView(s, list, a, createBacklogBucket)
if err != nil {
return
}
gantt := &ProjectView{
ProjectID: project.ID,
Title: "Gantt",
ViewKind: ProjectViewKindGantt,
Position: 200,
}
err = createProjectView(s, gantt, a, createBacklogBucket)
if err != nil {
return
}
table := &ProjectView{
ProjectID: project.ID,
Title: "Table",
ViewKind: ProjectViewKindTable,
Position: 300,
}
err = createProjectView(s, table, a, createBacklogBucket)
if err != nil {
return
}
kanban := &ProjectView{
ProjectID: project.ID,
Title: "Kanban",
ViewKind: ProjectViewKindKanban,
Position: 400,
BucketConfigurationMode: BucketConfigurationModeManual,
}
err = createProjectView(s, kanban, a, createBacklogBucket)
if err != nil {
return
}
project.Views = []*ProjectView{
list,
gantt,
table,
kanban,
}
return
}

View File

@ -0,0 +1,46 @@
// 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 <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/web"
"xorm.io/xorm"
)
func (p *ProjectView) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) {
pp := p.getProject()
return pp.CanRead(s, a)
}
func (p *ProjectView) CanDelete(s *xorm.Session, a web.Auth) (bool, error) {
pp := p.getProject()
return pp.CanUpdate(s, a)
}
func (p *ProjectView) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) {
pp := p.getProject()
return pp.CanUpdate(s, a)
}
func (p *ProjectView) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
pp := p.getProject()
return pp.CanUpdate(s, a)
}
func (p *ProjectView) getProject() (pp *Project) {
return &Project{ID: p.ProjectID}
}

View File

@ -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, true)
return err
}

View File

@ -17,14 +17,18 @@
package models
import (
"strings"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/xorm"
)
// 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"`
@ -33,7 +37,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:"-"`
@ -41,6 +45,8 @@ type TaskCollection struct {
// If set to true, the result will also include null values
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"`
isSavedFilter bool
web.CRUDable `xorm:"-" json:"-"`
web.Rights `xorm:"-" json:"-"`
}
@ -66,7 +72,6 @@ func validateTaskField(fieldName string) error {
taskPropertyCreated,
taskPropertyUpdated,
taskPropertyPosition,
taskPropertyKanbanPosition,
taskPropertyBucketID,
taskPropertyIndex:
return nil
@ -74,7 +79,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 +100,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
@ -113,6 +122,45 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection) (opts *taskSearchOption
return opts, err
}
func getTaskOrTasksInBuckets(s *xorm.Session, a web.Auth, projects []*Project, view *ProjectView, opts *taskSearchOptions) (tasks interface{}, resultCount int, totalItems int64, err error) {
if view != nil && !strings.Contains(opts.filter, "bucket_id") {
if view.BucketConfigurationMode != BucketConfigurationModeNone {
tasksInBuckets, err := GetTasksInBucketsForView(s, view, projects, opts, a)
return tasksInBuckets, len(tasksInBuckets), int64(len(tasksInBuckets)), err
}
}
return getTasksForProjects(s, projects, a, opts, view)
}
func getRelevantProjectsFromCollection(s *xorm.Session, a web.Auth, tf *TaskCollection) (projects []*Project, err error) {
if tf.ProjectID == 0 || tf.isSavedFilter {
projects, _, _, err = getRawProjectsForUser(
s,
&projectOptions{
user: &user.User{ID: a.GetID()},
page: -1,
},
)
return projects, err
}
// Check the project exists and the user has access on it
project := &Project{ID: tf.ProjectID}
canRead, _, err := project.CanRead(s, a)
if err != nil {
return nil, err
}
if !canRead {
return nil, ErrUserDoesNotHaveAccessToProject{
ProjectID: tf.ProjectID,
UserID: a.GetID(),
}
}
return []*Project{{ID: tf.ProjectID}}, nil
}
// ReadAll gets all tasks for a collection
// @Summary Get tasks in a project
// @Description Returns all tasks for the current project.
@ -120,6 +168,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,12 +180,12 @@ 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
// -1 is the favorites project which works as intended
if tf.ProjectID < -1 {
if !tf.isSavedFilter && tf.ProjectID < -1 {
sf, err := getSavedFilterSimpleByID(s, getSavedFilterIDFromProjectID(tf.ProjectID))
if err != nil {
return nil, 0, 0, err
@ -166,17 +215,46 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
sf.Filters.FilterTimezone = u.Timezone
}
return sf.getTaskCollection().ReadAll(s, a, search, page, perPage)
tc := sf.getTaskCollection()
tc.ProjectViewID = tf.ProjectViewID
tc.ProjectID = tf.ProjectID
tc.isSavedFilter = true
return tc.ReadAll(s, a, search, page, perPage)
}
taskopts, err := getTaskFilterOptsFromCollection(tf)
var view *ProjectView
if tf.ProjectViewID != 0 {
view, err = GetProjectViewByIDAndProject(s, tf.ProjectViewID, tf.ProjectID)
if err != nil {
return nil, 0, 0, err
}
if view.Filter != "" {
if tf.Filter != "" {
tf.Filter = "(" + tf.Filter + ") && (" + view.Filter + ")"
} else {
tf.Filter = view.Filter
}
}
}
opts, err := getTaskFilterOptsFromCollection(tf, view)
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
if view != nil {
opts.sortby = append(opts.sortby, &sortParam{
projectViewID: view.ID,
sortBy: taskPropertyPosition,
orderBy: orderAscending,
})
}
shareAuth, is := a.(*LinkSharing)
if is {
@ -184,38 +262,13 @@ 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 getTaskOrTasksInBuckets(s, a, []*Project{project}, view, opts)
}
// If the project ID is not set, we get all tasks for the user.
// This allows to use this function in Task.ReadAll with a possibility to deprecate the latter at some point.
var projects []*Project
if tf.ProjectID == 0 {
projects, _, _, err = getRawProjectsForUser(
s,
&projectOptions{
user: &user.User{ID: a.GetID()},
page: -1,
},
)
if err != nil {
return nil, 0, 0, err
}
} else {
// Check the project exists and the user has access on it
project := &Project{ID: tf.ProjectID}
canRead, _, err := project.CanRead(s, a)
if err != nil {
return nil, 0, 0, err
}
if !canRead {
return nil, 0, 0, ErrUserDoesNotHaveAccessToProject{
ProjectID: tf.ProjectID,
UserID: a.GetID(),
}
}
projects = []*Project{{ID: tf.ProjectID}}
projects, err := getRelevantProjectsFromCollection(s, a, tf)
if err != nil {
return nil, 0, 0, err
}
return getTasksForProjects(s, projects, a, taskopts)
return getTaskOrTasksInBuckets(s, a, projects, view, opts)
}

View File

@ -18,35 +18,36 @@ package models
type (
sortParam struct {
sortBy string
orderBy sortOrder // asc or desc
sortBy string
orderBy sortOrder // asc or desc
projectViewID int64
}
sortOrder string
)
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"
taskPropertyProjectViewID string = "project_view_id"
)
const (
@ -73,5 +74,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)
}

View File

@ -61,7 +61,6 @@ func TestSortParamValidation(t *testing.T) {
taskPropertyUID,
taskPropertyCreated,
taskPropertyUpdated,
taskPropertyPosition,
} {
t.Run(test, func(t *testing.T) {
s := &sortParam{

View File

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

180
pkg/models/task_position.go Normal file
View File

@ -0,0 +1,180 @@
// 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 <https://www.gnu.org/licenses/>.
package models
import (
"math"
"code.vikunja.io/web"
"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, err := GetProjectViewByID(s, tp.ProjectViewID)
if err != nil {
return false, err
}
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, a web.Auth) (err error) {
// Update all positions if the newly saved position is < 0.1
var shouldRecalculate bool
var view *ProjectView
if tp.Position < 0.1 {
shouldRecalculate = true
view, err = GetProjectViewByID(s, tp.ProjectViewID)
if err != nil {
return err
}
}
exists, err := s.
Where("task_id = ? AND project_view_id = ?", tp.TaskID, tp.ProjectViewID).
Exist(&TaskPosition{})
if err != nil {
return err
}
if !exists {
_, err = s.Insert(tp)
if err != nil {
return
}
if shouldRecalculate {
return RecalculateTaskPositions(s, view, a)
}
return nil
}
_, err = s.
Where("task_id = ?", tp.TaskID).
Cols("project_view_id", "position").
Update(tp)
if err != nil {
return
}
if shouldRecalculate {
return RecalculateTaskPositions(s, view, a)
}
return
}
func RecalculateTaskPositions(s *xorm.Session, view *ProjectView, a web.Auth) (err error) {
// Using the collection so that we get all tasks, even in cases where we're dealing with a saved filter underneath
tc := &TaskCollection{
ProjectID: view.ProjectID,
}
if view.ProjectID < -1 {
tc.ProjectID = 0
}
projects, err := getRelevantProjectsFromCollection(s, a, tc)
if err != nil {
return err
}
opts := &taskSearchOptions{
sortby: []*sortParam{
{
projectViewID: view.ID,
sortBy: taskPropertyPosition,
orderBy: orderAscending,
},
},
}
allTasks, _, _, err := getRawTasksForProjects(s, projects, a, opts)
if err != nil {
return err
}
if len(allTasks) == 0 {
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.
Where("project_view_id = ?", view.ID).
Delete(&TaskPosition{})
if err != nil {
return
}
_, 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
}

View File

@ -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.
@ -204,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
@ -248,25 +261,43 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
limit, start := getLimitFromPageIndex(opts.page, opts.perPage)
cond := builder.And(builder.Or(projectIDCond, favoritesCond), where, filterCond)
query := d.s.Where(cond)
var distinct = "tasks.*"
if strings.Contains(orderby, "task_positions.") {
distinct += ", task_positions.position"
}
query := d.s.
Distinct(distinct).
Where(cond)
if limit > 0 {
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
}
}
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
}
@ -404,29 +435,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))
@ -442,6 +450,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 {
param.sortBy = "positions.view_" + strconv.FormatInt(param.projectViewID, 10)
continue
}
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

View File

@ -112,18 +112,15 @@ 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.
BucketID int64 `xorm:"bigint null" json:"bucket_id"`
// 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:"<-" 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.
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"`
// 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"`
// Reactions on that task.
Reactions ReactionMap `xorm:"-" json:"reactions"`
@ -207,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}
@ -261,7 +261,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
@ -304,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 {
@ -316,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
}
@ -393,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
}
@ -534,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 {
@ -592,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 {
@ -613,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
@ -620,23 +635,12 @@ 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 {
taskCount, err := s.
Where("bucket_id = ?", bucket.ID).
Count(&Task{})
Count(&TaskBucket{})
if err != nil {
return err
}
@ -648,62 +652,92 @@ 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) {
if project == nil {
project, err = GetProjectSimpleByID(s, task.ProjectID)
if err != nil {
return nil, err
}
func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, view *ProjectView, targetBucketID int64) (err error) {
if view.BucketConfigurationMode == BucketConfigurationModeNone {
return
}
var shouldChangeBucket = true
targetBucket := &TaskBucket{
BucketID: targetBucketID,
TaskID: task.ID,
ProjectViewID: view.ID,
}
oldTaskBucket := &TaskBucket{}
_, err = s.
Where("task_id = ? AND project_view_id = ?", task.ID, view.ID).
Get(oldTaskBucket)
if err != nil {
return
}
var bucket *Bucket
if task.Done && originalTask != nil &&
(!originalTask.Done || task.ProjectID != originalTask.ProjectID) {
task.BucketID = project.DoneBucketID
targetBucket.BucketID = view.DoneBucketID
}
if task.BucketID == 0 && originalTask != nil && originalTask.BucketID != 0 {
task.BucketID = originalTask.BucketID
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, project)
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
err = checkBucketAndTaskBelongToSameProject(task, bucket)
if err != nil {
return
if view.ID != bucket.ProjectViewID {
return ErrBucketDoesNotBelongToProjectView{
ProjectViewID: view.ID,
BucketID: bucket.ID,
}
}
// 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
}
}
if bucket.ID == project.DoneBucketID && originalTask != nil && !originalTask.Done {
if bucket.ID == view.DoneBucketID && originalTask != nil && !originalTask.Done {
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 {
@ -742,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
@ -771,22 +805,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.KanbanPosition = calculateDefaultPosition(t.Index, t.KanbanPosition)
t.HexColor = utils.NormalizeHex(t.HexColor)
_, err = s.Insert(t)
@ -794,6 +818,37 @@ 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
}
positions := []*TaskPosition{}
for _, view := range views {
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{
TaskID: t.ID,
ProjectViewID: view.ID,
Position: calculateDefaultPosition(t.Index, t.Position),
})
}
if updateBucket {
_, err = s.Insert(&positions)
if err != nil {
return
}
}
t.CreatedBy = createdBy
// Update the assignees
@ -865,26 +920,6 @@ 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)
if err != nil {
return err
}
targetBucket, err := setTaskBucket(s, t, &ot, t.BucketID != 0 && t.BucketID != ot.BucketID, project)
if err != nil {
return err
}
// 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
}
// When a repeating task is marked as done, we update all deadlines and reminders and set it as undone
updateDone(&ot, t)
// Update the assignees
if err := ot.updateTaskAssignees(s, t.Assignees, a); err != nil {
return err
@ -910,9 +945,7 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) {
"percent_done",
"project_id",
"bucket_id",
"position",
"repeat_mode",
"kanban_position",
"cover_image_attachment_id",
}
@ -922,9 +955,48 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) {
if err != nil {
return err
}
t.BucketID = 0
colsToUpdate = append(colsToUpdate, "index")
}
views, err := getViewsForProject(s, t.ProjectID)
if err != nil {
return err
}
buckets := make(map[int64]*Bucket)
err = s.In("project_view_id",
builder.Select("id").
From("project_views").
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
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
}
}
err = setTaskBucket(s, t, &ot, view, targetBucketID)
if err != nil {
return err
}
}
// When a repeating task is marked as done, we update all deadlines and reminders and set it as undone
updateDone(&ot, t)
// If a task attachment is being set as cover image, check if the attachment actually belongs to the task
if t.CoverImageAttachmentID != 0 {
is, err := s.Exist(&TaskAttachment{
@ -1024,13 +1096,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
}
if t.KanbanPosition == 0 {
ot.KanbanPosition = 0
}
// Repeat from current date
if t.RepeatMode == TaskRepeatModeDefault {
ot.RepeatMode = TaskRepeatModeDefault
@ -1052,20 +1117,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
}
}
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.
nt := &Task{}
@ -1074,8 +1125,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) {
return err
}
t.Updated = nt.Updated
t.Position = nt.Position
t.KanbanPosition = nt.KanbanPosition
doer, _ := user.GetFromAuth(a)
err = events.Dispatch(&TaskUpdatedEvent{
@ -1089,72 +1138,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{}
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())
}
@ -1531,7 +1514,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
}

View File

@ -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)
})
@ -253,12 +254,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)
@ -277,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)
@ -297,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) {
@ -321,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) {
@ -345,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)
@ -361,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)
})
@ -386,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) {
@ -405,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)
@ -426,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)
})

View File

@ -19,6 +19,7 @@ package models
import (
"context"
"fmt"
"strconv"
"time"
"code.vikunja.io/api/pkg/config"
@ -154,11 +155,11 @@ func CreateTypesenseCollections() error {
Type: "float",
},
{
Name: "kanban_position",
Type: "float",
Name: "created_by_id",
Type: "int64",
},
{
Name: "created_by_id",
Name: "project_view_id",
Type: "int64",
},
{
@ -248,7 +249,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 {
@ -284,14 +291,14 @@ 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())
}
projects := make(map[int64]*Project)
typesenseTasks := []interface{}{}
for _, task := range tasks {
ttask, err := getTypesenseTaskForTask(s, task, projects)
@ -415,19 +422,18 @@ 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"`
KanbanPosition float64 `json:"kanban_position"`
CreatedByID int64 `json:"created_by_id"`
Reminders interface{} `json:"reminders"`
Assignees interface{} `json:"assignees"`
Labels interface{} `json:"labels"`
//RelatedTasks interface{} `json:"related_tasks"` // TODO
Attachments interface{} `json:"attachments"`
Comments interface{} `json:"comments"`
Attachments interface{} `json:"attachments"`
Comments interface{} `json:"comments"`
Positions map[string]float64 `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,
@ -449,9 +455,6 @@ func convertTaskToTypesenseTask(task *Task) *typesenseTask {
CoverImageAttachmentID: task.CoverImageAttachmentID,
Created: task.Created.UTC().Unix(),
Updated: task.Updated.UTC().Unix(),
BucketID: task.BucketID,
Position: task.Position,
KanbanPosition: task.KanbanPosition,
CreatedByID: task.CreatedByID,
Reminders: task.Reminders,
Assignees: task.Assignees,
@ -473,6 +476,10 @@ func convertTaskToTypesenseTask(task *Task) *typesenseTask {
tt.EndDate = nil
}
for _, position := range positions {
tt.Positions["view_"+strconv.FormatInt(position.ProjectViewID, 10)] = position.Position
}
return tt
}

View File

@ -66,6 +66,9 @@ func SetupTests() {
"favorites",
"api_tokens",
"reactions",
"project_views",
"task_positions",
"task_buckets",
)
if err != nil {
log.Fatal(err)

View File

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

View File

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

View File

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

View File

@ -228,11 +228,10 @@ func TestConvertTrelloToVikunja(t *testing.T) {
Tasks: []*models.TaskWithComments{
{
Task: models.Task{
Title: "Test Card 1",
Description: "<p>Card Description <strong>bold</strong></p>\n",
BucketID: 1,
KanbanPosition: 123,
DueDate: time1,
Title: "Test Card 1",
Description: "<p>Card Description <strong>bold</strong></p>\n",
BucketID: 1,
DueDate: time1,
Labels: []*models.Label{
{
Title: "Label 1",
@ -271,22 +270,19 @@ func TestConvertTrelloToVikunja(t *testing.T) {
<ul data-type="taskList">
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Pending Task</p></div></li>
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Another Pending Task</p></div></li></ul>`,
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,
},
},
},

View File

@ -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{
@ -362,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 {
@ -385,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{}
@ -590,6 +598,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 +607,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) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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