diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md index 4aff46443..8183f44c3 100644 --- a/docs/content/doc/usage/errors.md +++ b/docs/content/doc/usage/errors.md @@ -55,19 +55,20 @@ This document describes the different errors Vikunja can return. ## Project -| ErrorCode | HTTP Status Code | Description | -|-----------|------------------|-------------------------------------------------------------------------------------------------------------------------------------| -| 3001 | 404 | The project does not exist. | -| 3004 | 403 | The user needs to have read permissions on that project to perform that action. | -| 3005 | 400 | The project title cannot be empty. | -| 3006 | 404 | The project share does not exist. | -| 3007 | 400 | A project with this identifier already exists. | +| ErrorCode | HTTP Status Code | Description | +|-----------|------------------|------------------------------------------------------------------------------------------------------------------------------------| +| 3001 | 404 | The project does not exist. | +| 3004 | 403 | The user needs to have read permissions on that project to perform that action. | +| 3005 | 400 | The project title cannot be empty. | +| 3006 | 404 | The project share does not exist. | +| 3007 | 400 | A project with this identifier already exists. | | 3008 | 412 | The project is archived and can therefore only be accessed read only. This is also true for all tasks associated with this project. | -| 3009 | 412 | The project cannot belong to a dynamically generated parent project like "Favorites". | -| 3010 | 412 | This project cannot be a child of itself. | -| 3011 | 412 | This project cannot have a cyclic relationship to a parent project. | -| 3012 | 412 | This project cannot be deleted because a user has set it as their default project. | -| 3013 | 412 | This project cannot be archived because a user has set it as their default project. | +| 3009 | 412 | The project cannot belong to a dynamically generated parent project like "Favorites". | +| 3010 | 412 | This project cannot be a child of itself. | +| 3011 | 412 | This project cannot have a cyclic relationship to a parent project. | +| 3012 | 412 | This project cannot be deleted because a user has set it as their default project. | +| 3013 | 412 | This project cannot be archived because a user has set it as their default project. | +| 3014 | 404 | This project view does not exist. | ## Task @@ -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 diff --git a/frontend/cypress/e2e/project/prepareProjects.ts b/frontend/cypress/e2e/project/prepareProjects.ts index ea7a1b01d..27c8b20df 100644 --- a/frontend/cypress/e2e/project/prepareProjects.ts +++ b/frontend/cypress/e2e/project/prepareProjects.ts @@ -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) diff --git a/frontend/cypress/e2e/project/project-history.spec.ts b/frontend/cypress/e2e/project/project-history.spec.ts index 0dadca8c7..b7caad07e 100644 --- a/frontend/cypress/e2e/project/project-history.spec.ts +++ b/frontend/cypress/e2e/project/project-history.spec.ts @@ -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('/') diff --git a/frontend/cypress/e2e/project/project-view-gantt.spec.ts b/frontend/cypress/e2e/project/project-view-gantt.spec.ts index 5a67c7081..507588d29 100644 --- a/frontend/cypress/e2e/project/project-view-gantt.spec.ts +++ b/frontend/cypress/e2e/project/project-view-gantt.spec.ts @@ -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() diff --git a/frontend/cypress/e2e/project/project-view-kanban.spec.ts b/frontend/cypress/e2e/project/project-view-kanban.spec.ts index 2c74ba9a9..1d93e2f86 100644 --- a/frontend/cypress/e2e/project/project-view-kanban.spec.ts +++ b/frontend/cypress/e2e/project/project-view-kanban.spec.ts @@ -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: '

', }) - 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') }) -}) \ No newline at end of file +}) diff --git a/frontend/cypress/e2e/project/project-view-list.spec.ts b/frontend/cypress/e2e/project/project-view-list.spec.ts index c325f6824..1cb6f0c94 100644 --- a/frontend/cypress/e2e/project/project-view-list.spec.ts +++ b/frontend/cypress/e2e/project/project-view-list.spec.ts @@ -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) }) }) \ No newline at end of file diff --git a/frontend/cypress/e2e/project/project-view-table.spec.ts b/frontend/cypress/e2e/project/project-view-table.spec.ts index d468e61c2..6319617c1 100644 --- a/frontend/cypress/e2e/project/project-view-table.spec.ts +++ b/frontend/cypress/e2e/project/project-view-table.spec.ts @@ -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) diff --git a/frontend/cypress/e2e/project/project.spec.ts b/frontend/cypress/e2e/project/project.spec.ts index a5ef6cabc..7258b59fd 100644 --- a/frontend/cypress/e2e/project/project.spec.ts +++ b/frontend/cypress/e2e/project/project.spec.ts @@ -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', () => { diff --git a/frontend/cypress/e2e/sharing/linkShare.spec.ts b/frontend/cypress/e2e/sharing/linkShare.spec.ts index 99b6fb5a0..ffb6ea3a7 100644 --- a/frontend/cypress/e2e/sharing/linkShare.spec.ts +++ b/frontend/cypress/e2e/sharing/linkShare.spec.ts @@ -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) diff --git a/frontend/cypress/e2e/task/overview.spec.ts b/frontend/cypress/e2e/task/overview.spec.ts index 342134b23..ab10dc1b0 100644 --- a/frontend/cypress/e2e/task/overview.spec.ts +++ b/frontend/cypress/e2e/task/overview.spec.ts @@ -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('/') diff --git a/frontend/cypress/e2e/task/task.spec.ts b/frontend/cypress/e2e/task/task.spec.ts index aa1653e8b..768f3d61c 100644 --- a/frontend/cypress/e2e/task/task.spec.ts +++ b/frontend/cypress/e2e/task/task.spec.ts @@ -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: '

', }) - 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) diff --git a/frontend/cypress/factories/bucket.ts b/frontend/cypress/factories/bucket.ts index 2e0e91077..23b1cfe60 100644 --- a/frontend/cypress/factories/bucket.ts +++ b/frontend/cypress/factories/bucket.ts @@ -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(), diff --git a/frontend/cypress/factories/project_view.ts b/frontend/cypress/factories/project_view.ts new file mode 100644 index 000000000..710afff9c --- /dev/null +++ b/frontend/cypress/factories/project_view.ts @@ -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(), + } + } +} \ No newline at end of file diff --git a/frontend/cypress/factories/task.ts b/frontend/cypress/factories/task.ts index bc97446a8..9c37ad0f7 100644 --- a/frontend/cypress/factories/task.ts +++ b/frontend/cypress/factories/task.ts @@ -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() } diff --git a/frontend/cypress/factories/task_buckets.ts b/frontend/cypress/factories/task_buckets.ts new file mode 100644 index 000000000..a91141d9d --- /dev/null +++ b/frontend/cypress/factories/task_buckets.ts @@ -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}', + } + } +} \ No newline at end of file diff --git a/frontend/src/components/home/contentAuth.vue b/frontend/src/components/home/contentAuth.vue index 51248f140..c2c814b4b 100644 --- a/frontend/src/components/home/contentAuth.vue +++ b/frontend/src/components/home/contentAuth.vue @@ -37,7 +37,7 @@ v-slot="{ Component }" :route="routeWithModal" > - + diff --git a/frontend/src/components/home/contentLinkShare.vue b/frontend/src/components/home/contentLinkShare.vue index b21ee1ea5..95cf3dc15 100644 --- a/frontend/src/components/home/contentLinkShare.vue +++ b/frontend/src/components/home/contentLinkShare.vue @@ -33,11 +33,15 @@ import {useBaseStore} from '@/stores/base' import Logo from '@/components/home/Logo.vue' import PoweredByLink from './PoweredByLink.vue' +import {useProjectStore} from '@/stores/projects' const baseStore = useBaseStore() const currentProject = computed(() => baseStore.currentProject) const background = computed(() => baseStore.background) const logoVisible = computed(() => baseStore.logoVisible) + +const projectStore = useProjectStore() +projectStore.loadAllProjects() \ No newline at end of file diff --git a/frontend/src/components/sharing/linkSharing.vue b/frontend/src/components/sharing/linkSharing.vue index 88aaa61ad..b63d0805d 100644 --- a/frontend/src/components/sharing/linkSharing.vue +++ b/frontend/src/components/sharing/linkSharing.vue @@ -173,11 +173,11 @@
@@ -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 +type SelectedViewMapper = Record const selectedView = ref({}) -const availableViews = computed>(() => ({ - 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(() => 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 } diff --git a/frontend/src/components/tasks/partials/singleTaskInProject.vue b/frontend/src/components/tasks/partials/singleTaskInProject.vue index 95d9f8888..2951c39cd 100644 --- a/frontend/src/components/tasks/partials/singleTaskInProject.vue +++ b/frontend/src/components/tasks/partials/singleTaskInProject.vue @@ -30,7 +30,7 @@ @@ -136,7 +136,7 @@ {{ project.title }} diff --git a/frontend/src/composables/useRouteWithModal.ts b/frontend/src/composables/useRouteWithModal.ts index 6cf9a66dc..4857b3c67 100644 --- a/frontend/src/composables/useRouteWithModal.ts +++ b/frontend/src/composables/useRouteWithModal.ts @@ -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 } diff --git a/frontend/src/composables/useTaskList.ts b/frontend/src/composables/useTaskList.ts index ef048dc7a..27a8ab6eb 100644 --- a/frontend/src/composables/useTaskList.ts +++ b/frontend/src/composables/useTaskList.ts @@ -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, sortByDefault: SortBy = SORT_BY_DEFAULT) { +export function useTaskList( + projectIdGetter: ComputedGetter, + projectViewIdGetter: ComputedGetter, + sortByDefault: SortBy = SORT_BY_DEFAULT, +) { const projectId = computed(() => projectIdGetter()) + const projectViewId = computed(() => projectViewIdGetter()) const params = ref({...getDefaultTaskFilterParams()}) @@ -87,7 +93,10 @@ export function useTaskList(projectIdGetter: ComputedGetter, sor const getAllTasksParams = computed(() => { return [ - {projectId: projectId.value}, + { + projectId: projectId.value, + viewId: projectViewId.value, + }, { ...allParams.value, filter_timezone: authStore.settings.timezone, diff --git a/frontend/src/helpers/projectView.ts b/frontend/src/helpers/projectView.ts index 9bc3a7747..2c8cfb763 100644 --- a/frontend/src/helpers/projectView.ts +++ b/frontend/src/helpers/projectView.ts @@ -1,64 +1,17 @@ -import type { RouteRecordName } from 'vue-router' -import router from '@/router' - import type {IProject} from '@/modelTypes/IProject' -export type ProjectRouteName = Extract -export type ProjectViewSettings = Record< - IProject['id'], - Extract -> +export type ProjectViewSettings = Record 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] } \ No newline at end of file diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index b9dcefc4c..9d39969c0 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -381,6 +381,22 @@ "secret": "Secret", "secretHint": "If provided, all requests to the webhook target URL will be signed using HMAC.", "secretDocs": "Check out the docs for more details about how to use secrets." + }, + "views": { + "header": "Edit views", + "title": "Title", + "actions": "Actions", + "kind": "Kind", + "bucketConfigMode": "Bucket configuration mode", + "bucketConfig": "Bucket configuration", + "bucketConfigManual": "Manual", + "filter": "Filter", + "create": "Create view", + "createSuccess": "The view was created successfully.", + "titleRequired": "Please provide a title.", + "delete": "Delete this view", + "deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!", + "deleteSuccess": "The view was successfully deleted" } }, "filters": { @@ -1049,7 +1065,8 @@ "newProject": "New project", "createProject": "Create project", "cantArchiveIsDefault": "You cannot archive this because it is your default project.", - "cantDeleteIsDefault": "You cannot delete this because it is your default project." + "cantDeleteIsDefault": "You cannot delete this because it is your default project.", + "views": "Views" }, "apiConfig": { "url": "Vikunja URL", diff --git a/frontend/src/modelTypes/IBucket.ts b/frontend/src/modelTypes/IBucket.ts index 0f7ce12da..dccd0d842 100644 --- a/frontend/src/modelTypes/IBucket.ts +++ b/frontend/src/modelTypes/IBucket.ts @@ -1,6 +1,7 @@ import type {IAbstract} from './IAbstract' import type {IUser} from './IUser' import type {ITask} from './ITask' +import type {IProjectView} from '@/modelTypes/IProjectView' export interface IBucket extends IAbstract { id: number @@ -10,6 +11,7 @@ export interface IBucket extends IAbstract { tasks: ITask[] position: number count: number + projectViewId: IProjectView['id'] createdBy: IUser created: Date diff --git a/frontend/src/modelTypes/IProject.ts b/frontend/src/modelTypes/IProject.ts index f5c976f3a..c4e212e83 100644 --- a/frontend/src/modelTypes/IProject.ts +++ b/frontend/src/modelTypes/IProject.ts @@ -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 diff --git a/frontend/src/modelTypes/IProjectView.ts b/frontend/src/modelTypes/IProjectView.ts new file mode 100644 index 000000000..6a003b8ff --- /dev/null +++ b/frontend/src/modelTypes/IProjectView.ts @@ -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 +} \ No newline at end of file diff --git a/frontend/src/modelTypes/ITaskPosition.ts b/frontend/src/modelTypes/ITaskPosition.ts new file mode 100644 index 000000000..7ea71e1c0 --- /dev/null +++ b/frontend/src/modelTypes/ITaskPosition.ts @@ -0,0 +1,8 @@ +import type {IProjectView} from '@/modelTypes/IProjectView' +import type {IAbstract} from '@/modelTypes/IAbstract' + +export interface ITaskPosition extends IAbstract { + position: number + projectViewId: IProjectView['id'] + taskId: number +} \ No newline at end of file diff --git a/frontend/src/models/project.ts b/frontend/src/models/project.ts index 145262dc3..06c9e8ee7 100644 --- a/frontend/src/models/project.ts +++ b/frontend/src/models/project.ts @@ -7,6 +7,7 @@ import type {IProject} from '@/modelTypes/IProject' import type {IUser} from '@/modelTypes/IUser' import type {ITask} from '@/modelTypes/ITask' import type {ISubscription} from '@/modelTypes/ISubscription' +import ProjectViewModel from '@/models/projectView' export default class ProjectModel extends AbstractModel implements IProject { id = 0 @@ -25,6 +26,7 @@ export default class ProjectModel extends AbstractModel implements IPr parentProjectId = 0 doneBucketId = 0 defaultBucketId = 0 + views = [] created: Date = null updated: Date = null @@ -48,6 +50,8 @@ export default class ProjectModel extends AbstractModel implements IPr this.subscription = new SubscriptionModel(this.subscription) } + this.views = this.views.map(v => new ProjectViewModel(v)) + this.created = new Date(this.created) this.updated = new Date(this.updated) } diff --git a/frontend/src/models/projectView.ts b/frontend/src/models/projectView.ts new file mode 100644 index 000000000..559f94818 --- /dev/null +++ b/frontend/src/models/projectView.ts @@ -0,0 +1,29 @@ +import type {IProjectView, ProjectViewBucketConfigurationMode, ProjectViewKind} from '@/modelTypes/IProjectView' +import AbstractModel from '@/models/abstractModel' + +export default class ProjectViewModel extends AbstractModel implements IProjectView { + id = 0 + title = '' + projectId = 0 + viewKind: ProjectViewKind = 'list' + + filter = '' + position = 0 + + bucketConfiguration = [] + bucketConfigurationMode: ProjectViewBucketConfigurationMode = 'manual' + defaultBucketId = 0 + doneBucketId = 0 + + created: Date = new Date() + updated: Date = new Date() + + constructor(data: Partial) { + super() + this.assignData(data) + + if (!this.bucketConfiguration) { + this.bucketConfiguration = [] + } + } +} \ No newline at end of file diff --git a/frontend/src/models/taskPosition.ts b/frontend/src/models/taskPosition.ts new file mode 100644 index 000000000..e1c37ae0b --- /dev/null +++ b/frontend/src/models/taskPosition.ts @@ -0,0 +1,13 @@ +import AbstractModel from '@/models/abstractModel' +import type {ITaskPosition} from '@/modelTypes/ITaskPosition' + +export default class TaskPositionModel extends AbstractModel implements ITaskPosition { + position = 0 + projectViewId = 0 + taskId = 0 + + constructor(data: Partial) { + super() + this.assignData(data) + } +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 775a6f562..a5f94d45c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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', diff --git a/frontend/src/services/bucket.ts b/frontend/src/services/bucket.ts index 0ef42f876..a353093ff 100644 --- a/frontend/src/services/bucket.ts +++ b/frontend/src/services/bucket.ts @@ -6,10 +6,10 @@ import type { IBucket } from '@/modelTypes/IBucket' export default class BucketService extends AbstractService { 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}', }) } diff --git a/frontend/src/services/projectViews.ts b/frontend/src/services/projectViews.ts new file mode 100644 index 000000000..a35d0f325 --- /dev/null +++ b/frontend/src/services/projectViews.ts @@ -0,0 +1,20 @@ +import AbstractService from '@/services/abstractService' +import type {IAbstract} from '@/modelTypes/IAbstract' +import ProjectViewModel from '@/models/projectView' +import type {IProjectView} from '@/modelTypes/IProjectView' + +export default class ProjectViewService extends AbstractService { + constructor() { + super({ + get: '/projects/{projectId}/views/{id}', + getAll: '/projects/{projectId}/views', + create: '/projects/{projectId}/views', + update: '/projects/{projectId}/views/{id}', + delete: '/projects/{projectId}/views/{id}', + }) + } + + modelFactory(data: Partial): ProjectViewModel { + return new ProjectViewModel(data) + } +} diff --git a/frontend/src/services/taskCollection.ts b/frontend/src/services/taskCollection.ts index 046c3f0d9..3304227f1 100644 --- a/frontend/src/services/taskCollection.ts +++ b/frontend/src/services/taskCollection.ts @@ -2,6 +2,7 @@ import AbstractService from '@/services/abstractService' import TaskModel from '@/models/task' import type {ITask} from '@/modelTypes/ITask' +import BucketModel from '@/models/bucket' export interface TaskFilterParams { sort_by: ('start_date' | 'end_date' | 'due_date' | 'done' | 'id' | 'position' | 'kanban_position')[], @@ -27,11 +28,15 @@ export function getDefaultTaskFilterParams(): TaskFilterParams { export default class TaskCollectionService extends AbstractService { 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) } } \ No newline at end of file diff --git a/frontend/src/services/taskPosition.ts b/frontend/src/services/taskPosition.ts new file mode 100644 index 000000000..c74a8038a --- /dev/null +++ b/frontend/src/services/taskPosition.ts @@ -0,0 +1,15 @@ +import AbstractService from '@/services/abstractService' +import type {ITaskPosition} from '@/modelTypes/ITaskPosition' +import TaskPositionModel from '@/models/taskPosition' + +export default class TaskPositionService extends AbstractService { + constructor() { + super({ + update: '/tasks/{taskId}/position', + }) + } + + modelFactory(data: Partial) { + return new TaskPositionModel(data) + } +} \ No newline at end of file diff --git a/frontend/src/stores/kanban.ts b/frontend/src/stores/kanban.ts index 67cbfe988..05d51e4a1 100644 --- a/frontend/src/stores/kanban.ts +++ b/frontend/src/stores/kanban.ts @@ -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, } }) diff --git a/frontend/src/stores/projects.ts b/frontend/src/stores/projects.ts index 7a1992407..e6b436e6a 100644 --- a/frontend/src/stores/projects.ts +++ b/frontend/src/stores/projects.ts @@ -18,6 +18,7 @@ import ProjectModel from '@/models/project' import {success} from '@/message' import {useBaseStore} from '@/stores/base' import {getSavedFilterIdFromProjectId} from '@/services/savedFilter' +import type {IProjectView} from '@/modelTypes/IProjectView' const {add, remove, search, update} = createNewIndexer('projects', ['title', 'description']) @@ -210,7 +211,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, } }) diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index 332a4eba7..8a55c1065 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -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 { diff --git a/frontend/src/types/ProjectView.ts b/frontend/src/types/ProjectView.ts deleted file mode 100644 index ba435ef96..000000000 --- a/frontend/src/types/ProjectView.ts +++ /dev/null @@ -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] diff --git a/frontend/src/views/project/ProjectView.vue b/frontend/src/views/project/ProjectView.vue new file mode 100644 index 000000000..055cdecd9 --- /dev/null +++ b/frontend/src/views/project/ProjectView.vue @@ -0,0 +1,80 @@ + + + diff --git a/frontend/src/views/project/helpers/useGanttFilters.ts b/frontend/src/views/project/helpers/useGanttFilters.ts index dfe8ccd8b..e75677b75 100644 --- a/frontend/src/views/project/helpers/useGanttFilters.ts +++ b/frontend/src/views/project/helpers/useGanttFilters.ts @@ -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): 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> & ReturnType> -export function useGanttFilters(route: Ref): UseGanttFiltersReturn { +export function useGanttFilters(route: Ref, viewId: IProjectView['id']): UseGanttFiltersReturn { const { filters, hasDefaultFilters, @@ -98,7 +104,7 @@ export function useGanttFilters(route: Ref): UseGanttFi ganttGetDefaultFilters, ganttRouteToFilters, ganttFiltersToRoute, - ['project.gantt'], + ['project.view'], ) const { @@ -108,7 +114,7 @@ export function useGanttFilters(route: Ref): UseGanttFi isLoading, addTask, updateTask, - } = useGanttTaskList(filters, ganttFiltersToApiParams) + } = useGanttTaskList(filters, ganttFiltersToApiParams, viewId) return { filters, diff --git a/frontend/src/views/project/helpers/useGanttTaskList.ts b/frontend/src/views/project/helpers/useGanttTaskList.ts index f2a76c8d6..e616c2399 100644 --- a/frontend/src/views/project/helpers/useGanttTaskList.ts +++ b/frontend/src/views/project/helpers/useGanttTaskList.ts @@ -1,4 +1,4 @@ -import {computed, ref, shallowReactive, watch, type Ref} from 'vue' +import {computed, ref, type Ref, shallowReactive, watch} from 'vue' import {klona} from 'klona/lite' import type {Filters} from '@/composables/useRouteFilters' @@ -10,16 +10,15 @@ import TaskService from '@/services/task' import TaskModel from '@/models/task' import {error, success} from '@/message' import {useAuthStore} from '@/stores/auth' +import type {IProjectView} from '@/modelTypes/IProjectView' // FIXME: unify with general `useTaskList` export function useGanttTaskList( filters: Ref, filterToApiParams: (filters: F) => TaskFilterParams, - options: { - loadAll?: boolean, - } = { - loadAll: true, - }) { + 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( const tasks = ref>(new Map()) async function fetchTasks(params: TaskFilterParams, page = 1): Promise { - - if(params.filter_timezone === '') { + + if (params.filter_timezone === '') { params.filter_timezone = authStore.settings.timezone } - const tasks = await taskCollectionService.getAll({projectId: filters.value.projectId}, params, page) as ITask[] - if (options.loadAll && page < taskCollectionService.totalPages) { + 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) } diff --git a/frontend/src/views/project/settings/views.vue b/frontend/src/views/project/settings/views.vue new file mode 100644 index 000000000..e39fe34d8 --- /dev/null +++ b/frontend/src/views/project/settings/views.vue @@ -0,0 +1,178 @@ + + + diff --git a/frontend/src/views/sharing/LinkSharingAuth.vue b/frontend/src/views/sharing/LinkSharingAuth.vue index 8daa79ba5..e49b4ea1c 100644 --- a/frontend/src/views/sharing/LinkSharingAuth.vue +++ b/frontend/src/views/sharing/LinkSharingAuth.vue @@ -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) { diff --git a/pkg/db/fixtures/buckets.yml b/pkg/db/fixtures/buckets.yml index 4d565ae91..d5b23f4c0 100644 --- a/pkg/db/fixtures/buckets.yml +++ b/pkg/db/fixtures/buckets.yml @@ -1,6 +1,6 @@ - id: 1 title: testbucket1 - project_id: 1 + project_view_id: 4 created_by_id: 1 limit: 9999999 # This bucket has a limit we will never exceed in the tests to make sure the logic allows for buckets with limits position: 1 @@ -8,7 +8,7 @@ updated: 2020-04-18 21:13:52 - id: 2 title: testbucket2 - project_id: 1 + project_view_id: 4 created_by_id: 1 limit: 3 position: 2 @@ -16,14 +16,14 @@ updated: 2020-04-18 21:13:52 - id: 3 title: testbucket3 - project_id: 1 + project_view_id: 4 created_by_id: 1 position: 3 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 4 title: testbucket4 - other project - project_id: 2 + project_view_id: 8 created_by_id: 1 position: 1 created: 2020-04-18 21:13:52 @@ -31,221 +31,221 @@ # The following are not or only partly owned by user 1 - id: 5 title: testbucket5 - project_id: 20 + project_view_id: 80 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 6 title: testbucket6 - project_id: 6 + project_view_id: 24 created_by_id: 1 position: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 7 title: testbucket7 - project_id: 7 + project_view_id: 28 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 8 title: testbucket8 - project_id: 8 + project_view_id: 32 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 9 title: testbucket9 - project_id: 9 + project_view_id: 36 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 10 title: testbucket10 - project_id: 10 + project_view_id: 40 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 11 title: testbucket11 - project_id: 11 + project_view_id: 44 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 12 title: testbucket13 - project_id: 12 + project_view_id: 48 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 13 title: testbucket13 - project_id: 13 + project_view_id: 52 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 14 title: testbucket14 - project_id: 14 + project_view_id: 56 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 15 title: testbucket15 - project_id: 15 + project_view_id: 60 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 16 title: testbucket16 - project_id: 16 + project_view_id: 64 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 17 title: testbucket17 - project_id: 17 + project_view_id: 68 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 18 title: testbucket18 - project_id: 5 + project_view_id: 20 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 19 title: testbucket19 - project_id: 21 + project_view_id: 84 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 20 title: testbucket20 - project_id: 22 + project_view_id: 88 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 21 title: testbucket21 - project_id: 3 + project_view_id: 12 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 # Duplicate buckets to make deletion of one of them possible - id: 22 title: testbucket22 - project_id: 6 + project_view_id: 24 created_by_id: 1 position: 2 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 23 title: testbucket23 - project_id: 7 + project_view_id: 28 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 24 title: testbucket24 - project_id: 8 + project_view_id: 32 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 25 title: testbucket25 - project_id: 9 + project_view_id: 36 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 26 title: testbucket26 - project_id: 10 + project_view_id: 40 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 27 title: testbucket27 - project_id: 11 + project_view_id: 44 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 28 title: testbucket28 - project_id: 12 + project_view_id: 48 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 29 title: testbucket29 - project_id: 13 + project_view_id: 52 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 30 title: testbucket30 - project_id: 14 + project_view_id: 56 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 31 title: testbucket31 - project_id: 15 + project_view_id: 60 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 32 title: testbucket32 - project_id: 16 + project_view_id: 64 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 33 title: testbucket33 - project_id: 17 + project_view_id: 68 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 # This bucket is the last one in its project - id: 34 title: testbucket34 - project_id: 18 + project_view_id: 72 created_by_id: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 35 title: testbucket35 - project_id: 23 + project_view_id: 92 created_by_id: -2 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 36 title: testbucket36 - project_id: 33 + project_view_id: 132 created_by_id: 6 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 37 title: testbucket37 - project_id: 34 + project_view_id: 136 created_by_id: 6 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 38 title: testbucket36 - project_id: 36 + project_view_id: 144 created_by_id: 15 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 39 title: testbucket38 - project_id: 38 + project_view_id: 152 created_by_id: 15 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 40 title: testbucket40 - project_id: 2 + project_view_id: 8 created_by_id: 1 position: 10 created: 2020-04-18 21:13:52 diff --git a/pkg/db/fixtures/project_views.yml b/pkg/db/fixtures/project_views.yml new file mode 100644 index 000000000..09df1ae04 --- /dev/null +++ b/pkg/db/fixtures/project_views.yml @@ -0,0 +1,954 @@ +- id: 1 + title: List + project_id: 1 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 2 + title: Gantt + project_id: 1 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 3 + title: Table + project_id: 1 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 4 + title: Kanban + project_id: 1 + view_kind: 3 + done_bucket_id: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 5 + title: List + project_id: 2 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 6 + title: Gantt + project_id: 2 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 7 + title: Table + project_id: 2 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 8 + title: Kanban + project_id: 2 + view_kind: 3 + done_bucket_id: 4 + default_bucket_id: 40 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 9 + title: List + project_id: 3 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 10 + title: Gantt + project_id: 3 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 11 + title: Table + project_id: 3 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 12 + title: Kanban + project_id: 3 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 13 + title: List + project_id: 4 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 14 + title: Gantt + project_id: 4 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 15 + title: Table + project_id: 4 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 16 + title: Kanban + project_id: 4 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 17 + title: List + project_id: 5 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 18 + title: Gantt + project_id: 5 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 19 + title: Table + project_id: 5 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 20 + title: Kanban + project_id: 5 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 21 + title: List + project_id: 6 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 22 + title: Gantt + project_id: 6 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 23 + title: Table + project_id: 6 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 24 + title: Kanban + project_id: 6 + view_kind: 3 + default_bucket_id: 22 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 25 + title: List + project_id: 7 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 26 + title: Gantt + project_id: 7 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 27 + title: Table + project_id: 7 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 28 + title: Kanban + project_id: 7 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 29 + title: List + project_id: 8 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 30 + title: Gantt + project_id: 8 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 31 + title: Table + project_id: 8 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 32 + title: Kanban + project_id: 8 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 33 + title: List + project_id: 9 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 34 + title: Gantt + project_id: 9 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 35 + title: Table + project_id: 9 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 36 + title: Kanban + project_id: 9 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 37 + title: List + project_id: 10 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 38 + title: Gantt + project_id: 10 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 39 + title: Table + project_id: 10 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 40 + title: Kanban + project_id: 10 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 41 + title: List + project_id: 11 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 42 + title: Gantt + project_id: 11 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 43 + title: Table + project_id: 11 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 44 + title: Kanban + project_id: 11 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 45 + title: List + project_id: 12 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 46 + title: Gantt + project_id: 12 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 47 + title: Table + project_id: 12 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 48 + title: Kanban + project_id: 12 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 49 + title: List + project_id: 13 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 50 + title: Gantt + project_id: 13 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 51 + title: Table + project_id: 13 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 52 + title: Kanban + project_id: 13 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 53 + title: List + project_id: 14 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 54 + title: Gantt + project_id: 14 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 55 + title: Table + project_id: 14 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 56 + title: Kanban + project_id: 14 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 57 + title: List + project_id: 15 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 58 + title: Gantt + project_id: 15 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 59 + title: Table + project_id: 15 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 60 + title: Kanban + project_id: 15 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 61 + title: List + project_id: 16 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 62 + title: Gantt + project_id: 16 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 63 + title: Table + project_id: 16 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 64 + title: Kanban + project_id: 16 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 65 + title: List + project_id: 17 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 66 + title: Gantt + project_id: 17 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 67 + title: Table + project_id: 17 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 68 + title: Kanban + project_id: 17 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 69 + title: List + project_id: 18 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 70 + title: Gantt + project_id: 18 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 71 + title: Table + project_id: 18 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 72 + title: Kanban + project_id: 18 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 73 + title: List + project_id: 19 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 74 + title: Gantt + project_id: 19 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 75 + title: Table + project_id: 19 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 76 + title: Kanban + project_id: 19 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 77 + title: List + project_id: 20 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 78 + title: Gantt + project_id: 20 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 79 + title: Table + project_id: 20 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 80 + title: Kanban + project_id: 20 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 81 + title: List + project_id: 21 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 82 + title: Gantt + project_id: 21 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 83 + title: Table + project_id: 21 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 84 + title: Kanban + project_id: 21 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 85 + title: List + project_id: 22 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 86 + title: Gantt + project_id: 22 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 87 + title: Table + project_id: 22 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 88 + title: Kanban + project_id: 22 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 89 + title: List + project_id: 23 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 90 + title: Gantt + project_id: 23 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 91 + title: Table + project_id: 23 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 92 + title: Kanban + project_id: 23 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 93 + title: List + project_id: 24 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 94 + title: Gantt + project_id: 24 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 95 + title: Table + project_id: 24 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 96 + title: Kanban + project_id: 24 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 97 + title: List + project_id: 25 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 98 + title: Gantt + project_id: 25 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 99 + title: Table + project_id: 25 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 100 + title: Kanban + project_id: 25 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 101 + title: List + project_id: 26 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 102 + title: Gantt + project_id: 26 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 103 + title: Table + project_id: 26 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 104 + title: Kanban + project_id: 26 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 105 + title: List + project_id: 27 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 106 + title: Gantt + project_id: 27 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 107 + title: Table + project_id: 27 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 108 + title: Kanban + project_id: 27 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 109 + title: List + project_id: 28 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 110 + title: Gantt + project_id: 28 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 111 + title: Table + project_id: 28 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 112 + title: Kanban + project_id: 28 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 113 + title: List + project_id: 29 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 114 + title: Gantt + project_id: 29 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 115 + title: Table + project_id: 29 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 116 + title: Kanban + project_id: 29 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 117 + title: List + project_id: 30 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 118 + title: Gantt + project_id: 30 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 119 + title: Table + project_id: 30 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 120 + title: Kanban + project_id: 30 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 121 + title: List + project_id: 31 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 122 + title: Gantt + project_id: 31 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 123 + title: Table + project_id: 31 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 124 + title: Kanban + project_id: 31 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 125 + title: List + project_id: 32 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 126 + title: Gantt + project_id: 32 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 127 + title: Table + project_id: 32 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 128 + title: Kanban + project_id: 32 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 129 + title: List + project_id: 33 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 130 + title: Gantt + project_id: 33 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 131 + title: Table + project_id: 33 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 132 + title: Kanban + project_id: 33 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 133 + title: List + project_id: 34 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 134 + title: Gantt + project_id: 34 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 135 + title: Table + project_id: 34 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 136 + title: Kanban + project_id: 34 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 137 + title: List + project_id: 35 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 138 + title: Gantt + project_id: 35 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 139 + title: Table + project_id: 35 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 140 + title: Kanban + project_id: 35 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 141 + title: List + project_id: 36 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 142 + title: Gantt + project_id: 36 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 143 + title: Table + project_id: 36 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 144 + title: Kanban + project_id: 36 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 145 + title: List + project_id: 37 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 146 + title: Gantt + project_id: 37 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 147 + title: Table + project_id: 37 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 148 + title: Kanban + project_id: 37 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 +- id: 149 + title: List + project_id: 38 + view_kind: 0 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 150 + title: Gantt + project_id: 38 + view_kind: 1 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 151 + title: Table + project_id: 38 + view_kind: 2 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' +- id: 152 + title: Kanban + project_id: 38 + view_kind: 3 + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' + bucket_configuration_mode: 1 \ No newline at end of file diff --git a/pkg/db/fixtures/projects.yml b/pkg/db/fixtures/projects.yml index b42944d84..f16622d93 100644 --- a/pkg/db/fixtures/projects.yml +++ b/pkg/db/fixtures/projects.yml @@ -5,7 +5,6 @@ identifier: test1 owner_id: 1 position: 3 - done_bucket_id: 3 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - @@ -15,8 +14,6 @@ identifier: test2 owner_id: 3 position: 2 - done_bucket_id: 4 - default_bucket_id: 40 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - @@ -53,7 +50,6 @@ identifier: test6 owner_id: 6 position: 6 - default_bucket_id: 22 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - diff --git a/pkg/db/fixtures/task_buckets.yml b/pkg/db/fixtures/task_buckets.yml new file mode 100644 index 000000000..390a7d6ad --- /dev/null +++ b/pkg/db/fixtures/task_buckets.yml @@ -0,0 +1,138 @@ +- task_id: 1 + project_view_id: 4 + bucket_id: 1 +- task_id: 2 + project_view_id: 4 + bucket_id: 1 +- task_id: 3 + project_view_id: 4 + bucket_id: 2 +- task_id: 4 + project_view_id: 4 + bucket_id: 2 +- task_id: 5 + project_view_id: 4 + bucket_id: 2 +- task_id: 6 + project_view_id: 4 + bucket_id: 3 +- task_id: 7 + project_view_id: 4 + bucket_id: 3 +- task_id: 8 + project_view_id: 4 + bucket_id: 3 +- task_id: 9 + project_view_id: 4 + bucket_id: 1 +- task_id: 10 + project_view_id: 4 + bucket_id: 1 +- task_id: 11 + project_view_id: 4 + bucket_id: 1 +- task_id: 12 + project_view_id: 4 + bucket_id: 1 +- task_id: 13 + project_view_id: 8 + bucket_id: 4 +- task_id: 14 + project_view_id: 20 + bucket_id: 18 +- task_id: 15 + project_view_id: 24 + bucket_id: 6 +- task_id: 16 + project_view_id: 28 + bucket_id: 7 +- task_id: 17 + project_view_id: 32 + bucket_id: 8 +- task_id: 18 + project_view_id: 36 + bucket_id: 9 +- task_id: 19 + project_view_id: 40 + bucket_id: 10 +- task_id: 20 + project_view_id: 44 + bucket_id: 11 +- task_id: 21 + project_view_id: 128 + bucket_id: 12 +- task_id: 22 + project_view_id: 132 + bucket_id: 36 +- task_id: 23 + project_view_id: 136 + bucket_id: 37 +- task_id: 24 + project_view_id: 60 + bucket_id: 15 +- task_id: 25 + project_view_id: 64 + bucket_id: 16 +- task_id: 26 + project_view_id: 68 + bucket_id: 17 +- task_id: 27 + project_view_id: 4 + bucket_id: 1 +- task_id: 28 + project_view_id: 4 + bucket_id: 1 +- task_id: 29 + project_view_id: 4 + bucket_id: 1 +- task_id: 30 + project_view_id: 4 + bucket_id: 1 +- task_id: 31 + project_view_id: 4 + bucket_id: 1 +- task_id: 32 + project_view_id: 12 + bucket_id: 21 +- task_id: 33 + project_view_id: 4 + bucket_id: 1 +- task_id: 34 + project_view_id: 80 + bucket_id: 5 +- task_id: 35 + project_view_id: 84 + bucket_id: 19 +- task_id: 36 + project_view_id: 88 + bucket_id: 20 +#- task_id: 37 +# project_view_id: 8 +# bucket_id: null +#- task_id: 38 +# project_view_id: 88 +# bucket_id: null +#- task_id: 39 +# project_view_id: 100 +# bucket_id: null +- task_id: 40 + project_view_id: 144 + bucket_id: 38 +- task_id: 41 + project_view_id: 144 + bucket_id: 38 +- task_id: 42 + project_view_id: 144 + bucket_id: 38 +- task_id: 43 + project_view_id: 144 + bucket_id: 38 +- task_id: 44 + project_view_id: 152 + bucket_id: 38 +- task_id: 45 + project_view_id: 144 + bucket_id: 38 +- task_id: 46 + project_view_id: 152 + bucket_id: 38 diff --git a/pkg/db/fixtures/task_positions.yml b/pkg/db/fixtures/task_positions.yml new file mode 100644 index 000000000..08e118305 --- /dev/null +++ b/pkg/db/fixtures/task_positions.yml @@ -0,0 +1,138 @@ +- task_id: 1 + project_view_id: 1 + position: 2 +- task_id: 2 + project_view_id: 1 + position: 4 +#- task_id: 3 +# project_view_id: 1 +# position: null +#- task_id: 4 +# project_view_id: 1 +# position: null +#- task_id: 5 +# project_view_id: 1 +# position: null +#- task_id: 6 +# project_view_id: 1 +# position: null +#- task_id: 7 +# project_view_id: 1 +# position: null +#- task_id: 8 +# project_view_id: 1 +# position: null +#- task_id: 9 +# project_view_id: 1 +# position: null +#- task_id: 10 +# project_view_id: 1 +# position: null +#- task_id: 11 +# project_view_id: 1 +# position: null +#- task_id: 12 +# project_view_id: 1 +# position: null +#- task_id: 13 +# project_view_id: 2 +# position: null +#- task_id: 14 +# project_view_id: 5 +# position: null +#- task_id: 15 +# project_view_id: 6 +# position: null +#- task_id: 16 +# project_view_id: 7 +# position: null +#- task_id: 17 +# project_view_id: 8 +# position: null +#- task_id: 18 +# project_view_id: 9 +# position: null +#- task_id: 19 +# project_view_id: 10 +# position: null +#- task_id: 20 +# project_view_id: 11 +# position: null +#- task_id: 21 +# project_view_id: 32 +# position: null +#- task_id: 22 +# project_view_id: 33 +# position: null +#- task_id: 23 +# project_view_id: 34 +# position: null +#- task_id: 24 +# project_view_id: 15 +# position: null +#- task_id: 25 +# project_view_id: 16 +# position: null +#- task_id: 26 +# project_view_id: 17 +# position: null +#- task_id: 27 +# project_view_id: 1 +# position: null +#- task_id: 28 +# project_view_id: 1 +# position: null +#- task_id: 29 +# project_view_id: 1 +# position: null +#- task_id: 30 +# project_view_id: 1 +# position: null +#- task_id: 31 +# project_view_id: 1 +# position: null +#- task_id: 32 +# project_view_id: 3 +# position: null +#- task_id: 33 +# project_view_id: 1 +# position: null +#- task_id: 34 +# project_view_id: 20 +# position: null +- task_id: 35 + project_view_id: 21 + position: 0 +#- task_id: 36 +# project_view_id: 22 +# position: null +#- task_id: 37 +# project_view_id: 2 +# position: null +#- task_id: 38 +# project_view_id: 22 +# position: null +- task_id: 39 + project_view_id: 25 + position: 0 +- task_id: 40 + project_view_id: 36 + position: 39 +- task_id: 41 + project_view_id: 36 + position: 40 +- task_id: 42 + project_view_id: 36 + position: 41 +- task_id: 43 + project_view_id: 36 + position: 42 +- task_id: 44 + project_view_id: 38 + position: 43 +- task_id: 45 + project_view_id: 36 + position: 44 +- task_id: 46 + project_view_id: 38 + position: 45 diff --git a/pkg/db/fixtures/tasks.yml b/pkg/db/fixtures/tasks.yml index fe636fb10..7657c2bb6 100644 --- a/pkg/db/fixtures/tasks.yml +++ b/pkg/db/fixtures/tasks.yml @@ -7,8 +7,6 @@ index: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 1 - position: 2 - id: 2 title: 'task #2 done' done: true @@ -17,8 +15,6 @@ index: 2 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 1 - position: 4 - id: 3 title: 'task #3 high prio' done: false @@ -28,7 +24,6 @@ created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 priority: 100 - bucket_id: 2 - id: 4 title: 'task #4 low prio' done: false @@ -38,7 +33,6 @@ created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 priority: 1 - bucket_id: 2 - id: 5 title: 'task #5 higher due date' done: false @@ -48,7 +42,6 @@ created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 due_date: 2018-12-01 03:58:44 - bucket_id: 2 - id: 6 title: 'task #6 lower due date' done: false @@ -58,7 +51,6 @@ created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 due_date: 2018-11-30 22:25:24 - bucket_id: 3 - id: 7 title: 'task #7 with start date' done: false @@ -68,7 +60,6 @@ created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 start_date: 2018-12-12 07:33:20 - bucket_id: 3 - id: 8 title: 'task #8 with end date' done: false @@ -78,7 +69,6 @@ created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 end_date: 2018-12-13 11:20:00 - bucket_id: 3 - id: 9 title: 'task #9 with start and end date' done: false @@ -89,14 +79,12 @@ updated: 2018-12-01 01:12:04 start_date: 2018-12-12 07:33:20 end_date: 2018-12-13 11:20:00 - bucket_id: 1 - id: 10 title: 'task #10 basic' done: false created_by_id: 1 project_id: 1 index: 10 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 11 @@ -105,7 +93,6 @@ created_by_id: 1 project_id: 1 index: 11 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 12 @@ -114,7 +101,6 @@ created_by_id: 1 project_id: 1 index: 12 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 13 @@ -123,7 +109,6 @@ created_by_id: 1 project_id: 2 index: 1 - bucket_id: 4 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 14 @@ -132,7 +117,6 @@ created_by_id: 5 project_id: 5 index: 1 - bucket_id: 18 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 15 @@ -141,7 +125,6 @@ created_by_id: 6 project_id: 6 index: 1 - bucket_id: 6 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 16 @@ -150,7 +133,6 @@ created_by_id: 6 project_id: 7 index: 1 - bucket_id: 7 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 17 @@ -159,7 +141,6 @@ created_by_id: 6 project_id: 8 index: 1 - bucket_id: 8 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 18 @@ -168,7 +149,6 @@ created_by_id: 6 project_id: 9 index: 1 - bucket_id: 9 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 19 @@ -177,7 +157,6 @@ created_by_id: 6 project_id: 10 index: 1 - bucket_id: 10 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 20 @@ -186,7 +165,6 @@ created_by_id: 6 project_id: 11 index: 1 - bucket_id: 11 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 21 @@ -195,7 +173,6 @@ created_by_id: 6 project_id: 32 index: 1 - bucket_id: 12 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 22 @@ -204,7 +181,6 @@ created_by_id: 6 project_id: 33 index: 1 - bucket_id: 36 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 23 @@ -213,7 +189,6 @@ created_by_id: 6 project_id: 34 index: 1 - bucket_id: 37 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 24 @@ -222,7 +197,6 @@ created_by_id: 6 project_id: 15 index: 1 - bucket_id: 15 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 25 @@ -231,7 +205,6 @@ created_by_id: 6 project_id: 16 index: 1 - bucket_id: 16 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 26 @@ -240,7 +213,6 @@ created_by_id: 6 project_id: 17 index: 1 - bucket_id: 17 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 27 @@ -249,7 +221,6 @@ created_by_id: 1 project_id: 1 index: 12 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 start_date: 2018-11-30 22:25:24 @@ -260,7 +231,6 @@ repeat_after: 3600 project_id: 1 index: 13 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 29 @@ -269,7 +239,6 @@ created_by_id: 1 project_id: 1 index: 14 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 30 @@ -278,7 +247,6 @@ created_by_id: 1 project_id: 1 index: 15 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 31 @@ -288,7 +256,6 @@ project_id: 1 index: 16 hex_color: f0f0f0 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 32 @@ -297,7 +264,6 @@ created_by_id: 1 project_id: 3 index: 1 - bucket_id: 21 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 33 @@ -307,7 +273,6 @@ project_id: 1 index: 17 percent_done: 0.5 - bucket_id: 1 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 # This task is forbidden for user1 @@ -317,7 +282,6 @@ created_by_id: 13 project_id: 20 index: 20 - bucket_id: 5 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 35 @@ -326,7 +290,6 @@ created_by_id: 1 project_id: 21 index: 1 - bucket_id: 19 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 36 @@ -335,7 +298,6 @@ created_by_id: 1 project_id: 22 index: 1 - bucket_id: 20 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 due_date: 2018-10-30 22:25:24 @@ -374,8 +336,6 @@ due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 38 - position: 39 - id: 41 uid: 'uid-caldav-test-parent-task' title: 'Parent task for Caldav Test' @@ -388,8 +348,6 @@ due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 38 - position: 40 - id: 42 uid: 'uid-caldav-test-parent-task-2' title: 'Parent task for Caldav Test 2' @@ -402,8 +360,6 @@ due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 38 - position: 41 - id: 43 uid: 'uid-caldav-test-child-task' title: 'Child task for Caldav Test' @@ -416,8 +372,6 @@ due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 38 - position: 42 - id: 44 uid: 'uid-caldav-test-child-task-2' title: 'Child task for Caldav Test ' @@ -430,8 +384,6 @@ due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 38 - position: 43 - id: 45 uid: 'uid-caldav-test-parent-task-another-list' title: 'Parent task for Caldav Test' @@ -444,8 +396,6 @@ due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 38 - position: 44 - id: 46 uid: 'uid-caldav-test-child-task-another-list' title: 'Child task for Caldav Test ' @@ -457,6 +407,4 @@ index: 45 due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 - updated: 2018-12-01 01:12:04 - bucket_id: 38 - position: 45 \ No newline at end of file + updated: 2018-12-01 01:12:04 \ No newline at end of file diff --git a/pkg/integrations/kanban_test.go b/pkg/integrations/kanban_test.go index e874be316..47790d4e7 100644 --- a/pkg/integrations/kanban_test.go +++ b/pkg/integrations/kanban_test.go @@ -52,7 +52,10 @@ func TestBucket(t *testing.T) { } t.Run("ReadAll", func(t *testing.T) { t.Run("Normal", func(t *testing.T) { - rec, err := testHandler.testReadAllWithUser(nil, map[string]string{"project": "1"}) + rec, err := testHandler.testReadAllWithUser(nil, map[string]string{ + "project": "1", + "view": "4", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `testbucket1`) assert.Contains(t, rec.Body.String(), `testbucket2`) @@ -63,87 +66,151 @@ func TestBucket(t *testing.T) { t.Run("Update", func(t *testing.T) { t.Run("Normal", func(t *testing.T) { // Check the project was loaded successfully afterwards, see testReadOneWithUser - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "1"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "1", + "project": "1", + "view": "4", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Nonexisting Bucket", func(t *testing.T) { - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "9999"}, `{"title":"TestLoremIpsum"}`) + _, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "9999", + "project": "1", + "view": "4", + }, `{"title":"TestLoremIpsum"}`) require.Error(t, err) assertHandlerErrorCode(t, err, models.ErrCodeBucketDoesNotExist) }) t.Run("Empty title", func(t *testing.T) { - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "1"}, `{"title":""}`) + _, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "1", + "project": "1", + "view": "4", + }, `{"title":""}`) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message.(models.ValidationHTTPError).InvalidFields, "title: non zero value required") }) t.Run("Rights check", func(t *testing.T) { t.Run("Forbidden", func(t *testing.T) { // Owned by user13 - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "5"}, `{"title":"TestLoremIpsum"}`) + _, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "5", + "project": "20", + "view": "80", + }, `{"title":"TestLoremIpsum"}`) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via Team readonly", func(t *testing.T) { - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "6"}, `{"title":"TestLoremIpsum"}`) + _, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "6", + "project": "6", + "view": "24", + }, `{"title":"TestLoremIpsum"}`) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via Team write", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "7"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "7", + "project": "7", + "view": "28", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Shared Via Team admin", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "8"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "8", + "project": "8", + "view": "32", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Shared Via User readonly", func(t *testing.T) { - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "9"}, `{"title":"TestLoremIpsum"}`) + _, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "9", + "project": "9", + "view": "36", + }, `{"title":"TestLoremIpsum"}`) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via User write", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "10"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "10", + "project": "10", + "view": "40", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Shared Via User admin", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "11"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "11", + "project": "11", + "view": "44", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Shared Via Parent Project User readonly", func(t *testing.T) { - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "12"}, `{"title":"TestLoremIpsum"}`) + _, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "12", + "project": "12", + "view": "48", + }, `{"title":"TestLoremIpsum"}`) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via Parent Project User write", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "13"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "13", + "project": "13", + "view": "52", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Shared Via Parent Project User admin", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "14"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "14", + "project": "14", + "view": "56", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Shared Via Parent Project Team readonly", func(t *testing.T) { - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "15"}, `{"title":"TestLoremIpsum"}`) + _, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "15", + "project": "15", + "view": "60", + }, `{"title":"TestLoremIpsum"}`) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via Parent Project Team write", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "16"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "16", + "project": "16", + "view": "64", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) t.Run("Shared Via Parent Project Team admin", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"bucket": "17"}, `{"title":"TestLoremIpsum"}`) + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{ + "bucket": "17", + "project": "17", + "view": "68", + }, `{"title":"TestLoremIpsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`) }) @@ -151,7 +218,11 @@ func TestBucket(t *testing.T) { }) t.Run("Delete", func(t *testing.T) { t.Run("Normal", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "1", "bucket": "1"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "1", + "bucket": "1", + "view": "4", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) @@ -173,60 +244,104 @@ func TestBucket(t *testing.T) { assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via Team write", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "7", "bucket": "7"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "7", + "bucket": "7", + "view": "28", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) t.Run("Shared Via Team admin", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "8", "bucket": "8"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "8", + "bucket": "8", + "view": "32", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) t.Run("Shared Via User readonly", func(t *testing.T) { - _, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "9", "bucket": "9"}) + _, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "9", + "bucket": "9", + "view": "36", + }) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via User write", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "10", "bucket": "10"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "10", + "bucket": "10", + "view": "40", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) t.Run("Shared Via User admin", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "11", "bucket": "11"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "11", + "bucket": "11", + "view": "44", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) t.Run("Shared Via Parent Project Team readonly", func(t *testing.T) { - _, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "12", "bucket": "12"}) + _, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "12", + "bucket": "12", + "view": "48", + }) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via Parent Project Team write", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "13", "bucket": "13"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "13", + "bucket": "13", + "view": "52", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) t.Run("Shared Via Parent Project Team admin", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "14", "bucket": "14"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "14", + "bucket": "14", + "view": "56", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) t.Run("Shared Via Parent Project User readonly", func(t *testing.T) { - _, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "15", "bucket": "15"}) + _, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "15", + "bucket": "15", + "view": "60", + }) require.Error(t, err) assert.Contains(t, err.(*echo.HTTPError).Message, `Forbidden`) }) t.Run("Shared Via Parent Project User write", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "16", "bucket": "16"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "16", + "bucket": "16", + "view": "64", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) t.Run("Shared Via Parent Project User admin", func(t *testing.T) { - rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "17", "bucket": "17"}) + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{ + "project": "17", + "bucket": "17", + "view": "68", + }) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"message":"Successfully deleted."`) }) @@ -315,13 +430,16 @@ func TestBucket(t *testing.T) { }) }) t.Run("Link Share", func(t *testing.T) { - rec, err := testHandlerLinkShareWrite.testCreateWithLinkShare(nil, map[string]string{"project": "2"}, `{"title":"Lorem Ipsum"}`) + rec, err := testHandlerLinkShareWrite.testCreateWithLinkShare(nil, map[string]string{ + "project": "2", + "view": "8", + }, `{"title":"Lorem Ipsum"}`) require.NoError(t, err) assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`) db.AssertExists(t, "buckets", map[string]interface{}{ - "project_id": 2, - "created_by_id": -2, - "title": "Lorem Ipsum", + "project_view_id": 8, + "created_by_id": -2, + "title": "Lorem Ipsum", }, false) }) }) diff --git a/pkg/integrations/task_collection_test.go b/pkg/integrations/task_collection_test.go index 6ae87f52e..6b7ea6b8e 100644 --- a/pkg/integrations/task_collection_test.go +++ b/pkg/integrations/task_collection_test.go @@ -115,49 +115,49 @@ func TestTaskCollection(t *testing.T) { t.Run("by priority", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) + assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) }) t.Run("by priority desc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"desc"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":1`) + assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":1`) }) t.Run("by priority asc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"asc"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) + assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) }) // should equal duedate asc t.Run("by due_date", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) + assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) }) t.Run("by duedate desc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"desc"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`) + assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`) }) // Due date without unix suffix t.Run("by duedate asc without suffix", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"asc"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) + assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) }) t.Run("by due_date without suffix", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) + assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) }) t.Run("by duedate desc without suffix", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"desc"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`) + assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`) }) t.Run("by duedate asc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"asc"}}, urlParams) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) + assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) }) t.Run("invalid sort parameter", func(t *testing.T) { _, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"loremipsum"}}, urlParams) @@ -358,33 +358,33 @@ func TestTaskCollection(t *testing.T) { t.Run("by priority", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}}, nil) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"reactions":null,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"reactions":null,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":19,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":39,"title":"task #39","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":25,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"#0","index":0,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) + assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":39,"title":"task #39","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":25,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"#0","index":0,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) }) t.Run("by priority desc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"desc"}}, nil) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":1`) + assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":1`) }) t.Run("by priority asc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"asc"}}, nil) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"reactions":null,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"reactions":null,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":19,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":39,"title":"task #39","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":25,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"#0","index":0,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) + assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":39,"title":"task #39","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":25,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"#0","index":0,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`) }) // should equal duedate asc t.Run("by due_date", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}}, nil) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) + assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) }) t.Run("by duedate desc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"desc"}}, nil) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`) + assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`) }) t.Run("by duedate asc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"asc"}}, nil) require.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) + assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`) }) t.Run("invalid parameter", func(t *testing.T) { // Invalid parameter should not sort at all diff --git a/pkg/integrations/task_test.go b/pkg/integrations/task_test.go index 761941d3c..099a7b5ce 100644 --- a/pkg/integrations/task_test.go +++ b/pkg/integrations/task_test.go @@ -317,7 +317,7 @@ func TestTask(t *testing.T) { t.Run("Different Project", func(t *testing.T) { _, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "1"}, `{"bucket_id":4}`) require.Error(t, err) - assertHandlerErrorCode(t, err, models.ErrCodeBucketDoesNotBelongToProject) + assertHandlerErrorCode(t, err, models.ErrCodeBucketDoesNotExist) }) t.Run("Nonexisting Bucket", func(t *testing.T) { _, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "1"}, `{"bucket_id":9999}`) diff --git a/pkg/migration/20240313230538.go b/pkg/migration/20240313230538.go new file mode 100644 index 000000000..3f712017b --- /dev/null +++ b/pkg/migration/20240313230538.go @@ -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 . + +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 + }, + }) +} diff --git a/pkg/migration/20240314214802.go b/pkg/migration/20240314214802.go new file mode 100644 index 000000000..c266bbaa1 --- /dev/null +++ b/pkg/migration/20240314214802.go @@ -0,0 +1,200 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package migration + +import ( + "code.vikunja.io/api/pkg/config" + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type taskPositions20240314214802 struct { + TaskID int64 `xorm:"bigint not null index" json:"task_id"` + ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id"` + Position float64 `xorm:"double not null" json:"position"` +} + +func (taskPositions20240314214802) TableName() string { + return "task_positions" +} + +type task20240314214802 struct { + ID int64 `xorm:"bigint autoincr not null unique pk"` + ProjectID int64 `xorm:"bigint INDEX not null"` + Position float64 `xorm:"double not null"` + KanbanPosition float64 `xorm:"double not null"` +} + +func (task20240314214802) TableName() string { + return "tasks" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20240314214802", + Description: "make task position 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 + }, + }) +} diff --git a/pkg/migration/20240315093418.go b/pkg/migration/20240315093418.go new file mode 100644 index 000000000..0fa146075 --- /dev/null +++ b/pkg/migration/20240315093418.go @@ -0,0 +1,119 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package migration + +import ( + "code.vikunja.io/api/pkg/config" + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type buckets20240315093418 struct { + ID int64 `xorm:"bigint autoincr not null unique pk"` + ProjectID int64 `xorm:"bigint not null"` + ProjectViewID int64 `xorm:"bigint not null default 0"` +} + +func (buckets20240315093418) TableName() string { + return "buckets" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20240315093418", + Description: "Relate buckets to views instead of projects", + Migrate: func(tx *xorm.Engine) (err error) { + err = tx.Sync2(buckets20240315093418{}) + if err != nil { + return + } + + buckets := []*buckets20240315093418{} + err = tx.Find(&buckets) + if err != nil { + return err + } + + views := []*projectView20240313230538{} + err = tx.Find(&views) + if err != nil { + return err + } + + viewMap := make(map[int64][]*projectView20240313230538) + for _, view := range views { + if _, has := viewMap[view.ProjectID]; !has { + viewMap[view.ProjectID] = []*projectView20240313230538{} + } + + viewMap[view.ProjectID] = append(viewMap[view.ProjectID], view) + } + + for _, bucket := range buckets { + for _, view := range viewMap[bucket.ProjectID] { + if view.ViewKind == 3 { // Kanban view + + bucket.ProjectViewID = view.ID + + _, err = tx. + Where("id = ?", bucket.ID). + Cols("project_view_id"). + Update(bucket) + if err != nil { + return err + } + } + } + } + + if config.DatabaseType.GetString() == "sqlite" { + _, err = tx.Exec(` +create table buckets_dg_tmp +( + id INTEGER not null + primary key autoincrement, + title TEXT not null, + "limit" INTEGER default 0, + position REAL, + created DATETIME not null, + updated DATETIME not null, + created_by_id INTEGER not null, + project_view_id INTEGER not null default 0 +); + +insert into buckets_dg_tmp(id, title, "limit", position, created, updated, created_by_id, project_view_id) +select id, title, "limit", position, created, updated, created_by_id, project_view_id +from buckets; + +drop table buckets; + +alter table buckets_dg_tmp + rename to buckets; + +create unique index UQE_buckets_id + on buckets (id); +`) + return err + } + + return dropTableColum(tx, "buckets", "project_id") + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/migration/20240315104205.go b/pkg/migration/20240315104205.go new file mode 100644 index 000000000..6100af526 --- /dev/null +++ b/pkg/migration/20240315104205.go @@ -0,0 +1,158 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package migration + +import ( + "code.vikunja.io/api/pkg/config" + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type projects20240315104205 struct { + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"project"` + DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id"` + DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id"` +} + +func (projects20240315104205) TableName() string { + return "projects" +} + +type projectView20240315104205 struct { + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"project"` + ViewKind int `xorm:"not null" json:"view_kind"` + DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id"` + DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id"` + ProjectID int64 `xorm:"not null index" json:"project_id" param:"project"` +} + +func (projectView20240315104205) TableName() string { + return "project_views" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20240315104205", + Description: "Move done and default bucket id to views", + Migrate: func(tx *xorm.Engine) (err error) { + err = tx.Sync(projectView20240315104205{}) + if err != nil { + return + } + + projects := []*projects20240315104205{} + err = tx.Find(&projects) + if err != nil { + return + } + + views := []*projectView20240315104205{} + err = tx.Find(&views) + if err != nil { + return err + } + + viewMap := make(map[int64][]*projectView20240315104205) + for _, view := range views { + if _, has := viewMap[view.ProjectID]; !has { + viewMap[view.ProjectID] = []*projectView20240315104205{} + } + + viewMap[view.ProjectID] = append(viewMap[view.ProjectID], view) + } + + for _, project := range projects { + for _, view := range viewMap[project.ID] { + if view.ViewKind == 3 { // Kanban view + view.DefaultBucketID = project.DefaultBucketID + view.DoneBucketID = project.DoneBucketID + _, err = tx. + Where("id = ?", view.ID). + Cols("default_bucket_id", "done_bucket_id"). + Update(view) + if err != nil { + return + } + } + } + } + + if config.DatabaseType.GetString() == "sqlite" { + _, err = tx.Exec(` +create table projects_dg_tmp +( + id INTEGER not null + primary key autoincrement, + title TEXT not null, + description TEXT, + identifier TEXT, + hex_color TEXT, + owner_id INTEGER not null, + parent_project_id INTEGER, + is_archived INTEGER default 0 not null, + background_file_id INTEGER, + background_blur_hash TEXT, + position REAL, + created DATETIME not null, + updated DATETIME not null +); + +insert into projects_dg_tmp(id, title, description, identifier, hex_color, owner_id, parent_project_id, is_archived, + background_file_id, background_blur_hash, position, created, updated) +select id, + title, + description, + identifier, + hex_color, + owner_id, + parent_project_id, + is_archived, + background_file_id, + background_blur_hash, + position, + created, + updated +from projects; + +drop table projects; + +alter table projects_dg_tmp + rename to projects; + +create index IDX_projects_owner_id + on projects (owner_id); + +create index IDX_projects_parent_project_id + on projects (parent_project_id); + +create unique index UQE_projects_id + on projects (id); +`) + return err + } + + err = dropTableColum(tx, "projects", "done_bucket_id") + if err != nil { + return + } + return dropTableColum(tx, "projects", "default_bucket_id") + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/migration/20240315110428.go b/pkg/migration/20240315110428.go new file mode 100644 index 000000000..b2ae07ba8 --- /dev/null +++ b/pkg/migration/20240315110428.go @@ -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 . + +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 + }, + }) +} diff --git a/pkg/models/error.go b/pkg/models/error.go index 8eb151830..25cb0a0b9 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -412,6 +412,33 @@ func (err *ErrCannotArchiveDefaultProject) HTTPError() web.HTTPError { } } +// ErrProjectViewDoesNotExist represents an error where the default project is being deleted +type ErrProjectViewDoesNotExist struct { + ProjectViewID int64 +} + +// IsErrProjectViewDoesNotExist checks if an error is a project is archived error. +func IsErrProjectViewDoesNotExist(err error) bool { + _, ok := err.(*ErrProjectViewDoesNotExist) + return ok +} + +func (err *ErrProjectViewDoesNotExist) Error() string { + return fmt.Sprintf("Project view does not exist [ProjectViewID: %d]", err.ProjectViewID) +} + +// ErrCodeProjectViewDoesNotExist holds the unique world-error code of this error +const ErrCodeProjectViewDoesNotExist = 3014 + +// HTTPError holds the http error description +func (err *ErrProjectViewDoesNotExist) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusNotFound, + Code: ErrCodeProjectViewDoesNotExist, + Message: "This project view does not exist.", + } +} + // ============== // Task errors // ============== @@ -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.", } } diff --git a/pkg/models/export.go b/pkg/models/export.go index 05fe5a178..99a1d88c2 100644 --- a/pkg/models/export.go +++ b/pkg/models/export.go @@ -158,7 +158,7 @@ func exportProjectsAndTasks(s *xorm.Session, u *user.User, wr *zip.Writer) (task tasks, _, _, err := getTasksForProjects(s, rawProjects, u, &taskSearchOptions{ page: 0, perPage: -1, - }) + }, nil) if err != nil { return taskIDs, err } diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index 0b6adaffc..d420452ad 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -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 } diff --git a/pkg/models/kanban_rights.go b/pkg/models/kanban_rights.go index 53d18d4e4..9438e7803 100644 --- a/pkg/models/kanban_rights.go +++ b/pkg/models/kanban_rights.go @@ -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) } diff --git a/pkg/models/kanban_test.go b/pkg/models/kanban_test.go index c997d9d18..3ce8a08eb 100644 --- a/pkg/models/kanban_test.go +++ b/pkg/models/kanban_test.go @@ -35,7 +35,10 @@ func TestBucket_ReadAll(t *testing.T) { defer s.Close() testuser := &user.User{ID: 1} - b := &Bucket{ProjectID: 1} + b := &TaskCollection{ + ProjectViewID: 4, + ProjectID: 1, + } bucketsInterface, _, _, err := b.ReadAll(s, testuser, "", 0, 0) require.NoError(t, err) @@ -78,11 +81,10 @@ func TestBucket_ReadAll(t *testing.T) { defer s.Close() testuser := &user.User{ID: 1} - b := &Bucket{ - ProjectID: 1, - TaskCollection: TaskCollection{ - Filter: "title ~ 'done'", - }, + b := &TaskCollection{ + ProjectViewID: 4, + ProjectID: 1, + Filter: "title ~ 'done'", } bucketsInterface, _, _, err := b.ReadAll(s, testuser, "", -1, 0) require.NoError(t, err) @@ -98,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) diff --git a/pkg/models/models.go b/pkg/models/models.go index f8404978c..a9d5d388d 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -62,6 +62,9 @@ func GetTables() []interface{} { &TypesenseSync{}, &Webhook{}, &Reaction{}, + &ProjectView{}, + &TaskPosition{}, + &TaskBucket{}, } } diff --git a/pkg/models/project.go b/pkg/models/project.go index 7be2d933c..677ed34ff 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -52,9 +52,7 @@ type Project struct { ParentProjectID int64 `xorm:"bigint INDEX null" json:"parent_project_id"` ParentProject *Project `xorm:"-" json:"-"` - // The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a project. - DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id"` - // If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket. + // Deprecated: If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket. DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id"` // The user who created this project. @@ -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 } diff --git a/pkg/models/project_duplicate.go b/pkg/models/project_duplicate.go index ee6882b63..dbc38d2af 100644 --- a/pkg/models/project_duplicate.go +++ b/pkg/models/project_duplicate.go @@ -81,7 +81,8 @@ func (pd *ProjectDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) { pd.Project.ParentProjectID = pd.ParentProjectID // Set the owner to the current user pd.Project.OwnerID = doer.GetID() - if err := CreateProject(s, pd.Project, doer, false); err != nil { + err = CreateProject(s, pd.Project, doer, false, false) + if err != nil { // If there is no available unique project identifier, just reset it. if IsErrProjectIdentifierIsNotUnique(err) { pd.Project.Identifier = "" @@ -92,32 +93,20 @@ func (pd *ProjectDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) { log.Debugf("Duplicated project %d into new project %d", pd.ProjectID, pd.Project.ID) - // Duplicate kanban buckets - // Old bucket ID as key, new id as value - // Used to map the newly created tasks to their new buckets - bucketMap := make(map[int64]int64) - buckets := []*Bucket{} - err = s.Where("project_id = ?", pd.ProjectID).Find(&buckets) + newTaskIDs, err := duplicateTasks(s, doer, pd) if err != nil { return } - for _, b := range buckets { - oldID := b.ID - b.ID = 0 - b.ProjectID = pd.Project.ID - if err := b.Create(s, doer); err != nil { - return err - } - bucketMap[oldID] = b.ID - } - log.Debugf("Duplicated all buckets from project %d into %d", pd.ProjectID, pd.Project.ID) + log.Debugf("Duplicated all tasks from project %d into %d", pd.ProjectID, pd.Project.ID) - err = duplicateTasks(s, doer, pd, bucketMap) + err = duplicateViews(s, pd, doer, newTaskIDs) if err != nil { return } + log.Debugf("Duplicated all views, buckets and positions from project %d into %d", pd.ProjectID, pd.Project.ID) + err = duplicateProjectBackground(s, pd, doer) if err != nil { return @@ -173,6 +162,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 } diff --git a/pkg/models/project_duplicate_test.go b/pkg/models/project_duplicate_test.go index 0f3957791..aa16d960a 100644 --- a/pkg/models/project_duplicate_test.go +++ b/pkg/models/project_duplicate_test.go @@ -48,11 +48,11 @@ func TestProjectDuplicate(t *testing.T) { require.NoError(t, err) // assert the new project has the same number of buckets as the old one - numberOfOriginalBuckets, err := s.Where("project_id = ?", l.ProjectID).Count(&Bucket{}) + numberOfOriginalViews, err := s.Where("project_id = ?", l.ProjectID).Count(&ProjectView{}) require.NoError(t, err) - numberOfDuplicatedBuckets, err := s.Where("project_id = ?", l.Project.ID).Count(&Bucket{}) + numberOfDuplicatedViews, err := s.Where("project_id = ?", l.Project.ID).Count(&ProjectView{}) require.NoError(t, err) - assert.Equal(t, numberOfOriginalBuckets, numberOfDuplicatedBuckets, "duplicated project does not have the same amount of buckets as the original one") + assert.Equal(t, numberOfOriginalViews, numberOfDuplicatedViews, "duplicated project does not have the same amount of views as the original one") // To make this test 100% useful, it would need to assert a lot more stuff, but it is good enough for now. // Also, we're lacking utility functions to do all needed assertions. diff --git a/pkg/models/project_rights.go b/pkg/models/project_rights.go index c4b251b84..8442d52a0 100644 --- a/pkg/models/project_rights.go +++ b/pkg/models/project_rights.go @@ -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{} diff --git a/pkg/models/project_test.go b/pkg/models/project_test.go index 8d79badd8..3bc7f9fca 100644 --- a/pkg/models/project_test.go +++ b/pkg/models/project_test.go @@ -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) { diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go new file mode 100644 index 000000000..6d6bd5fbe --- /dev/null +++ b/pkg/models/project_view.go @@ -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 . + +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 +} diff --git a/pkg/models/project_view_rights.go b/pkg/models/project_view_rights.go new file mode 100644 index 000000000..39dad870b --- /dev/null +++ b/pkg/models/project_view_rights.go @@ -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 . + +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} +} diff --git a/pkg/models/saved_filters.go b/pkg/models/saved_filters.go index be320f347..e4ee322be 100644 --- a/pkg/models/saved_filters.go +++ b/pkg/models/saved_filters.go @@ -116,9 +116,14 @@ func (sf *SavedFilter) toProject() *Project { // @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter." // @Failure 500 {object} models.Message "Internal error" // @Router /filters [put] -func (sf *SavedFilter) Create(s *xorm.Session, auth web.Auth) error { +func (sf *SavedFilter) Create(s *xorm.Session, auth web.Auth) (err error) { sf.OwnerID = auth.GetID() - _, err := s.Insert(sf) + _, err = s.Insert(sf) + if err != nil { + return + } + + err = CreateDefaultViewsForProject(s, &Project{ID: getProjectIDFromSavedFilterID(sf.ID)}, auth, true) return err } diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index f32658fa4..c87f00e22 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -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) } diff --git a/pkg/models/task_collection_sort.go b/pkg/models/task_collection_sort.go index 8b6a2f06b..bd686e049 100644 --- a/pkg/models/task_collection_sort.go +++ b/pkg/models/task_collection_sort.go @@ -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) } diff --git a/pkg/models/task_collection_sort_test.go b/pkg/models/task_collection_sort_test.go index d8385cbd3..6b28d95b9 100644 --- a/pkg/models/task_collection_sort_test.go +++ b/pkg/models/task_collection_sort_test.go @@ -61,7 +61,6 @@ func TestSortParamValidation(t *testing.T) { taskPropertyUID, taskPropertyCreated, taskPropertyUpdated, - taskPropertyPosition, } { t.Run(test, func(t *testing.T) { s := &sortParam{ diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go index 63a8ab766..b321a1d48 100644 --- a/pkg/models/task_collection_test.go +++ b/pkg/models/task_collection_test.go @@ -95,9 +95,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedByID: 1, CreatedBy: user1, ProjectID: 1, - BucketID: 1, IsFavorite: true, - Position: 2, Reactions: ReactionMap{ "👋": []*user.User{user1}, }, @@ -112,7 +110,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { Index: 14, CreatedByID: 1, ProjectID: 1, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), }, @@ -170,8 +167,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedByID: 1, CreatedBy: user1, ProjectID: 1, - BucketID: 1, - Position: 4, Labels: []*Label{ label4, }, @@ -199,7 +194,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), Priority: 100, - BucketID: 2, } task4 := &Task{ ID: 4, @@ -213,7 +207,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), Priority: 1, - BucketID: 2, } task5 := &Task{ ID: 5, @@ -227,7 +220,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), DueDate: time.Unix(1543636724, 0).In(loc), - BucketID: 2, } task6 := &Task{ ID: 6, @@ -241,7 +233,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), DueDate: time.Unix(1543616724, 0).In(loc), - BucketID: 3, } task7 := &Task{ ID: 7, @@ -255,7 +246,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), StartDate: time.Unix(1544600000, 0).In(loc), - BucketID: 3, } task8 := &Task{ ID: 8, @@ -269,7 +259,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), EndDate: time.Unix(1544700000, 0).In(loc), - BucketID: 3, } task9 := &Task{ ID: 9, @@ -280,7 +269,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user1, ProjectID: 1, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), StartDate: time.Unix(1544600000, 0).In(loc), @@ -295,7 +283,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user1, ProjectID: 1, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -308,7 +295,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user1, ProjectID: 1, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -321,7 +307,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user1, ProjectID: 1, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -335,7 +320,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { ProjectID: 6, IsFavorite: true, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 6, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -348,7 +332,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 7, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 7, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -361,7 +344,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 8, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 8, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -374,7 +356,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 9, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 9, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -387,7 +368,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 10, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 10, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -400,7 +380,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 11, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 11, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -413,7 +392,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 32, // parent project is shared to user 1 via direct share RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 12, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -426,7 +404,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 33, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 36, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -439,7 +416,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 34, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 37, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -452,7 +428,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 15, // parent project is shared to user 1 via team RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 15, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -465,7 +440,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 16, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 16, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -478,7 +452,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user6, ProjectID: 17, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 17, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -507,7 +480,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { }, StartDate: time.Unix(1543616724, 0).In(loc), ProjectID: 1, - BucketID: 1, RelatedTasks: map[RelationKind][]*Task{}, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), @@ -522,7 +494,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { ProjectID: 1, RelatedTasks: map[RelationKind][]*Task{}, RepeatAfter: 3600, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -546,14 +517,11 @@ func TestTaskCollection_ReadAll(t *testing.T) { IsFavorite: true, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), - BucketID: 1, - Position: 2, }, }, }, - BucketID: 1, - Created: time.Unix(1543626724, 0).In(loc), - Updated: time.Unix(1543626724, 0).In(loc), + Created: time.Unix(1543626724, 0).In(loc), + Updated: time.Unix(1543626724, 0).In(loc), } task30 := &Task{ ID: 30, @@ -568,7 +536,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { user2, }, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -582,7 +549,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user1, ProjectID: 1, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -595,7 +561,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user1, ProjectID: 3, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 21, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -609,7 +574,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { ProjectID: 1, PercentDone: 0.5, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 1, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } @@ -639,8 +603,6 @@ func TestTaskCollection_ReadAll(t *testing.T) { IsFavorite: true, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), - BucketID: 1, - Position: 2, }, { ID: 1, @@ -652,14 +614,11 @@ func TestTaskCollection_ReadAll(t *testing.T) { IsFavorite: true, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), - BucketID: 1, - Position: 2, }, }, }, - BucketID: 19, - Created: time.Unix(1543626724, 0).In(loc), - Updated: time.Unix(1543626724, 0).In(loc), + Created: time.Unix(1543626724, 0).In(loc), + Updated: time.Unix(1543626724, 0).In(loc), } task39 := &Task{ ID: 39, @@ -669,16 +628,16 @@ func TestTaskCollection_ReadAll(t *testing.T) { CreatedBy: user1, ProjectID: 25, RelatedTasks: map[RelationKind][]*Task{}, - BucketID: 0, Created: time.Unix(1543626724, 0).In(loc), Updated: time.Unix(1543626724, 0).In(loc), } type fields struct { - ProjectID int64 - Projects []*Project - SortBy []string // Is a string, since this is the place where a query string comes from the user - OrderBy []string + ProjectID int64 + ProjectViewID int64 + Projects []*Project + SortBy []string // Is a string, since this is the place where a query string comes from the user + OrderBy []string FilterIncludeNulls bool Filter string @@ -705,6 +664,13 @@ func TestTaskCollection_ReadAll(t *testing.T) { page: 0, } + taskWithPosition := func(task *Task, position float64) *Task { + newTask := &Task{} + *newTask = *task + newTask.Position = position + return newTask + } + tests := []testcase{ { name: "ReadAll Tasks normally", @@ -1258,16 +1224,18 @@ func TestTaskCollection_ReadAll(t *testing.T) { { name: "order by position", fields: fields{ - SortBy: []string{"position", "id"}, - OrderBy: []string{"asc", "asc"}, + SortBy: []string{"position", "id"}, + OrderBy: []string{"asc", "asc"}, + ProjectViewID: 1, + ProjectID: 1, }, args: args{ a: &user.User{ID: 1}, }, want: []*Task{ // The only tasks with a position set - task1, - task2, + taskWithPosition(task1, 2), + taskWithPosition(task2, 4), // the other ones don't have a position set task3, task4, @@ -1279,27 +1247,24 @@ func TestTaskCollection_ReadAll(t *testing.T) { task10, task11, task12, - task15, - task16, - task17, - task18, - task19, - task20, - task21, - task22, - task23, - task24, - task25, - task26, + //task15, + //task16, + //task17, + //task18, + //task19, + //task20, + //task21, + //task22, + //task23, + //task24, + //task25, + //task26, task27, task28, task29, task30, task31, - task32, task33, - task35, - task39, }, }, { @@ -1414,9 +1379,10 @@ func TestTaskCollection_ReadAll(t *testing.T) { defer s.Close() lt := &TaskCollection{ - ProjectID: tt.fields.ProjectID, - SortBy: tt.fields.SortBy, - OrderBy: tt.fields.OrderBy, + ProjectID: tt.fields.ProjectID, + ProjectViewID: tt.fields.ProjectViewID, + SortBy: tt.fields.SortBy, + OrderBy: tt.fields.OrderBy, FilterIncludeNulls: tt.fields.FilterIncludeNulls, diff --git a/pkg/models/task_position.go b/pkg/models/task_position.go new file mode 100644 index 000000000..9f558e67a --- /dev/null +++ b/pkg/models/task_position.go @@ -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 . + +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 +} diff --git a/pkg/models/task_search.go b/pkg/models/task_search.go index 66949dbfb..190fd150d 100644 --- a/pkg/models/task_search.go +++ b/pkg/models/task_search.go @@ -53,14 +53,19 @@ func getOrderByDBStatement(opts *taskSearchOptions) (orderby string, err error) return "", err } + var prefix string + if param.sortBy == taskPropertyPosition { + prefix = "task_positions." + } + // Mysql sorts columns with null values before ones without null value. // Because it does not have support for NULLS FIRST or NULLS LAST we work around this by // first sorting for null (or not null) values and then the order we actually want to. if db.Type() == schemas.MYSQL { - orderby += "`" + param.sortBy + "` IS NULL, " + orderby += prefix + "`" + param.sortBy + "` IS NULL, " } - orderby += "`" + param.sortBy + "` " + param.orderBy.String() + orderby += prefix + "`" + param.sortBy + "` " + param.orderBy.String() // Postgres and sqlite allow us to control how columns with null values are sorted. // To make that consistent with the sort order we have and other dbms, we're adding a separate clause here. @@ -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 diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 4a3b28da7..b4e72b80b 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -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 } diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index 1849936f3..10e15c4e7 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -55,8 +55,6 @@ func TestTask_Create(t *testing.T) { // Assert getting a new index assert.NotEmpty(t, task.Index) assert.Equal(t, int64(18), task.Index) - // Assert moving it into the default bucket - assert.Equal(t, int64(1), task.BucketID) err = s.Commit() require.NoError(t, err) @@ -66,7 +64,10 @@ func TestTask_Create(t *testing.T) { "description": "Lorem Ipsum Dolor", "project_id": 1, "created_by_id": 1, - "bucket_id": 1, + }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": task.ID, + "bucket_id": 1, }, false) events.AssertDispatched(t, &TaskCreatedEvent{}) @@ -183,8 +184,8 @@ func TestTask_Create(t *testing.T) { } err := task.Create(s, usr) require.NoError(t, err) - db.AssertExists(t, "tasks", map[string]interface{}{ - "id": task.ID, + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": task.ID, "bucket_id": 22, // default bucket of project 6 but with a position of 2 }, false) }) @@ -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) }) diff --git a/pkg/models/typesense.go b/pkg/models/typesense.go index 368da9e8c..b876ff9cc 100644 --- a/pkg/models/typesense.go +++ b/pkg/models/typesense.go @@ -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 } diff --git a/pkg/models/unit_tests.go b/pkg/models/unit_tests.go index b8f0a99de..584fbe4ca 100644 --- a/pkg/models/unit_tests.go +++ b/pkg/models/unit_tests.go @@ -66,6 +66,9 @@ func SetupTests() { "favorites", "api_tokens", "reactions", + "project_views", + "task_positions", + "task_buckets", ) if err != nil { log.Fatal(err) diff --git a/pkg/modules/migration/create_from_structure.go b/pkg/modules/migration/create_from_structure.go index 66bdeefa1..12bad93dd 100644 --- a/pkg/modules/migration/create_from_structure.go +++ b/pkg/modules/migration/create_from_structure.go @@ -126,6 +126,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas originalBuckets := project.Buckets originalBackgroundInformation := project.BackgroundInformation needsDefaultBucket := false + oldViews := project.Views // Saving the archived status to archive the project again after creating it var wasArchived bool @@ -182,6 +183,47 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas log.Debugf("[creating structure] Created bucket %d, old ID was %d", bucket.ID, oldID) } + // Create all views, create default views if we don't have any + if len(oldViews) > 0 { + for _, view := range oldViews { + view.ID = 0 + + if view.DefaultBucketID != 0 { + bucket, has := buckets[view.DefaultBucketID] + if has { + view.DefaultBucketID = bucket.ID + } + } + + if view.DoneBucketID != 0 { + bucket, has := buckets[view.DoneBucketID] + if has { + view.DoneBucketID = bucket.ID + } + } + + err = view.Create(s, user) + if err != nil { + return + } + } + } else { + // Only using the default views + // Add all buckets to the default kanban view + for _, view := range project.Views { + if view.ViewKind == models.ProjectViewKindKanban { + for _, b := range buckets { + b.ProjectViewID = view.ID + err = b.Update(s, user) + if err != nil { + return + } + } + break + } + } + } + log.Debugf("[creating structure] Creating %d tasks", len(tasks)) setBucketOrDefault := func(task *models.Task) { @@ -205,7 +247,6 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas oldid := t.ID t.ProjectID = project.ID err = t.Create(s, user) - if err != nil && models.IsErrTaskCannotBeEmpty(err) { continue } @@ -332,6 +373,14 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas // All tasks brought their own bucket with them, therefore the newly created default bucket is just extra space if !needsDefaultBucket { b := &models.Bucket{ProjectID: project.ID} + + for _, view := range project.Views { + if view.ViewKind == models.ProjectViewKindKanban { + b.ProjectViewID = view.ID + break + } + } + bucketsIn, _, _, err := b.ReadAll(s, user, "", 1, 1) if err != nil { return err @@ -341,6 +390,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas for _, b := range buckets { if b.Title == "Backlog" { newBacklogBucket = b + newBacklogBucket.ProjectID = project.ID break } } diff --git a/pkg/modules/migration/create_from_structure_test.go b/pkg/modules/migration/create_from_structure_test.go index 41395436a..8e0c63572 100644 --- a/pkg/modules/migration/create_from_structure_test.go +++ b/pkg/modules/migration/create_from_structure_test.go @@ -142,12 +142,11 @@ func TestInsertFromStructure(t *testing.T) { "title": testStructure[1].Title, "description": testStructure[1].Description, }, false) - db.AssertExists(t, "tasks", map[string]interface{}{ - "title": testStructure[1].Tasks[5].Title, + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": testStructure[1].Tasks[5].ID, "bucket_id": testStructure[1].Buckets[0].ID, }, false) - db.AssertMissing(t, "tasks", map[string]interface{}{ - "title": testStructure[1].Tasks[6].Title, + db.AssertMissing(t, "task_buckets", map[string]interface{}{ "bucket_id": 1111, // No task with that bucket should exist }) db.AssertExists(t, "tasks", map[string]interface{}{ diff --git a/pkg/modules/migration/trello/trello.go b/pkg/modules/migration/trello/trello.go index d19fd48b7..b96ed35a8 100644 --- a/pkg/modules/migration/trello/trello.go +++ b/pkg/modules/migration/trello/trello.go @@ -253,9 +253,8 @@ func convertTrelloDataToVikunja(trelloData []*trello.Board, token string) (fullV // The usual stuff: Title, description, position, bucket id task := &models.Task{ - Title: card.Name, - KanbanPosition: card.Pos, - BucketID: bucketID, + Title: card.Name, + BucketID: bucketID, } task.Description, err = convertMarkdownToHTML(card.Desc) diff --git a/pkg/modules/migration/trello/trello_test.go b/pkg/modules/migration/trello/trello_test.go index cc7757ca3..4099a2250 100644 --- a/pkg/modules/migration/trello/trello_test.go +++ b/pkg/modules/migration/trello/trello_test.go @@ -228,11 +228,10 @@ func TestConvertTrelloToVikunja(t *testing.T) { Tasks: []*models.TaskWithComments{ { Task: models.Task{ - Title: "Test Card 1", - Description: "

Card Description bold

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

Card Description bold

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

  • Another Pending Task

`, - BucketID: 1, - KanbanPosition: 124, + BucketID: 1, }, }, { Task: models.Task{ - Title: "Test Card 3", - BucketID: 1, - KanbanPosition: 126, + Title: "Test Card 3", + BucketID: 1, }, }, { Task: models.Task{ - Title: "Test Card 4", - BucketID: 1, - KanbanPosition: 127, + Title: "Test Card 4", + BucketID: 1, Labels: []*models.Label{ { Title: "Label 2", @@ -297,9 +293,8 @@ func TestConvertTrelloToVikunja(t *testing.T) { }, { Task: models.Task{ - Title: "Test Card 5", - BucketID: 2, - KanbanPosition: 111, + Title: "Test Card 5", + BucketID: 2, Labels: []*models.Label{ { Title: "Label 3", @@ -318,24 +313,21 @@ func TestConvertTrelloToVikunja(t *testing.T) { }, { Task: models.Task{ - Title: "Test Card 6", - BucketID: 2, - KanbanPosition: 222, - DueDate: time1, + Title: "Test Card 6", + BucketID: 2, + DueDate: time1, }, }, { Task: models.Task{ - Title: "Test Card 7", - BucketID: 2, - KanbanPosition: 333, + Title: "Test Card 7", + BucketID: 2, }, }, { Task: models.Task{ - Title: "Test Card 8", - BucketID: 2, - KanbanPosition: 444, + Title: "Test Card 8", + BucketID: 2, }, }, }, @@ -355,9 +347,8 @@ func TestConvertTrelloToVikunja(t *testing.T) { Tasks: []*models.TaskWithComments{ { Task: models.Task{ - Title: "Test Card 634", - BucketID: 3, - KanbanPosition: 123, + Title: "Test Card 634", + BucketID: 3, }, }, }, @@ -378,9 +369,8 @@ func TestConvertTrelloToVikunja(t *testing.T) { Tasks: []*models.TaskWithComments{ { Task: models.Task{ - Title: "Test Card 63423", - BucketID: 4, - KanbanPosition: 123, + Title: "Test Card 63423", + BucketID: 4, }, }, }, diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index d59970059..e6f4e89fc 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -355,6 +355,7 @@ func registerAPIRoutes(a *echo.Group) { return &models.TaskCollection{} }, } + a.GET("/projects/:project/views/:view/tasks", taskCollectionHandler.ReadAllWeb) a.GET("/projects/:project/tasks", taskCollectionHandler.ReadAllWeb) kanbanBucketHandler := &handler.WebHandler{ @@ -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) { diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index a27794f94..2ce644a3b 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -1,4 +1,5 @@ -// Package swagger Code generated by swaggo/swag. DO NOT EDIT +// Code generated by swaggo/swag. DO NOT EDIT. + package swagger import "github.com/swaggo/swag" @@ -1891,150 +1892,6 @@ const docTemplate = `{ } } }, - "/projects/{id}/buckets": { - "get": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Returns all kanban buckets with belong to a project including their tasks. Buckets are always sorted by their ` + "`" + `position` + "`" + ` in ascending order. Tasks are sorted by their ` + "`" + `kanban_position` + "`" + ` in ascending order.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Get all kanban buckets of a project", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "The page number for tasks. Used for pagination. If not provided, the first page of results is returned.", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "The maximum number of tasks per bucket per page. This parameter is limited by the configured maximum of items per page.", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "Search tasks by task text.", - "name": "s", - "in": "query" - }, - { - "type": "string", - "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", - "name": "filter", - "in": "query" - }, - { - "type": "string", - "description": "The time zone which should be used for date match (statements like ", - "name": "filter_timezone", - "in": "query" - }, - { - "type": "string", - "description": "If set to true the result will include filtered fields whose value is set to ` + "`" + `null` + "`" + `. Available values are ` + "`" + `true` + "`" + ` or ` + "`" + `false` + "`" + `. Defaults to ` + "`" + `false` + "`" + `.", - "name": "filter_include_nulls", - "in": "query" - } - ], - "responses": { - "200": { - "description": "The buckets with their tasks", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Bucket" - } - } - }, - "500": { - "description": "Internal server error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - }, - "put": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Creates a new kanban bucket on a project.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Create a new bucket", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "The bucket object", - "name": "bucket", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.Bucket" - } - } - ], - "responses": { - "200": { - "description": "The created bucket object.", - "schema": { - "$ref": "#/definitions/models.Bucket" - } - }, - "400": { - "description": "Invalid bucket object provided.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "404": { - "description": "The project does not exist.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - } - }, "/projects/{id}/projectusers": { "get": { "security": [ @@ -2100,98 +1957,6 @@ const docTemplate = `{ } }, "/projects/{id}/tasks": { - "get": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Returns all tasks for the current project.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "task" - ], - "summary": "Get tasks in a project", - "parameters": [ - { - "type": "integer", - "description": "The project ID.", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "The page number. Used for pagination. If not provided, the first page of results is returned.", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page.", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "Search tasks by task text.", - "name": "s", - "in": "query" - }, - { - "type": "string", - "description": "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with ` + "`" + `order_by` + "`" + `. Possible values to sort by are ` + "`" + `id` + "`" + `, ` + "`" + `title` + "`" + `, ` + "`" + `description` + "`" + `, ` + "`" + `done` + "`" + `, ` + "`" + `done_at` + "`" + `, ` + "`" + `due_date` + "`" + `, ` + "`" + `created_by_id` + "`" + `, ` + "`" + `project_id` + "`" + `, ` + "`" + `repeat_after` + "`" + `, ` + "`" + `priority` + "`" + `, ` + "`" + `start_date` + "`" + `, ` + "`" + `end_date` + "`" + `, ` + "`" + `hex_color` + "`" + `, ` + "`" + `percent_done` + "`" + `, ` + "`" + `uid` + "`" + `, ` + "`" + `created` + "`" + `, ` + "`" + `updated` + "`" + `. Default is ` + "`" + `id` + "`" + `.", - "name": "sort_by", - "in": "query" - }, - { - "type": "string", - "description": "The ordering parameter. Possible values to order by are ` + "`" + `asc` + "`" + ` or ` + "`" + `desc` + "`" + `. Default is ` + "`" + `asc` + "`" + `.", - "name": "order_by", - "in": "query" - }, - { - "type": "string", - "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", - "name": "filter", - "in": "query" - }, - { - "type": "string", - "description": "The time zone which should be used for date match (statements like ", - "name": "filter_timezone", - "in": "query" - }, - { - "type": "string", - "description": "If set to true the result will include filtered fields whose value is set to ` + "`" + `null` + "`" + `. Available values are ` + "`" + `true` + "`" + ` or ` + "`" + `false` + "`" + `. Defaults to ` + "`" + `false` + "`" + `.", - "name": "filter_include_nulls", - "in": "query" - } - ], - "responses": { - "200": { - "description": "The tasks", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Task" - } - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - }, "put": { "security": [ { @@ -2531,6 +2296,229 @@ const docTemplate = `{ } } }, + "/projects/{id}/views/{view}/buckets": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns all kanban buckets which belong to that project. Buckets are always sorted by their ` + "`" + `position` + "`" + ` in ascending order. To get all buckets with their tasks, use the tasks endpoint with a kanban view.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get all kanban buckets of a project", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The buckets", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Bucket" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Creates a new kanban bucket on a project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create a new bucket", + "parameters": [ + { + "type": "integer", + "description": "Project Id", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + }, + { + "description": "The bucket object", + "name": "bucket", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Bucket" + } + } + ], + "responses": { + "200": { + "description": "The created bucket object.", + "schema": { + "$ref": "#/definitions/models.Bucket" + } + }, + "400": { + "description": "Invalid bucket object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "The project does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/projects/{id}/views/{view}/tasks": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns all tasks for the current project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "task" + ], + "summary": "Get tasks in a project", + "parameters": [ + { + "type": "integer", + "description": "The project ID.", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "The project view ID.", + "name": "view", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "The page number. Used for pagination. If not provided, the first page of results is returned.", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page.", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Search tasks by task text.", + "name": "s", + "in": "query" + }, + { + "type": "string", + "description": "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with ` + "`" + `order_by` + "`" + `. Possible values to sort by are ` + "`" + `id` + "`" + `, ` + "`" + `title` + "`" + `, ` + "`" + `description` + "`" + `, ` + "`" + `done` + "`" + `, ` + "`" + `done_at` + "`" + `, ` + "`" + `due_date` + "`" + `, ` + "`" + `created_by_id` + "`" + `, ` + "`" + `project_id` + "`" + `, ` + "`" + `repeat_after` + "`" + `, ` + "`" + `priority` + "`" + `, ` + "`" + `start_date` + "`" + `, ` + "`" + `end_date` + "`" + `, ` + "`" + `hex_color` + "`" + `, ` + "`" + `percent_done` + "`" + `, ` + "`" + `uid` + "`" + `, ` + "`" + `created` + "`" + `, ` + "`" + `updated` + "`" + `. Default is ` + "`" + `id` + "`" + `.", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "The ordering parameter. Possible values to order by are ` + "`" + `asc` + "`" + ` or ` + "`" + `desc` + "`" + `. Default is ` + "`" + `asc` + "`" + `.", + "name": "order_by", + "in": "query" + }, + { + "type": "string", + "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", + "name": "filter", + "in": "query" + }, + { + "type": "string", + "description": "The time zone which should be used for date match (statements like ", + "name": "filter_timezone", + "in": "query" + }, + { + "type": "string", + "description": "If set to true the result will include filtered fields whose value is set to ` + "`" + `null` + "`" + `. Available values are ` + "`" + `true` + "`" + ` or ` + "`" + `false` + "`" + `. Defaults to ` + "`" + `false` + "`" + `.", + "name": "filter_include_nulls", + "in": "query" + } + ], + "responses": { + "200": { + "description": "The tasks", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Task" + } + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/projects/{id}/webhooks": { "get": { "security": [ @@ -2755,131 +2743,6 @@ const docTemplate = `{ } } }, - "/projects/{projectID}/buckets/{bucketID}": { - "post": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Updates an existing kanban bucket.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Update an existing bucket", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "projectID", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Bucket Id", - "name": "bucketID", - "in": "path", - "required": true - }, - { - "description": "The bucket object", - "name": "bucket", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.Bucket" - } - } - ], - "responses": { - "200": { - "description": "The created bucket object.", - "schema": { - "$ref": "#/definitions/models.Bucket" - } - }, - "400": { - "description": "Invalid bucket object provided.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "404": { - "description": "The bucket does not exist.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - }, - "delete": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Deletes an existing kanban bucket and dissociates all of its task. It does not delete any tasks. You cannot delete the last bucket on a project.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Deletes an existing bucket", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "projectID", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Bucket Id", - "name": "bucketID", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Successfully deleted.", - "schema": { - "$ref": "#/definitions/models.Message" - } - }, - "404": { - "description": "The bucket does not exist.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - } - }, "/projects/{projectID}/duplicate": { "put": { "security": [ @@ -3200,6 +3063,145 @@ const docTemplate = `{ } } }, + "/projects/{projectID}/views/{view}/buckets/{bucketID}": { + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Updates an existing kanban bucket.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Update an existing bucket", + "parameters": [ + { + "type": "integer", + "description": "Project Id", + "name": "projectID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Bucket Id", + "name": "bucketID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + }, + { + "description": "The bucket object", + "name": "bucket", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Bucket" + } + } + ], + "responses": { + "200": { + "description": "The created bucket object.", + "schema": { + "$ref": "#/definitions/models.Bucket" + } + }, + "400": { + "description": "Invalid bucket object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "The bucket does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "delete": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Deletes an existing kanban bucket and dissociates all of its task. It does not delete any tasks. You cannot delete the last bucket on a project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Deletes an existing bucket", + "parameters": [ + { + "type": "integer", + "description": "Project Id", + "name": "projectID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Bucket Id", + "name": "bucketID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully deleted.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "404": { + "description": "The bucket does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/projects/{project}/shares": { "get": { "security": [ @@ -3454,6 +3456,281 @@ const docTemplate = `{ } } }, + "/projects/{project}/views": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns all project views for a sepcific project", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get all project views for a project", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The project views", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectView" + } + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Create a project view in a specific project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create a project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "description": "The project view you want to create.", + "name": "view", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + } + ], + "responses": { + "200": { + "description": "The created project view", + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + }, + "403": { + "description": "The user does not have access to create a project view", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/projects/{project}/views/{id}": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns a project view by its ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get one project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project View ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The project view", + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + }, + "403": { + "description": "The user does not have access to this project view", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Updates a project view.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Updates a project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project View ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "The project view with updated values you want to change.", + "name": "view", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + } + ], + "responses": { + "200": { + "description": "The updated project view.", + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + }, + "400": { + "description": "Invalid project view object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "delete": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Deletes a project view.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Delete a project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project View ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The project view was successfully deleted.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "403": { + "description": "The user does not have access to the project view", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/register": { "post": { "description": "Creates a new user account.", @@ -4269,6 +4546,64 @@ const docTemplate = `{ } } }, + "/tasks/{id}/position": { + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Updates a task position.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "task" + ], + "summary": "Updates a task position", + "parameters": [ + { + "type": "integer", + "description": "Task ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "The task position with updated values you want to change.", + "name": "view", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.TaskPosition" + } + } + ], + "responses": { + "200": { + "description": "The updated task position.", + "schema": { + "$ref": "#/definitions/models.TaskPosition" + } + }, + "400": { + "description": "Invalid task position object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/tasks/{taskID}/assignees": { "get": { "security": [ @@ -7454,8 +7789,8 @@ const docTemplate = `{ "description": "The position this bucket has when querying all buckets. See the tasks.position property on how to use this.", "type": "number" }, - "project_id": { - "description": "The project this bucket belongs to.", + "project_view_id": { + "description": "The project view this bucket belongs to.", "type": "integer" }, "tasks": { @@ -7476,6 +7811,19 @@ const docTemplate = `{ } } }, + "models.BucketConfigurationModeKind": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-varnames": [ + "BucketConfigurationModeNone", + "BucketConfigurationModeManual", + "BucketConfigurationModeFilter" + ] + }, "models.BulkAssignees": { "type": "object", "properties": { @@ -7506,7 +7854,7 @@ const docTemplate = `{ } }, "bucket_id": { - "description": "BucketID is the ID of the kanban bucket this task belongs to.", + "description": "The bucket id. Will only be populated when the task is accessed via a view with buckets.\nCan be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one.", "type": "integer" }, "cover_image_attachment_id": { @@ -7566,10 +7914,6 @@ const docTemplate = `{ "description": "True if a task is a favorite task. Favorite tasks show up in a separate \"Important\" project. This value depends on the user making the call to the api.", "type": "boolean" }, - "kanban_position": { - "description": "The position of tasks in the kanban board. See the docs for the ` + "`" + `position` + "`" + ` property on how to use this.", - "type": "number" - }, "labels": { "description": "An array of labels which are associated with this task.", "type": "array", @@ -7582,7 +7926,7 @@ const docTemplate = `{ "type": "number" }, "position": { - "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via kanban buckets, this is primarily used to sort them based on a range\nWe're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).\nYou would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2.\nA 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task\nwhich also leaves a lot of room for rearranging and sorting later.", + "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via views with buckets, this is primarily used to sort them based on a range.\nPositions are always saved per view. They will automatically be set if you request the tasks through a view\nendpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.", "type": "number" }, "priority": { @@ -7831,16 +8175,12 @@ const docTemplate = `{ "description": "A timestamp when this project was created. You cannot change this value.", "type": "string" }, - "default_bucket_id": { - "description": "The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a project.", - "type": "integer" - }, "description": { "description": "The description of the project.", "type": "string" }, "done_bucket_id": { - "description": "If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.", + "description": "Deprecated: If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.", "type": "integer" }, "hex_color": { @@ -7898,6 +8238,12 @@ const docTemplate = `{ "updated": { "description": "A timestamp when this project was last updated. You cannot change this value.", "type": "string" + }, + "views": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectView" + } } } }, @@ -7949,6 +8295,96 @@ const docTemplate = `{ } } }, + "models.ProjectView": { + "type": "object", + "properties": { + "bucket_configuration": { + "description": "When the bucket configuration mode is not ` + "`" + `manual` + "`" + `, this field holds the options of that configuration.", + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectViewBucketConfiguration" + } + }, + "bucket_configuration_mode": { + "description": "The bucket configuration mode. Can be ` + "`" + `none` + "`" + `, ` + "`" + `manual` + "`" + ` or ` + "`" + `filter` + "`" + `. ` + "`" + `manual` + "`" + ` allows to move tasks between buckets as you normally would. ` + "`" + `filter` + "`" + ` creates buckets based on a filter for each bucket.", + "allOf": [ + { + "$ref": "#/definitions/models.BucketConfigurationModeKind" + } + ] + }, + "created": { + "description": "A timestamp when this reaction was created. You cannot change this value.", + "type": "string" + }, + "default_bucket_id": { + "description": "The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a view.", + "type": "integer" + }, + "done_bucket_id": { + "description": "If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.", + "type": "integer" + }, + "filter": { + "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.", + "type": "string" + }, + "id": { + "description": "The unique numeric id of this view", + "type": "integer" + }, + "position": { + "description": "The position of this view in the list. The list of all views will be sorted by this parameter.", + "type": "number" + }, + "project_id": { + "description": "The project this view belongs to", + "type": "integer" + }, + "title": { + "description": "The title of this view", + "type": "string" + }, + "updated": { + "description": "A timestamp when this view was updated. You cannot change this value.", + "type": "string" + }, + "view_kind": { + "description": "The kind of this view. Can be ` + "`" + `list` + "`" + `, ` + "`" + `gantt` + "`" + `, ` + "`" + `table` + "`" + ` or ` + "`" + `kanban` + "`" + `.", + "allOf": [ + { + "$ref": "#/definitions/models.ProjectViewKind" + } + ] + } + } + }, + "models.ProjectViewBucketConfiguration": { + "type": "object", + "properties": { + "filter": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "models.ProjectViewKind": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-varnames": [ + "ProjectViewKindList", + "ProjectViewKindGantt", + "ProjectViewKindTable", + "ProjectViewKindKanban" + ] + }, "models.Reaction": { "type": "object", "properties": { @@ -8162,7 +8598,7 @@ const docTemplate = `{ } }, "bucket_id": { - "description": "BucketID is the ID of the kanban bucket this task belongs to.", + "description": "The bucket id. Will only be populated when the task is accessed via a view with buckets.\nCan be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one.", "type": "integer" }, "cover_image_attachment_id": { @@ -8222,10 +8658,6 @@ const docTemplate = `{ "description": "True if a task is a favorite task. Favorite tasks show up in a separate \"Important\" project. This value depends on the user making the call to the api.", "type": "boolean" }, - "kanban_position": { - "description": "The position of tasks in the kanban board. See the docs for the ` + "`" + `position` + "`" + ` property on how to use this.", - "type": "number" - }, "labels": { "description": "An array of labels which are associated with this task.", "type": "array", @@ -8238,7 +8670,7 @@ const docTemplate = `{ "type": "number" }, "position": { - "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via kanban buckets, this is primarily used to sort them based on a range\nWe're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).\nYou would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2.\nA 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task\nwhich also leaves a lot of room for rearranging and sorting later.", + "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via views with buckets, this is primarily used to sort them based on a range.\nPositions are always saved per view. They will automatically be set if you request the tasks through a view\nendpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.", "type": "number" }, "priority": { @@ -8342,7 +8774,7 @@ const docTemplate = `{ "type": "object", "properties": { "filter": { - "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", + "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.", "type": "string" }, "filter_include_nulls": { @@ -8388,6 +8820,23 @@ const docTemplate = `{ } } }, + "models.TaskPosition": { + "type": "object", + "properties": { + "position": { + "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via kanban buckets, this is primarily used to sort them based on a range\nWe're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).\nYou would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2.\nA 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task\nwhich also leaves a lot of room for rearranging and sorting later.\nPositions are always saved per view. They will automatically be set if you request the tasks through a view\nendpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.", + "type": "number" + }, + "project_view_id": { + "description": "The project view this task is related to", + "type": "integer" + }, + "task_id": { + "description": "The ID of the task this position is for", + "type": "integer" + } + } + }, "models.TaskRelation": { "type": "object", "properties": { @@ -9211,8 +9660,6 @@ var SwaggerInfo = &swag.Spec{ Description: "# Pagination\nEvery endpoint capable of pagination will return two headers:\n* `x-pagination-total-pages`: The total number of available pages for this request\n* `x-pagination-result-count`: The number of items returned for this request.\n# Rights\nAll endpoints which return a single item (project, task, etc.) - no array - will also return a `x-max-right` header with the max right the user has on this item as an int where `0` is `Read Only`, `1` is `Read & Write` and `2` is `Admin`.\nThis can be used to show or hide ui elements based on the rights the user has.\n# Errors\nAll errors have an error code and a human-readable error message in addition to the http status code. You should always check for the status code in the response, not only the http status code.\nDue to limitations in the swagger library we're using for this document, only one error per http status code is documented here. Make sure to check the [error docs](https://vikunja.io/docs/errors/) in Vikunja's documentation for a full list of available error codes.\n# Authorization\n**JWT-Auth:** Main authorization method, used for most of the requests. Needs `Authorization: Bearer `-header to authenticate successfully.\n\n**API Token:** You can create scoped API tokens for your user and use the token to make authenticated requests in the context of that user. The token must be provided via an `Authorization: Bearer ` header, similar to jwt auth. See the documentation for the `api` group to manage token creation and revocation.\n\n**BasicAuth:** Only used when requesting tasks via CalDAV.\n", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, - LeftDelim: "{{", - RightDelim: "}}", } func init() { diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index d5d89a40b..2d65ab7d1 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -1883,150 +1883,6 @@ } } }, - "/projects/{id}/buckets": { - "get": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Returns all kanban buckets with belong to a project including their tasks. Buckets are always sorted by their `position` in ascending order. Tasks are sorted by their `kanban_position` in ascending order.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Get all kanban buckets of a project", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "The page number for tasks. Used for pagination. If not provided, the first page of results is returned.", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "The maximum number of tasks per bucket per page. This parameter is limited by the configured maximum of items per page.", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "Search tasks by task text.", - "name": "s", - "in": "query" - }, - { - "type": "string", - "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", - "name": "filter", - "in": "query" - }, - { - "type": "string", - "description": "The time zone which should be used for date match (statements like ", - "name": "filter_timezone", - "in": "query" - }, - { - "type": "string", - "description": "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`.", - "name": "filter_include_nulls", - "in": "query" - } - ], - "responses": { - "200": { - "description": "The buckets with their tasks", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Bucket" - } - } - }, - "500": { - "description": "Internal server error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - }, - "put": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Creates a new kanban bucket on a project.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Create a new bucket", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "The bucket object", - "name": "bucket", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.Bucket" - } - } - ], - "responses": { - "200": { - "description": "The created bucket object.", - "schema": { - "$ref": "#/definitions/models.Bucket" - } - }, - "400": { - "description": "Invalid bucket object provided.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "404": { - "description": "The project does not exist.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - } - }, "/projects/{id}/projectusers": { "get": { "security": [ @@ -2092,98 +1948,6 @@ } }, "/projects/{id}/tasks": { - "get": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Returns all tasks for the current project.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "task" - ], - "summary": "Get tasks in a project", - "parameters": [ - { - "type": "integer", - "description": "The project ID.", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "The page number. Used for pagination. If not provided, the first page of results is returned.", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page.", - "name": "per_page", - "in": "query" - }, - { - "type": "string", - "description": "Search tasks by task text.", - "name": "s", - "in": "query" - }, - { - "type": "string", - "description": "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with `order_by`. Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, `due_date`, `created_by_id`, `project_id`, `repeat_after`, `priority`, `start_date`, `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default is `id`.", - "name": "sort_by", - "in": "query" - }, - { - "type": "string", - "description": "The ordering parameter. Possible values to order by are `asc` or `desc`. Default is `asc`.", - "name": "order_by", - "in": "query" - }, - { - "type": "string", - "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", - "name": "filter", - "in": "query" - }, - { - "type": "string", - "description": "The time zone which should be used for date match (statements like ", - "name": "filter_timezone", - "in": "query" - }, - { - "type": "string", - "description": "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`.", - "name": "filter_include_nulls", - "in": "query" - } - ], - "responses": { - "200": { - "description": "The tasks", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Task" - } - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - }, "put": { "security": [ { @@ -2523,6 +2287,229 @@ } } }, + "/projects/{id}/views/{view}/buckets": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns all kanban buckets which belong to that project. Buckets are always sorted by their `position` in ascending order. To get all buckets with their tasks, use the tasks endpoint with a kanban view.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get all kanban buckets of a project", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The buckets", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Bucket" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Creates a new kanban bucket on a project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create a new bucket", + "parameters": [ + { + "type": "integer", + "description": "Project Id", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + }, + { + "description": "The bucket object", + "name": "bucket", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Bucket" + } + } + ], + "responses": { + "200": { + "description": "The created bucket object.", + "schema": { + "$ref": "#/definitions/models.Bucket" + } + }, + "400": { + "description": "Invalid bucket object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "The project does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/projects/{id}/views/{view}/tasks": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns all tasks for the current project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "task" + ], + "summary": "Get tasks in a project", + "parameters": [ + { + "type": "integer", + "description": "The project ID.", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "The project view ID.", + "name": "view", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "The page number. Used for pagination. If not provided, the first page of results is returned.", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page.", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Search tasks by task text.", + "name": "s", + "in": "query" + }, + { + "type": "string", + "description": "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with `order_by`. Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, `due_date`, `created_by_id`, `project_id`, `repeat_after`, `priority`, `start_date`, `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default is `id`.", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "The ordering parameter. Possible values to order by are `asc` or `desc`. Default is `asc`.", + "name": "order_by", + "in": "query" + }, + { + "type": "string", + "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", + "name": "filter", + "in": "query" + }, + { + "type": "string", + "description": "The time zone which should be used for date match (statements like ", + "name": "filter_timezone", + "in": "query" + }, + { + "type": "string", + "description": "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`.", + "name": "filter_include_nulls", + "in": "query" + } + ], + "responses": { + "200": { + "description": "The tasks", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Task" + } + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/projects/{id}/webhooks": { "get": { "security": [ @@ -2747,131 +2734,6 @@ } } }, - "/projects/{projectID}/buckets/{bucketID}": { - "post": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Updates an existing kanban bucket.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Update an existing bucket", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "projectID", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Bucket Id", - "name": "bucketID", - "in": "path", - "required": true - }, - { - "description": "The bucket object", - "name": "bucket", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.Bucket" - } - } - ], - "responses": { - "200": { - "description": "The created bucket object.", - "schema": { - "$ref": "#/definitions/models.Bucket" - } - }, - "400": { - "description": "Invalid bucket object provided.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "404": { - "description": "The bucket does not exist.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - }, - "delete": { - "security": [ - { - "JWTKeyAuth": [] - } - ], - "description": "Deletes an existing kanban bucket and dissociates all of its task. It does not delete any tasks. You cannot delete the last bucket on a project.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "summary": "Deletes an existing bucket", - "parameters": [ - { - "type": "integer", - "description": "Project Id", - "name": "projectID", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Bucket Id", - "name": "bucketID", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Successfully deleted.", - "schema": { - "$ref": "#/definitions/models.Message" - } - }, - "404": { - "description": "The bucket does not exist.", - "schema": { - "$ref": "#/definitions/web.HTTPError" - } - }, - "500": { - "description": "Internal error", - "schema": { - "$ref": "#/definitions/models.Message" - } - } - } - } - }, "/projects/{projectID}/duplicate": { "put": { "security": [ @@ -3192,6 +3054,145 @@ } } }, + "/projects/{projectID}/views/{view}/buckets/{bucketID}": { + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Updates an existing kanban bucket.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Update an existing bucket", + "parameters": [ + { + "type": "integer", + "description": "Project Id", + "name": "projectID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Bucket Id", + "name": "bucketID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + }, + { + "description": "The bucket object", + "name": "bucket", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Bucket" + } + } + ], + "responses": { + "200": { + "description": "The created bucket object.", + "schema": { + "$ref": "#/definitions/models.Bucket" + } + }, + "400": { + "description": "Invalid bucket object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "The bucket does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "delete": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Deletes an existing kanban bucket and dissociates all of its task. It does not delete any tasks. You cannot delete the last bucket on a project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Deletes an existing bucket", + "parameters": [ + { + "type": "integer", + "description": "Project Id", + "name": "projectID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Bucket Id", + "name": "bucketID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project view ID", + "name": "view", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully deleted.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "404": { + "description": "The bucket does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/projects/{project}/shares": { "get": { "security": [ @@ -3446,6 +3447,281 @@ } } }, + "/projects/{project}/views": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns all project views for a sepcific project", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get all project views for a project", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The project views", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectView" + } + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Create a project view in a specific project.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Create a project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "description": "The project view you want to create.", + "name": "view", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + } + ], + "responses": { + "200": { + "description": "The created project view", + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + }, + "403": { + "description": "The user does not have access to create a project view", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/projects/{project}/views/{id}": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns a project view by its ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Get one project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project View ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The project view", + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + }, + "403": { + "description": "The user does not have access to this project view", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Updates a project view.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Updates a project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project View ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "The project view with updated values you want to change.", + "name": "view", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + } + ], + "responses": { + "200": { + "description": "The updated project view.", + "schema": { + "$ref": "#/definitions/models.ProjectView" + } + }, + "400": { + "description": "Invalid project view object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "delete": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Deletes a project view.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "project" + ], + "summary": "Delete a project view", + "parameters": [ + { + "type": "integer", + "description": "Project ID", + "name": "project", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Project View ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The project view was successfully deleted.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "403": { + "description": "The user does not have access to the project view", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/register": { "post": { "description": "Creates a new user account.", @@ -4261,6 +4537,64 @@ } } }, + "/tasks/{id}/position": { + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Updates a task position.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "task" + ], + "summary": "Updates a task position", + "parameters": [ + { + "type": "integer", + "description": "Task ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "The task position with updated values you want to change.", + "name": "view", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.TaskPosition" + } + } + ], + "responses": { + "200": { + "description": "The updated task position.", + "schema": { + "$ref": "#/definitions/models.TaskPosition" + } + }, + "400": { + "description": "Invalid task position object provided.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/tasks/{taskID}/assignees": { "get": { "security": [ @@ -7446,8 +7780,8 @@ "description": "The position this bucket has when querying all buckets. See the tasks.position property on how to use this.", "type": "number" }, - "project_id": { - "description": "The project this bucket belongs to.", + "project_view_id": { + "description": "The project view this bucket belongs to.", "type": "integer" }, "tasks": { @@ -7468,6 +7802,19 @@ } } }, + "models.BucketConfigurationModeKind": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-varnames": [ + "BucketConfigurationModeNone", + "BucketConfigurationModeManual", + "BucketConfigurationModeFilter" + ] + }, "models.BulkAssignees": { "type": "object", "properties": { @@ -7498,7 +7845,7 @@ } }, "bucket_id": { - "description": "BucketID is the ID of the kanban bucket this task belongs to.", + "description": "The bucket id. Will only be populated when the task is accessed via a view with buckets.\nCan be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one.", "type": "integer" }, "cover_image_attachment_id": { @@ -7558,10 +7905,6 @@ "description": "True if a task is a favorite task. Favorite tasks show up in a separate \"Important\" project. This value depends on the user making the call to the api.", "type": "boolean" }, - "kanban_position": { - "description": "The position of tasks in the kanban board. See the docs for the `position` property on how to use this.", - "type": "number" - }, "labels": { "description": "An array of labels which are associated with this task.", "type": "array", @@ -7574,7 +7917,7 @@ "type": "number" }, "position": { - "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via kanban buckets, this is primarily used to sort them based on a range\nWe're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).\nYou would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2.\nA 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task\nwhich also leaves a lot of room for rearranging and sorting later.", + "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via views with buckets, this is primarily used to sort them based on a range.\nPositions are always saved per view. They will automatically be set if you request the tasks through a view\nendpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.", "type": "number" }, "priority": { @@ -7823,16 +8166,12 @@ "description": "A timestamp when this project was created. You cannot change this value.", "type": "string" }, - "default_bucket_id": { - "description": "The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a project.", - "type": "integer" - }, "description": { "description": "The description of the project.", "type": "string" }, "done_bucket_id": { - "description": "If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.", + "description": "Deprecated: If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.", "type": "integer" }, "hex_color": { @@ -7890,6 +8229,12 @@ "updated": { "description": "A timestamp when this project was last updated. You cannot change this value.", "type": "string" + }, + "views": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectView" + } } } }, @@ -7941,6 +8286,96 @@ } } }, + "models.ProjectView": { + "type": "object", + "properties": { + "bucket_configuration": { + "description": "When the bucket configuration mode is not `manual`, this field holds the options of that configuration.", + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectViewBucketConfiguration" + } + }, + "bucket_configuration_mode": { + "description": "The bucket configuration mode. Can be `none`, `manual` or `filter`. `manual` allows to move tasks between buckets as you normally would. `filter` creates buckets based on a filter for each bucket.", + "allOf": [ + { + "$ref": "#/definitions/models.BucketConfigurationModeKind" + } + ] + }, + "created": { + "description": "A timestamp when this reaction was created. You cannot change this value.", + "type": "string" + }, + "default_bucket_id": { + "description": "The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a view.", + "type": "integer" + }, + "done_bucket_id": { + "description": "If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.", + "type": "integer" + }, + "filter": { + "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.", + "type": "string" + }, + "id": { + "description": "The unique numeric id of this view", + "type": "integer" + }, + "position": { + "description": "The position of this view in the list. The list of all views will be sorted by this parameter.", + "type": "number" + }, + "project_id": { + "description": "The project this view belongs to", + "type": "integer" + }, + "title": { + "description": "The title of this view", + "type": "string" + }, + "updated": { + "description": "A timestamp when this view was updated. You cannot change this value.", + "type": "string" + }, + "view_kind": { + "description": "The kind of this view. Can be `list`, `gantt`, `table` or `kanban`.", + "allOf": [ + { + "$ref": "#/definitions/models.ProjectViewKind" + } + ] + } + } + }, + "models.ProjectViewBucketConfiguration": { + "type": "object", + "properties": { + "filter": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "models.ProjectViewKind": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-varnames": [ + "ProjectViewKindList", + "ProjectViewKindGantt", + "ProjectViewKindTable", + "ProjectViewKindKanban" + ] + }, "models.Reaction": { "type": "object", "properties": { @@ -8154,7 +8589,7 @@ } }, "bucket_id": { - "description": "BucketID is the ID of the kanban bucket this task belongs to.", + "description": "The bucket id. Will only be populated when the task is accessed via a view with buckets.\nCan be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one.", "type": "integer" }, "cover_image_attachment_id": { @@ -8214,10 +8649,6 @@ "description": "True if a task is a favorite task. Favorite tasks show up in a separate \"Important\" project. This value depends on the user making the call to the api.", "type": "boolean" }, - "kanban_position": { - "description": "The position of tasks in the kanban board. See the docs for the `position` property on how to use this.", - "type": "number" - }, "labels": { "description": "An array of labels which are associated with this task.", "type": "array", @@ -8230,7 +8661,7 @@ "type": "number" }, "position": { - "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via kanban buckets, this is primarily used to sort them based on a range\nWe're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).\nYou would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2.\nA 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task\nwhich also leaves a lot of room for rearranging and sorting later.", + "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via views with buckets, this is primarily used to sort them based on a range.\nPositions are always saved per view. They will automatically be set if you request the tasks through a view\nendpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.", "type": "number" }, "priority": { @@ -8334,7 +8765,7 @@ "type": "object", "properties": { "filter": { - "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.", + "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.", "type": "string" }, "filter_include_nulls": { @@ -8380,6 +8811,23 @@ } } }, + "models.TaskPosition": { + "type": "object", + "properties": { + "position": { + "description": "The position of the task - any task project can be sorted as usual by this parameter.\nWhen accessing tasks via kanban buckets, this is primarily used to sort them based on a range\nWe're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).\nYou would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2.\nA 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task\nwhich also leaves a lot of room for rearranging and sorting later.\nPositions are always saved per view. They will automatically be set if you request the tasks through a view\nendpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.", + "type": "number" + }, + "project_view_id": { + "description": "The project view this task is related to", + "type": "integer" + }, + "task_id": { + "description": "The ID of the task this position is for", + "type": "integer" + } + } + }, "models.TaskRelation": { "type": "object", "properties": { diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index 9a9f34822..a077d6cf7 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -123,8 +123,8 @@ definitions: description: The position this bucket has when querying all buckets. See the tasks.position property on how to use this. type: number - project_id: - description: The project this bucket belongs to. + project_view_id: + description: The project view this bucket belongs to. type: integer tasks: description: All tasks which belong to this bucket. @@ -140,6 +140,16 @@ definitions: this value. type: string type: object + models.BucketConfigurationModeKind: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-varnames: + - BucketConfigurationModeNone + - BucketConfigurationModeManual + - BucketConfigurationModeFilter models.BulkAssignees: properties: assignees: @@ -161,7 +171,9 @@ definitions: $ref: '#/definitions/models.TaskAttachment' type: array bucket_id: - description: BucketID is the ID of the kanban bucket this task belongs to. + description: |- + The bucket id. Will only be populated when the task is accessed via a view with buckets. + Can be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one. type: integer cover_image_attachment_id: description: If this task has a cover image, the field will return the id @@ -209,10 +221,6 @@ definitions: a separate "Important" project. This value depends on the user making the call to the api. type: boolean - kanban_position: - description: The position of tasks in the kanban board. See the docs for the - `position` property on how to use this. - type: number labels: description: An array of labels which are associated with this task. items: @@ -224,11 +232,9 @@ definitions: position: description: |- The position of the task - any task project can be sorted as usual by this parameter. - When accessing tasks via kanban buckets, this is primarily used to sort them based on a range - We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number). - You would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2. - A 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task - which also leaves a lot of room for rearranging and sorting later. + When accessing tasks via views with buckets, this is primarily used to sort them based on a range. + Positions are always saved per view. They will automatically be set if you request the tasks through a view + endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. type: number priority: description: The task priority. Can be anything you want, it is possible to @@ -422,16 +428,13 @@ definitions: description: A timestamp when this project was created. You cannot change this value. type: string - default_bucket_id: - description: The ID of the bucket where new tasks without a bucket are added - to. By default, this is the leftmost bucket in a project. - type: integer description: description: The description of the project. type: string done_bucket_id: - description: If tasks are moved to the done bucket, they are marked as done. - If they are marked as done individually, they are moved into the done bucket. + description: 'Deprecated: If tasks are moved to the done bucket, they are + marked as done. If they are marked as done individually, they are moved + into the done bucket.' type: integer hex_color: description: The hex color of this project @@ -478,6 +481,10 @@ definitions: description: A timestamp when this project was last updated. You cannot change this value. type: string + views: + items: + $ref: '#/definitions/models.ProjectView' + type: array type: object models.ProjectDuplicate: properties: @@ -513,6 +520,77 @@ definitions: description: The username. type: string type: object + models.ProjectView: + properties: + bucket_configuration: + description: When the bucket configuration mode is not `manual`, this field + holds the options of that configuration. + items: + $ref: '#/definitions/models.ProjectViewBucketConfiguration' + type: array + bucket_configuration_mode: + allOf: + - $ref: '#/definitions/models.BucketConfigurationModeKind' + description: The bucket configuration mode. Can be `none`, `manual` or `filter`. + `manual` allows to move tasks between buckets as you normally would. `filter` + creates buckets based on a filter for each bucket. + created: + description: A timestamp when this reaction was created. You cannot change + this value. + type: string + default_bucket_id: + description: The ID of the bucket where new tasks without a bucket are added + to. By default, this is the leftmost bucket in a view. + type: integer + done_bucket_id: + description: If tasks are moved to the done bucket, they are marked as done. + If they are marked as done individually, they are moved into the done bucket. + type: integer + filter: + description: The filter query to match tasks by. Check out https://vikunja.io/docs/filters + for a full explanation. + type: string + id: + description: The unique numeric id of this view + type: integer + position: + description: The position of this view in the list. The list of all views + will be sorted by this parameter. + type: number + project_id: + description: The project this view belongs to + type: integer + title: + description: The title of this view + type: string + updated: + description: A timestamp when this view was updated. You cannot change this + value. + type: string + view_kind: + allOf: + - $ref: '#/definitions/models.ProjectViewKind' + description: The kind of this view. Can be `list`, `gantt`, `table` or `kanban`. + type: object + models.ProjectViewBucketConfiguration: + properties: + filter: + type: string + title: + type: string + type: object + models.ProjectViewKind: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - ProjectViewKindList + - ProjectViewKindGantt + - ProjectViewKindTable + - ProjectViewKindKanban models.Reaction: properties: created: @@ -671,7 +749,9 @@ definitions: $ref: '#/definitions/models.TaskAttachment' type: array bucket_id: - description: BucketID is the ID of the kanban bucket this task belongs to. + description: |- + The bucket id. Will only be populated when the task is accessed via a view with buckets. + Can be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one. type: integer cover_image_attachment_id: description: If this task has a cover image, the field will return the id @@ -719,10 +799,6 @@ definitions: a separate "Important" project. This value depends on the user making the call to the api. type: boolean - kanban_position: - description: The position of tasks in the kanban board. See the docs for the - `position` property on how to use this. - type: number labels: description: An array of labels which are associated with this task. items: @@ -734,11 +810,9 @@ definitions: position: description: |- The position of the task - any task project can be sorted as usual by this parameter. - When accessing tasks via kanban buckets, this is primarily used to sort them based on a range - We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number). - You would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2. - A 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task - which also leaves a lot of room for rearranging and sorting later. + When accessing tasks via views with buckets, this is primarily used to sort them based on a range. + Positions are always saved per view. They will automatically be set if you request the tasks through a view + endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. type: number priority: description: The task priority. Can be anything you want, it is possible to @@ -814,7 +888,7 @@ definitions: properties: filter: description: The filter query to match tasks by. Check out https://vikunja.io/docs/filters - for a full explanation of the feature. + for a full explanation. type: string filter_include_nulls: description: If set to true, the result will also include null values @@ -847,6 +921,26 @@ definitions: updated: type: string type: object + models.TaskPosition: + properties: + position: + description: |- + The position of the task - any task project can be sorted as usual by this parameter. + When accessing tasks via kanban buckets, this is primarily used to sort them based on a range + We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number). + You would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2. + A 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task + which also leaves a lot of room for rearranging and sorting later. + Positions are always saved per view. They will automatically be set if you request the tasks through a view + endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. + type: number + project_view_id: + description: The project view this task is related to + type: integer + task_id: + description: The ID of the task this position is for + type: integer + type: object models.TaskRelation: properties: created: @@ -2850,107 +2944,6 @@ paths: summary: Upload a project background tags: - project - /projects/{id}/buckets: - get: - consumes: - - application/json - description: Returns all kanban buckets with belong to a project including their - tasks. Buckets are always sorted by their `position` in ascending order. Tasks - are sorted by their `kanban_position` in ascending order. - parameters: - - description: Project Id - in: path - name: id - required: true - type: integer - - description: The page number for tasks. Used for pagination. If not provided, - the first page of results is returned. - in: query - name: page - type: integer - - description: The maximum number of tasks per bucket per page. This parameter - is limited by the configured maximum of items per page. - in: query - name: per_page - type: integer - - description: Search tasks by task text. - in: query - name: s - type: string - - description: The filter query to match tasks by. Check out https://vikunja.io/docs/filters - for a full explanation of the feature. - in: query - name: filter - type: string - - description: 'The time zone which should be used for date match (statements - like ' - in: query - name: filter_timezone - type: string - - description: If set to true the result will include filtered fields whose - value is set to `null`. Available values are `true` or `false`. Defaults - to `false`. - in: query - name: filter_include_nulls - type: string - produces: - - application/json - responses: - "200": - description: The buckets with their tasks - schema: - items: - $ref: '#/definitions/models.Bucket' - type: array - "500": - description: Internal server error - schema: - $ref: '#/definitions/models.Message' - security: - - JWTKeyAuth: [] - summary: Get all kanban buckets of a project - tags: - - project - put: - consumes: - - application/json - description: Creates a new kanban bucket on a project. - parameters: - - description: Project Id - in: path - name: id - required: true - type: integer - - description: The bucket object - in: body - name: bucket - required: true - schema: - $ref: '#/definitions/models.Bucket' - produces: - - application/json - responses: - "200": - description: The created bucket object. - schema: - $ref: '#/definitions/models.Bucket' - "400": - description: Invalid bucket object provided. - schema: - $ref: '#/definitions/web.HTTPError' - "404": - description: The project does not exist. - schema: - $ref: '#/definitions/web.HTTPError' - "500": - description: Internal error - schema: - $ref: '#/definitions/models.Message' - security: - - JWTKeyAuth: [] - summary: Create a new bucket - tags: - - project /projects/{id}/projectusers: get: consumes: @@ -2994,78 +2987,6 @@ paths: tags: - project /projects/{id}/tasks: - get: - consumes: - - application/json - description: Returns all tasks for the current project. - parameters: - - description: The project ID. - in: path - name: id - required: true - type: integer - - description: The page number. Used for pagination. If not provided, the first - page of results is returned. - in: query - name: page - type: integer - - description: The maximum number of items per page. Note this parameter is - limited by the configured maximum of items per page. - in: query - name: per_page - type: integer - - description: Search tasks by task text. - in: query - name: s - type: string - - description: The sorting parameter. You can pass this multiple times to get - the tasks ordered by multiple different parametes, along with `order_by`. - Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, - `due_date`, `created_by_id`, `project_id`, `repeat_after`, `priority`, `start_date`, - `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default - is `id`. - in: query - name: sort_by - type: string - - description: The ordering parameter. Possible values to order by are `asc` - or `desc`. Default is `asc`. - in: query - name: order_by - type: string - - description: The filter query to match tasks by. Check out https://vikunja.io/docs/filters - for a full explanation of the feature. - in: query - name: filter - type: string - - description: 'The time zone which should be used for date match (statements - like ' - in: query - name: filter_timezone - type: string - - description: If set to true the result will include filtered fields whose - value is set to `null`. Available values are `true` or `false`. Defaults - to `false`. - in: query - name: filter_include_nulls - type: string - produces: - - application/json - responses: - "200": - description: The tasks - schema: - items: - $ref: '#/definitions/models.Task' - type: array - "500": - description: Internal error - schema: - $ref: '#/definitions/models.Message' - security: - - JWTKeyAuth: [] - summary: Get tasks in a project - tags: - - task put: consumes: - application/json @@ -3288,6 +3209,165 @@ paths: summary: Add a user to a project tags: - sharing + /projects/{id}/views/{view}/buckets: + get: + consumes: + - application/json + description: Returns all kanban buckets which belong to that project. Buckets + are always sorted by their `position` in ascending order. To get all buckets + with their tasks, use the tasks endpoint with a kanban view. + parameters: + - description: Project ID + in: path + name: id + required: true + type: integer + - description: Project view ID + in: path + name: view + required: true + type: integer + produces: + - application/json + responses: + "200": + description: The buckets + schema: + items: + $ref: '#/definitions/models.Bucket' + type: array + "500": + description: Internal server error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Get all kanban buckets of a project + tags: + - project + put: + consumes: + - application/json + description: Creates a new kanban bucket on a project. + parameters: + - description: Project Id + in: path + name: id + required: true + type: integer + - description: Project view ID + in: path + name: view + required: true + type: integer + - description: The bucket object + in: body + name: bucket + required: true + schema: + $ref: '#/definitions/models.Bucket' + produces: + - application/json + responses: + "200": + description: The created bucket object. + schema: + $ref: '#/definitions/models.Bucket' + "400": + description: Invalid bucket object provided. + schema: + $ref: '#/definitions/web.HTTPError' + "404": + description: The project does not exist. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Create a new bucket + tags: + - project + /projects/{id}/views/{view}/tasks: + get: + consumes: + - application/json + description: Returns all tasks for the current project. + parameters: + - description: The project ID. + in: path + name: id + required: true + type: integer + - description: The project view ID. + in: path + name: view + required: true + type: integer + - description: The page number. Used for pagination. If not provided, the first + page of results is returned. + in: query + name: page + type: integer + - description: The maximum number of items per page. Note this parameter is + limited by the configured maximum of items per page. + in: query + name: per_page + type: integer + - description: Search tasks by task text. + in: query + name: s + type: string + - description: The sorting parameter. You can pass this multiple times to get + the tasks ordered by multiple different parametes, along with `order_by`. + Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, + `due_date`, `created_by_id`, `project_id`, `repeat_after`, `priority`, `start_date`, + `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default + is `id`. + in: query + name: sort_by + type: string + - description: The ordering parameter. Possible values to order by are `asc` + or `desc`. Default is `asc`. + in: query + name: order_by + type: string + - description: The filter query to match tasks by. Check out https://vikunja.io/docs/filters + for a full explanation of the feature. + in: query + name: filter + type: string + - description: 'The time zone which should be used for date match (statements + like ' + in: query + name: filter_timezone + type: string + - description: If set to true the result will include filtered fields whose + value is set to `null`. Available values are `true` or `false`. Defaults + to `false`. + in: query + name: filter_include_nulls + type: string + produces: + - application/json + responses: + "200": + description: The tasks + schema: + items: + $ref: '#/definitions/models.Task' + type: array + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Get tasks in a project + tags: + - task /projects/{id}/webhooks: get: consumes: @@ -3604,32 +3684,60 @@ paths: summary: Get one link shares for a project tags: - sharing - /projects/{projectID}/buckets/{bucketID}: - delete: + /projects/{project}/views: + get: consumes: - application/json - description: Deletes an existing kanban bucket and dissociates all of its task. - It does not delete any tasks. You cannot delete the last bucket on a project. + description: Returns all project views for a sepcific project parameters: - - description: Project Id + - description: Project ID in: path - name: projectID - required: true - type: integer - - description: Bucket Id - in: path - name: bucketID + name: project required: true type: integer produces: - application/json responses: "200": - description: Successfully deleted. + description: The project views + schema: + items: + $ref: '#/definitions/models.ProjectView' + type: array + "500": + description: Internal error schema: $ref: '#/definitions/models.Message' - "404": - description: The bucket does not exist. + security: + - JWTKeyAuth: [] + summary: Get all project views for a project + tags: + - project + put: + consumes: + - application/json + description: Create a project view in a specific project. + parameters: + - description: Project ID + in: path + name: project + required: true + type: integer + - description: The project view you want to create. + in: body + name: view + required: true + schema: + $ref: '#/definitions/models.ProjectView' + produces: + - application/json + responses: + "200": + description: The created project view + schema: + $ref: '#/definitions/models.ProjectView' + "403": + description: The user does not have access to create a project view schema: $ref: '#/definitions/web.HTTPError' "500": @@ -3638,43 +3746,110 @@ paths: $ref: '#/definitions/models.Message' security: - JWTKeyAuth: [] - summary: Deletes an existing bucket + summary: Create a project view + tags: + - project + /projects/{project}/views/{id}: + delete: + consumes: + - application/json + description: Deletes a project view. + parameters: + - description: Project ID + in: path + name: project + required: true + type: integer + - description: Project View ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: The project view was successfully deleted. + schema: + $ref: '#/definitions/models.Message' + "403": + description: The user does not have access to the project view + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Delete a project view + tags: + - project + get: + consumes: + - application/json + description: Returns a project view by its ID. + parameters: + - description: Project ID + in: path + name: project + required: true + type: integer + - description: Project View ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: The project view + schema: + $ref: '#/definitions/models.ProjectView' + "403": + description: The user does not have access to this project view + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Get one project view tags: - project post: consumes: - application/json - description: Updates an existing kanban bucket. + description: Updates a project view. parameters: - - description: Project Id + - description: Project ID in: path - name: projectID + name: project required: true type: integer - - description: Bucket Id + - description: Project View ID in: path - name: bucketID + name: id required: true type: integer - - description: The bucket object + - description: The project view with updated values you want to change. in: body - name: bucket + name: view required: true schema: - $ref: '#/definitions/models.Bucket' + $ref: '#/definitions/models.ProjectView' produces: - application/json responses: "200": - description: The created bucket object. + description: The updated project view. schema: - $ref: '#/definitions/models.Bucket' + $ref: '#/definitions/models.ProjectView' "400": - description: Invalid bucket object provided. - schema: - $ref: '#/definitions/web.HTTPError' - "404": - description: The bucket does not exist. + description: Invalid project view object provided. schema: $ref: '#/definitions/web.HTTPError' "500": @@ -3683,7 +3858,7 @@ paths: $ref: '#/definitions/models.Message' security: - JWTKeyAuth: [] - summary: Update an existing bucket + summary: Updates a project view tags: - project /projects/{projectID}/duplicate: @@ -3900,6 +4075,98 @@ paths: summary: Update a user <-> project relation tags: - sharing + /projects/{projectID}/views/{view}/buckets/{bucketID}: + delete: + consumes: + - application/json + description: Deletes an existing kanban bucket and dissociates all of its task. + It does not delete any tasks. You cannot delete the last bucket on a project. + parameters: + - description: Project Id + in: path + name: projectID + required: true + type: integer + - description: Bucket Id + in: path + name: bucketID + required: true + type: integer + - description: Project view ID + in: path + name: view + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Successfully deleted. + schema: + $ref: '#/definitions/models.Message' + "404": + description: The bucket does not exist. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Deletes an existing bucket + tags: + - project + post: + consumes: + - application/json + description: Updates an existing kanban bucket. + parameters: + - description: Project Id + in: path + name: projectID + required: true + type: integer + - description: Bucket Id + in: path + name: bucketID + required: true + type: integer + - description: Project view ID + in: path + name: view + required: true + type: integer + - description: The bucket object + in: body + name: bucket + required: true + schema: + $ref: '#/definitions/models.Bucket' + produces: + - application/json + responses: + "200": + description: The created bucket object. + schema: + $ref: '#/definitions/models.Bucket' + "400": + description: Invalid bucket object provided. + schema: + $ref: '#/definitions/web.HTTPError' + "404": + description: The bucket does not exist. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Update an existing bucket + tags: + - project /register: post: consumes: @@ -4334,6 +4601,43 @@ paths: summary: Get one attachment. tags: - task + /tasks/{id}/position: + post: + consumes: + - application/json + description: Updates a task position. + parameters: + - description: Task ID + in: path + name: id + required: true + type: integer + - description: The task position with updated values you want to change. + in: body + name: view + required: true + schema: + $ref: '#/definitions/models.TaskPosition' + produces: + - application/json + responses: + "200": + description: The updated task position. + schema: + $ref: '#/definitions/models.TaskPosition' + "400": + description: Invalid task position object provided. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Updates a task position + tags: + - task /tasks/{task}/labels: get: consumes: