feat: decouple views from projects (#2217)
This PR decouples views from projects. On the surface, everything stays the same - by default, there are the same views as right now in main - List, Gantt, Table, Kanban. With this feature, it is possible to modify these or create new ones. That means you can remove views you never need or create multiple ones if you need different configurations. Each view can have an optional filter to change what you see in the frontend on that view. For kanban, you can either set it to "manual" mode, where you can create buckets and move tasks around, or "filter" mode, where each bucket is the result of a filter (and you cannot move them around). All positions and buckets are now tied to the view, not the project. This means you can (finally!) have views for saved filters. Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/2217
This commit is contained in:
@ -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)
|
||||
|
@ -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('/')
|
||||
|
@ -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()
|
||||
|
@ -4,35 +4,65 @@ import {BucketFactory} from '../../factories/bucket'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
import {ProjectViewFactory} from "../../factories/project_view";
|
||||
import {TaskBucketFactory} from "../../factories/task_buckets";
|
||||
|
||||
function createSingleTaskInBucket(count = 1, attrs = {}) {
|
||||
const projects = ProjectFactory.create(1)
|
||||
const buckets = BucketFactory.create(2, {
|
||||
const views = ProjectViewFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: projects[0].id,
|
||||
view_kind: 3,
|
||||
bucket_configuration_mode: 1,
|
||||
})
|
||||
const buckets = BucketFactory.create(2, {
|
||||
project_view_id: views[0].id,
|
||||
})
|
||||
const tasks = TaskFactory.create(count, {
|
||||
project_id: projects[0].id,
|
||||
bucket_id: buckets[0].id,
|
||||
...attrs,
|
||||
})
|
||||
return tasks[0]
|
||||
TaskBucketFactory.create(1, {
|
||||
task_id: tasks[0].id,
|
||||
bucket_id: buckets[0].id,
|
||||
project_view_id: views[0].id,
|
||||
})
|
||||
return {
|
||||
task: tasks[0],
|
||||
view: views[0],
|
||||
project: projects[0],
|
||||
}
|
||||
}
|
||||
|
||||
function createTaskWithBuckets(buckets, count = 1) {
|
||||
const data = TaskFactory.create(10, {
|
||||
project_id: 1,
|
||||
})
|
||||
TaskBucketFactory.truncate()
|
||||
data.forEach(t => TaskBucketFactory.create(count, {
|
||||
task_id: t.id,
|
||||
bucket_id: buckets[0].id,
|
||||
project_view_id: buckets[0].project_view_id,
|
||||
}, false))
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
describe('Project View Kanban', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareProjects()
|
||||
|
||||
|
||||
let buckets
|
||||
beforeEach(() => {
|
||||
buckets = BucketFactory.create(2)
|
||||
buckets = BucketFactory.create(2, {
|
||||
project_view_id: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('Shows all buckets with their tasks', () => {
|
||||
const data = TaskFactory.create(10, {
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/projects/1/kanban')
|
||||
const data = createTaskWithBuckets(buckets, 10)
|
||||
cy.visit('/projects/1/4')
|
||||
|
||||
cy.get('.kanban .bucket .title')
|
||||
.contains(buckets[0].title)
|
||||
@ -46,11 +76,8 @@ describe('Project View Kanban', () => {
|
||||
})
|
||||
|
||||
it('Can add a new task to a bucket', () => {
|
||||
TaskFactory.create(2, {
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/projects/1/kanban')
|
||||
createTaskWithBuckets(buckets, 2)
|
||||
cy.visit('/projects/1/4')
|
||||
|
||||
cy.get('.kanban .bucket')
|
||||
.contains(buckets[0].title)
|
||||
@ -68,7 +95,7 @@ describe('Project View Kanban', () => {
|
||||
})
|
||||
|
||||
it('Can create a new bucket', () => {
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/projects/1/4')
|
||||
|
||||
cy.get('.kanban .bucket.new-bucket .button')
|
||||
.click()
|
||||
@ -82,7 +109,7 @@ describe('Project View Kanban', () => {
|
||||
})
|
||||
|
||||
it('Can set a bucket limit', () => {
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/projects/1/4')
|
||||
|
||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
||||
.first()
|
||||
@ -103,7 +130,7 @@ describe('Project View Kanban', () => {
|
||||
})
|
||||
|
||||
it('Can rename a bucket', () => {
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/projects/1/4')
|
||||
|
||||
cy.get('.kanban .bucket .bucket-header .title')
|
||||
.first()
|
||||
@ -114,7 +141,7 @@ describe('Project View Kanban', () => {
|
||||
})
|
||||
|
||||
it('Can delete a bucket', () => {
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/projects/1/4')
|
||||
|
||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
||||
.first()
|
||||
@ -137,17 +164,14 @@ describe('Project View Kanban', () => {
|
||||
})
|
||||
|
||||
it('Can drag tasks around', () => {
|
||||
const tasks = TaskFactory.create(2, {
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/projects/1/kanban')
|
||||
const tasks = createTaskWithBuckets(buckets, 2)
|
||||
cy.visit('/projects/1/4')
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
.contains(tasks[0].title)
|
||||
.first()
|
||||
.drag('.kanban .bucket:nth-child(2) .tasks')
|
||||
|
||||
|
||||
cy.get('.kanban .bucket:nth-child(2) .tasks')
|
||||
.should('contain', tasks[0].title)
|
||||
cy.get('.kanban .bucket:nth-child(1) .tasks')
|
||||
@ -155,12 +179,8 @@ describe('Project View Kanban', () => {
|
||||
})
|
||||
|
||||
it('Should navigate to the task when the task card is clicked', () => {
|
||||
const tasks = TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/projects/1/kanban')
|
||||
const tasks = createTaskWithBuckets(buckets, 5)
|
||||
cy.visit('/projects/1/4')
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
.contains(tasks[0].title)
|
||||
@ -168,28 +188,33 @@ describe('Project View Kanban', () => {
|
||||
.click()
|
||||
|
||||
cy.url()
|
||||
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
|
||||
.should('contain', `/tasks/${tasks[0].id}`, {timeout: 1000})
|
||||
})
|
||||
|
||||
it('Should remove a task from the kanban board when moving it to another project', () => {
|
||||
const projects = ProjectFactory.create(2)
|
||||
BucketFactory.create(2, {
|
||||
const views = ProjectViewFactory.create(2, {
|
||||
project_id: '{increment}',
|
||||
view_kind: 3,
|
||||
bucket_configuration_mode: 1,
|
||||
})
|
||||
BucketFactory.create(2)
|
||||
const tasks = TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
TaskBucketFactory.create(5, {
|
||||
project_view_id: 1,
|
||||
})
|
||||
const task = tasks[0]
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/projects/1/'+views[0].id)
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
.contains(task.title)
|
||||
.should('be.visible')
|
||||
.click()
|
||||
|
||||
cy.get('.task-view .action-buttons .button', { timeout: 3000 })
|
||||
cy.get('.task-view .action-buttons .button', {timeout: 3000})
|
||||
.contains('Move')
|
||||
.click()
|
||||
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
|
||||
@ -201,27 +226,23 @@ describe('Project View Kanban', () => {
|
||||
.first()
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification', { timeout: 1000 })
|
||||
cy.get('.global-notification', {timeout: 1000})
|
||||
.should('contain', 'Success')
|
||||
cy.go('back')
|
||||
cy.get('.kanban .bucket')
|
||||
.should('not.contain', task.title)
|
||||
})
|
||||
|
||||
|
||||
it('Shows a button to filter the kanban board', () => {
|
||||
const data = TaskFactory.create(10, {
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.visit('/projects/1/4')
|
||||
|
||||
cy.get('.project-kanban .filter-container .base-button')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
|
||||
it('Should remove a task from the board when deleting it', () => {
|
||||
const task = createSingleTaskInBucket(5)
|
||||
cy.visit('/projects/1/kanban')
|
||||
const {task, view} = createSingleTaskInBucket(5)
|
||||
cy.visit(`/projects/1/${view.id}`)
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
.contains(task.title)
|
||||
@ -239,18 +260,18 @@ describe('Project View Kanban', () => {
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
|
||||
|
||||
cy.get('.kanban .bucket .tasks')
|
||||
.should('not.contain', task.title)
|
||||
})
|
||||
|
||||
it('Should show a task description icon if the task has a description', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
|
||||
const task = createSingleTaskInBucket(1, {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
|
||||
const {task, view} = createSingleTaskInBucket(1, {
|
||||
description: 'Lorem Ipsum',
|
||||
})
|
||||
|
||||
cy.visit(`/projects/${task.project_id}/kanban`)
|
||||
cy.visit(`/projects/${task.project_id}/${view.id}`)
|
||||
cy.wait('@loadTasks')
|
||||
|
||||
cy.get('.bucket .tasks .task .footer .icon svg')
|
||||
@ -258,12 +279,12 @@ describe('Project View Kanban', () => {
|
||||
})
|
||||
|
||||
it('Should not show a task description icon if the task has an empty description', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
|
||||
const task = createSingleTaskInBucket(1, {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
|
||||
const {task, view} = createSingleTaskInBucket(1, {
|
||||
description: '',
|
||||
})
|
||||
|
||||
cy.visit(`/projects/${task.project_id}/kanban`)
|
||||
cy.visit(`/projects/${task.project_id}/${view.id}`)
|
||||
cy.wait('@loadTasks')
|
||||
|
||||
cy.get('.bucket .tasks .task .footer .icon svg')
|
||||
@ -271,15 +292,15 @@ describe('Project View Kanban', () => {
|
||||
})
|
||||
|
||||
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
|
||||
const task = createSingleTaskInBucket(1, {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
|
||||
const {task, view} = createSingleTaskInBucket(1, {
|
||||
description: '<p></p>',
|
||||
})
|
||||
|
||||
cy.visit(`/projects/${task.project_id}/kanban`)
|
||||
cy.visit(`/projects/${task.project_id}/${view.id}`)
|
||||
cy.wait('@loadTasks')
|
||||
|
||||
cy.get('.bucket .tasks .task .footer .icon svg')
|
||||
.should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
@ -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)
|
||||
|
@ -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', () => {
|
||||
|
@ -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)
|
||||
|
@ -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('/')
|
||||
|
@ -12,6 +12,7 @@ import {BucketFactory} from '../../factories/bucket'
|
||||
|
||||
import {TaskAttachmentFactory} from '../../factories/task_attachments'
|
||||
import {TaskReminderFactory} from '../../factories/task_reminders'
|
||||
import {createDefaultViews} from "../project/prepareProjects";
|
||||
|
||||
function addLabelToTaskAndVerify(labelTitle: string) {
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
@ -53,15 +54,16 @@ describe('Task', () => {
|
||||
beforeEach(() => {
|
||||
// UserFactory.create(1)
|
||||
projects = ProjectFactory.create(1)
|
||||
const views = createDefaultViews(projects[0].id)
|
||||
buckets = BucketFactory.create(1, {
|
||||
project_id: projects[0].id,
|
||||
project_view_id: views[3].id,
|
||||
})
|
||||
TaskFactory.truncate()
|
||||
UserProjectFactory.truncate()
|
||||
})
|
||||
|
||||
it('Should be created new', () => {
|
||||
cy.visit('/projects/1/list')
|
||||
cy.visit('/projects/1/1')
|
||||
cy.get('.input[placeholder="Add a new task…"')
|
||||
.type('New Task')
|
||||
cy.get('.button')
|
||||
@ -75,7 +77,7 @@ describe('Task', () => {
|
||||
it('Inserts new tasks at the top of the project', () => {
|
||||
TaskFactory.create(1)
|
||||
|
||||
cy.visit('/projects/1/list')
|
||||
cy.visit('/projects/1/1')
|
||||
cy.get('.project-is-empty-notice')
|
||||
.should('not.exist')
|
||||
cy.get('.input[placeholder="Add a new task…"')
|
||||
@ -93,7 +95,7 @@ describe('Task', () => {
|
||||
it('Marks a task as done', () => {
|
||||
TaskFactory.create(1)
|
||||
|
||||
cy.visit('/projects/1/list')
|
||||
cy.visit('/projects/1/1')
|
||||
cy.get('.tasks .task .fancycheckbox')
|
||||
.first()
|
||||
.click()
|
||||
@ -104,7 +106,7 @@ describe('Task', () => {
|
||||
it('Can add a task to favorites', () => {
|
||||
TaskFactory.create(1)
|
||||
|
||||
cy.visit('/projects/1/list')
|
||||
cy.visit('/projects/1/1')
|
||||
cy.get('.tasks .task .favorite')
|
||||
.first()
|
||||
.click()
|
||||
@ -113,12 +115,12 @@ describe('Task', () => {
|
||||
})
|
||||
|
||||
it('Should show a task description icon if the task has a description', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
|
||||
TaskFactory.create(1, {
|
||||
description: 'Lorem Ipsum',
|
||||
})
|
||||
|
||||
cy.visit('/projects/1/list')
|
||||
cy.visit('/projects/1/1')
|
||||
cy.wait('@loadTasks')
|
||||
|
||||
cy.get('.tasks .task .project-task-icon')
|
||||
@ -126,12 +128,12 @@ describe('Task', () => {
|
||||
})
|
||||
|
||||
it('Should not show a task description icon if the task has an empty description', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
|
||||
TaskFactory.create(1, {
|
||||
description: '',
|
||||
})
|
||||
|
||||
cy.visit('/projects/1/list')
|
||||
cy.visit('/projects/1/1')
|
||||
cy.wait('@loadTasks')
|
||||
|
||||
cy.get('.tasks .task .project-task-icon')
|
||||
@ -139,12 +141,12 @@ describe('Task', () => {
|
||||
})
|
||||
|
||||
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
|
||||
TaskFactory.create(1, {
|
||||
description: '<p></p>',
|
||||
})
|
||||
|
||||
cy.visit('/projects/1/list')
|
||||
cy.visit('/projects/1/1')
|
||||
cy.wait('@loadTasks')
|
||||
|
||||
cy.get('.tasks .task .project-task-icon')
|
||||
@ -314,8 +316,9 @@ describe('Task', () => {
|
||||
|
||||
it('Can move a task to another project', () => {
|
||||
const projects = ProjectFactory.create(2)
|
||||
const views = createDefaultViews(projects[0].id)
|
||||
BucketFactory.create(2, {
|
||||
project_id: '{increment}',
|
||||
project_view_id: views[3].id,
|
||||
})
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
@ -469,7 +472,7 @@ describe('Task', () => {
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.truncate()
|
||||
|
||||
cy.visit(`/projects/${projects[0].id}/kanban`)
|
||||
cy.visit(`/projects/${projects[0].id}/4`)
|
||||
|
||||
cy.get('.bucket .task')
|
||||
.contains(tasks[0].title)
|
||||
@ -836,7 +839,7 @@ describe('Task', () => {
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.truncate()
|
||||
|
||||
cy.visit(`/projects/${projects[0].id}/kanban`)
|
||||
cy.visit(`/projects/${projects[0].id}/4`)
|
||||
|
||||
cy.get('.bucket .task')
|
||||
.contains(tasks[0].title)
|
||||
|
@ -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(),
|
||||
|
19
frontend/cypress/factories/project_view.ts
Normal file
19
frontend/cypress/factories/project_view.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import {Factory} from '../support/factory'
|
||||
import {faker} from '@faker-js/faker'
|
||||
|
||||
export class ProjectViewFactory extends Factory {
|
||||
static table = 'project_views'
|
||||
|
||||
static factory() {
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: '{increment}',
|
||||
title: faker.lorem.words(3),
|
||||
project_id: '{increment}',
|
||||
view_kind: 0,
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
13
frontend/cypress/factories/task_buckets.ts
Normal file
13
frontend/cypress/factories/task_buckets.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import {Factory} from '../support/factory'
|
||||
|
||||
export class TaskBucketFactory extends Factory {
|
||||
static table = 'task_buckets'
|
||||
|
||||
static factory() {
|
||||
return {
|
||||
task_id: '{increment}',
|
||||
bucket_id: '{increment}',
|
||||
project_view_id: '{increment}',
|
||||
}
|
||||
}
|
||||
}
|
@ -37,7 +37,7 @@
|
||||
v-slot="{ Component }"
|
||||
:route="routeWithModal"
|
||||
>
|
||||
<keep-alive :include="['project.list', 'project.gantt', 'project.table', 'project.kanban']">
|
||||
<keep-alive :include="['project.view']">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
|
@ -33,11 +33,15 @@ import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import PoweredByLink from './PoweredByLink.vue'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
const background = computed(() => baseStore.background)
|
||||
const logoVisible = computed(() => baseStore.logoVisible)
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
projectStore.loadAllProjects()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -62,7 +62,7 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
|
||||
},
|
||||
{
|
||||
title: 'project.kanban.title',
|
||||
available: (route) => route.name === 'project.kanban',
|
||||
available: (route) => route.name === 'project.view',
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.task.done',
|
||||
|
@ -6,44 +6,17 @@
|
||||
<h1 class="project-title-print">
|
||||
{{ getProjectTitle(currentProject) }}
|
||||
</h1>
|
||||
|
||||
|
||||
<div class="switch-view-container d-print-none">
|
||||
<div class="switch-view">
|
||||
<BaseButton
|
||||
v-shortcut="'g l'"
|
||||
:title="$t('keyboardShortcuts.project.switchToListView')"
|
||||
v-for="v in views"
|
||||
:key="v.id"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'project'}"
|
||||
:to="{ name: 'project.list', params: { projectId } }"
|
||||
:class="{'is-active': v.id === viewId}"
|
||||
:to="{ name: 'project.view', params: { projectId, viewId: v.id } }"
|
||||
>
|
||||
{{ $t('project.list.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g g'"
|
||||
:title="$t('keyboardShortcuts.project.switchToGanttView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'gantt'}"
|
||||
:to="{ name: 'project.gantt', params: { projectId } }"
|
||||
>
|
||||
{{ $t('project.gantt.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g t'"
|
||||
:title="$t('keyboardShortcuts.project.switchToTableView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'table'}"
|
||||
:to="{ name: 'project.table', params: { projectId } }"
|
||||
>
|
||||
{{ $t('project.table.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g k'"
|
||||
:title="$t('keyboardShortcuts.project.switchToKanbanView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'kanban'}"
|
||||
:to="{ name: 'project.kanban', params: { projectId } }"
|
||||
>
|
||||
{{ $t('project.kanban.title') }}
|
||||
{{ getViewTitle(v) }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<slot name="header" />
|
||||
@ -63,7 +36,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
@ -79,26 +52,27 @@ import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
projectId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
viewName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
const {
|
||||
projectId,
|
||||
viewId,
|
||||
} = defineProps<{
|
||||
projectId: IProject['id'],
|
||||
viewId: IProjectView['id'],
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const {t} = useI18n()
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const projectStore = useProjectStore()
|
||||
const projectService = ref(new ProjectService())
|
||||
const loadedProjectId = ref(0)
|
||||
|
||||
const currentProject = computed(() => {
|
||||
const currentProject = computed<IProject>(() => {
|
||||
return typeof baseStore.currentProject === 'undefined' ? {
|
||||
id: 0,
|
||||
title: '',
|
||||
@ -108,13 +82,15 @@ const currentProject = computed(() => {
|
||||
})
|
||||
useTitle(() => currentProject.value?.id ? getProjectTitle(currentProject.value) : '')
|
||||
|
||||
const views = computed(() => currentProject.value?.views)
|
||||
|
||||
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
|
||||
// This resulted in loading and setting the project multiple times, even when navigating away from it.
|
||||
// This caused wired bugs where the project background would be set on the home page but only right after setting a new
|
||||
// project background and then navigating to home. It also highlighted the project in the menu and didn't allow changing any
|
||||
// of it, most likely due to the rights not being properly populated.
|
||||
watch(
|
||||
() => props.projectId,
|
||||
() => projectId,
|
||||
// loadProject
|
||||
async (projectIdToLoad: number) => {
|
||||
const projectData = {id: projectIdToLoad}
|
||||
@ -130,11 +106,11 @@ watch(
|
||||
)
|
||||
&& typeof currentProject.value !== 'undefined' && currentProject.value.maxRight !== null
|
||||
) {
|
||||
loadedProjectId.value = props.projectId
|
||||
loadedProjectId.value = projectId
|
||||
return
|
||||
}
|
||||
|
||||
console.debug(`Loading project, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedProjectId = ${loadedProjectId.value}, currentProject = `, currentProject.value)
|
||||
console.debug('Loading project, $route.params =', route.params, `, loadedProjectId = ${loadedProjectId.value}, currentProject = `, currentProject.value)
|
||||
|
||||
// Set the current project to the one we're about to load so that the title is already shown at the top
|
||||
loadedProjectId.value = 0
|
||||
@ -149,31 +125,46 @@ watch(
|
||||
const loadedProject = await projectService.value.get(project)
|
||||
baseStore.handleSetCurrentProject({project: loadedProject})
|
||||
} finally {
|
||||
loadedProjectId.value = props.projectId
|
||||
loadedProjectId.value = projectId
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function getViewTitle(view: IProjectView) {
|
||||
switch (view.title) {
|
||||
case 'List':
|
||||
return t('project.list.title')
|
||||
case 'Gantt':
|
||||
return t('project.gantt.title')
|
||||
case 'Table':
|
||||
return t('project.table.title')
|
||||
case 'Kanban':
|
||||
return t('project.kanban.title')
|
||||
}
|
||||
|
||||
return view.title
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.switch-view-container {
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.switch-view {
|
||||
background: var(--white);
|
||||
display: inline-flex;
|
||||
border-radius: $radius;
|
||||
font-size: .75rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
height: $switch-view-height;
|
||||
margin: 0 auto 1rem;
|
||||
padding: .5rem;
|
||||
background: var(--white);
|
||||
display: inline-flex;
|
||||
border-radius: $radius;
|
||||
font-size: .75rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
height: $switch-view-height;
|
||||
margin: 0 auto 1rem;
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.switch-view-button {
|
||||
@ -201,7 +192,7 @@ watch(
|
||||
|
||||
// FIXME: this should be in notification and set via a prop
|
||||
.is-archived .notification.is-warning {
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.project-title-print {
|
||||
@ -209,7 +200,7 @@ watch(
|
||||
font-size: 1.75rem;
|
||||
text-align: center;
|
||||
margin-bottom: .5rem;
|
||||
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
}
|
||||
|
@ -21,13 +21,16 @@ import {
|
||||
LABEL_FIELDS,
|
||||
} from '@/helpers/filters'
|
||||
import {useDebounceFn} from '@vueuse/core'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
projectId,
|
||||
inputLabel = undefined,
|
||||
} = defineProps<{
|
||||
modelValue: string,
|
||||
projectId?: number,
|
||||
inputLabel?: string,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'blur'])
|
||||
@ -38,6 +41,8 @@ const {
|
||||
height,
|
||||
} = useAutoHeightTextarea(filterQuery)
|
||||
|
||||
const id = ref(createRandomID())
|
||||
|
||||
watch(
|
||||
() => modelValue,
|
||||
() => {
|
||||
@ -246,7 +251,12 @@ const blurDebounced = useDebounceFn(() => emit('blur'), 500)
|
||||
|
||||
<template>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('filters.query.title') }}</label>
|
||||
<label
|
||||
class="label"
|
||||
:for="id"
|
||||
>
|
||||
{{ inputLabel ?? $t('filters.query.title') }}
|
||||
</label>
|
||||
<AutocompleteDropdown
|
||||
:options="autocompleteResults"
|
||||
@blur="filterInput?.blur()"
|
||||
@ -257,10 +267,10 @@ const blurDebounced = useDebounceFn(() => emit('blur'), 500)
|
||||
>
|
||||
<div class="control filter-input">
|
||||
<textarea
|
||||
:id
|
||||
ref="filterInput"
|
||||
v-model="filterQuery"
|
||||
autocomplete="off"
|
||||
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
|
@ -19,7 +19,7 @@
|
||||
</Fancycheckbox>
|
||||
</div>
|
||||
|
||||
<FilterInputDocs/>
|
||||
<FilterInputDocs />
|
||||
|
||||
<template
|
||||
v-if="hasFooter"
|
||||
|
@ -47,6 +47,12 @@
|
||||
>
|
||||
{{ $t('menu.edit') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
:to="{ name: 'project.settings.views', params: { projectId: project.id } }"
|
||||
icon="eye"
|
||||
>
|
||||
{{ $t('menu.views') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
v-if="backgroundsEnabled"
|
||||
:to="{ name: 'project.settings.background', params: { projectId: project.id } }"
|
||||
|
@ -1,8 +1,24 @@
|
||||
<!-- Vikunja is a to-do list application to facilitate your life. -->
|
||||
<!-- Copyright 2018-present Vikunja and contributors. All rights reserved. -->
|
||||
<!-- -->
|
||||
<!-- This program is free software: you can redistribute it and/or modify -->
|
||||
<!-- it under the terms of the GNU Affero General Public Licensee as published by -->
|
||||
<!-- the Free Software Foundation, either version 3 of the License, or -->
|
||||
<!-- (at your option) any later version. -->
|
||||
<!-- -->
|
||||
<!-- This program is distributed in the hope that it will be useful, -->
|
||||
<!-- but WITHOUT ANY WARRANTY; without even the implied warranty of -->
|
||||
<!-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -->
|
||||
<!-- GNU Affero General Public Licensee for more details. -->
|
||||
<!-- -->
|
||||
<!-- You should have received a copy of the GNU Affero General Public Licensee -->
|
||||
<!-- along with this program. If not, see <https://www.gnu.org/licenses/>. -->
|
||||
|
||||
<template>
|
||||
<ProjectWrapper
|
||||
class="project-gantt"
|
||||
:project-id="filters.projectId"
|
||||
view-name="gantt"
|
||||
:view
|
||||
>
|
||||
<template #header>
|
||||
<card :has-content="false">
|
||||
@ -87,15 +103,19 @@ import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import TaskForm from '@/components/tasks/TaskForm.vue'
|
||||
|
||||
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
||||
import {useGanttFilters} from './helpers/useGanttFilters'
|
||||
import {useGanttFilters} from '../../../views/project/helpers/useGanttFilters'
|
||||
import {RIGHTS} from '@/constants/rights'
|
||||
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
|
||||
type Options = Flatpickr.Options.Options
|
||||
|
||||
const props = defineProps<{route: RouteLocationNormalized}>()
|
||||
const props = defineProps<{
|
||||
route: RouteLocationNormalized
|
||||
viewId: IProjectView['id']
|
||||
}>()
|
||||
|
||||
const GanttChart = createAsyncComponent(() => import('@/components/tasks/GanttChart.vue'))
|
||||
|
||||
@ -111,7 +131,7 @@ const {
|
||||
isLoading,
|
||||
addTask,
|
||||
updateTask,
|
||||
} = useGanttFilters(route)
|
||||
} = useGanttFilters(route, props.viewId)
|
||||
|
||||
const DEFAULT_DATE_RANGE_DAYS = 7
|
||||
|
@ -2,7 +2,7 @@
|
||||
<ProjectWrapper
|
||||
class="project-kanban"
|
||||
:project-id="projectId"
|
||||
view-name="kanban"
|
||||
:view-id
|
||||
>
|
||||
<template #header>
|
||||
<div class="filter-container">
|
||||
@ -277,7 +277,6 @@ import {RIGHTS as Rights} from '@/constants/rights'
|
||||
import BucketModel from '@/models/bucket'
|
||||
|
||||
import type {IBucket} from '@/modelTypes/IBucket'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
@ -301,11 +300,17 @@ import {isSavedFilter} from '@/services/savedFilter'
|
||||
import {success} from '@/message'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
import TaskPositionService from '@/services/taskPosition'
|
||||
import TaskPositionModel from '@/models/taskPosition'
|
||||
import {i18n} from '@/i18n'
|
||||
|
||||
const {
|
||||
projectId = undefined,
|
||||
projectId,
|
||||
viewId,
|
||||
} = defineProps<{
|
||||
projectId: number,
|
||||
viewId: IProjectView['id'],
|
||||
}>()
|
||||
|
||||
const DRAG_OPTIONS = {
|
||||
@ -325,6 +330,7 @@ const baseStore = useBaseStore()
|
||||
const kanbanStore = useKanbanStore()
|
||||
const taskStore = useTaskStore()
|
||||
const projectStore = useProjectStore()
|
||||
const taskPositionService = ref(new TaskPositionService())
|
||||
|
||||
const taskContainerRefs = ref<{ [id: IBucket['id']]: HTMLElement }>({})
|
||||
const bucketLimitInputRef = ref<HTMLInputElement | null>(null)
|
||||
@ -363,7 +369,7 @@ const params = ref<TaskFilterParams>({
|
||||
const getTaskDraggableTaskComponentData = computed(() => (bucket: IBucket) => {
|
||||
return {
|
||||
ref: (el: HTMLElement) => setTaskContainerRef(bucket.id, el),
|
||||
onScroll: (event: Event) => handleTaskContainerScroll(bucket.id, bucket.projectId, event.target as HTMLElement),
|
||||
onScroll: (event: Event) => handleTaskContainerScroll(bucket.id, event.target as HTMLElement),
|
||||
type: 'transition-group',
|
||||
name: !drag.value ? 'move-card' : null,
|
||||
class: [
|
||||
@ -387,19 +393,20 @@ const project = computed(() => projectId ? projectStore.projects[projectId] : nu
|
||||
const buckets = computed(() => kanbanStore.buckets)
|
||||
const loading = computed(() => kanbanStore.isLoading)
|
||||
|
||||
const taskLoading = computed(() => taskStore.isLoading)
|
||||
const taskLoading = computed(() => taskStore.isLoading || taskPositionService.value.loading)
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
params: params.value,
|
||||
projectId,
|
||||
viewId,
|
||||
}),
|
||||
({params}) => {
|
||||
if (projectId === undefined || Number(projectId) === 0) {
|
||||
return
|
||||
}
|
||||
collapsedBuckets.value = getCollapsedBucketState(projectId)
|
||||
kanbanStore.loadBucketsForProject({projectId, params})
|
||||
kanbanStore.loadBucketsForProject(projectId, viewId, params)
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
@ -412,7 +419,7 @@ function setTaskContainerRef(id: IBucket['id'], el: HTMLElement) {
|
||||
taskContainerRefs.value[id] = el
|
||||
}
|
||||
|
||||
function handleTaskContainerScroll(id: IBucket['id'], projectId: IProject['id'], el: HTMLElement) {
|
||||
function handleTaskContainerScroll(id: IBucket['id'], el: HTMLElement) {
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
@ -424,6 +431,7 @@ function handleTaskContainerScroll(id: IBucket['id'], projectId: IProject['id'],
|
||||
|
||||
kanbanStore.loadNextTasksForBucket(
|
||||
projectId,
|
||||
viewId,
|
||||
params.value,
|
||||
id,
|
||||
)
|
||||
@ -473,7 +481,7 @@ async function updateTaskPosition(e) {
|
||||
|
||||
const newTask = klona(task) // cloning the task to avoid pinia store manipulation
|
||||
newTask.bucketId = newBucket.id
|
||||
newTask.kanbanPosition = calculateItemPosition(
|
||||
const position = calculateItemPosition(
|
||||
taskBefore !== null ? taskBefore.kanbanPosition : null,
|
||||
taskAfter !== null ? taskAfter.kanbanPosition : null,
|
||||
)
|
||||
@ -483,6 +491,8 @@ async function updateTaskPosition(e) {
|
||||
) {
|
||||
newTask.done = project.value?.doneBucketId === newBucket.id
|
||||
}
|
||||
|
||||
let bucketHasChanged = false
|
||||
if (
|
||||
oldBucket !== undefined && // This shouldn't actually be `undefined`, but let's play it safe.
|
||||
newBucket.id !== oldBucket.id
|
||||
@ -495,10 +505,20 @@ async function updateTaskPosition(e) {
|
||||
...newBucket,
|
||||
count: newBucket.count + 1,
|
||||
})
|
||||
bucketHasChanged = true
|
||||
}
|
||||
|
||||
try {
|
||||
await taskStore.update(newTask)
|
||||
const newPosition = new TaskPositionModel({
|
||||
position,
|
||||
projectViewId: viewId,
|
||||
taskId: newTask.id,
|
||||
})
|
||||
await taskPositionService.value.update(newPosition)
|
||||
|
||||
if(bucketHasChanged) {
|
||||
await taskStore.update(newTask)
|
||||
}
|
||||
|
||||
// Make sure the first and second task don't both get position 0 assigned
|
||||
if (newTaskIndex === 0 && taskAfter !== null && taskAfter.kanbanPosition === 0) {
|
||||
@ -556,6 +576,7 @@ async function createNewBucket() {
|
||||
await kanbanStore.createBucket(new BucketModel({
|
||||
title: newBucketTitle.value,
|
||||
projectId: project.value.id,
|
||||
projectViewId: viewId,
|
||||
}))
|
||||
newBucketTitle.value = ''
|
||||
}
|
||||
@ -575,6 +596,7 @@ async function deleteBucket() {
|
||||
bucket: new BucketModel({
|
||||
id: bucketToDelete.value,
|
||||
projectId: project.value.id,
|
||||
projectViewId: viewId,
|
||||
}),
|
||||
params: params.value,
|
||||
})
|
||||
@ -593,10 +615,19 @@ async function focusBucketTitle(e: Event) {
|
||||
}
|
||||
|
||||
async function saveBucketTitle(bucketId: IBucket['id'], bucketTitle: string) {
|
||||
await kanbanStore.updateBucketTitle({
|
||||
|
||||
const bucket = kanbanStore.getBucketById(bucketId)
|
||||
if (bucket?.title === bucketTitle) {
|
||||
bucketTitleEditable.value = false
|
||||
return
|
||||
}
|
||||
|
||||
await kanbanStore.updateBucket({
|
||||
id: bucketId,
|
||||
title: bucketTitle,
|
||||
projectId,
|
||||
})
|
||||
success({message: i18n.global.t('project.kanban.bucketTitleSavedSuccess')})
|
||||
bucketTitleEditable.value = false
|
||||
}
|
||||
|
||||
@ -616,6 +647,7 @@ function updateBucketPosition(e: { newIndex: number }) {
|
||||
|
||||
kanbanStore.updateBucket({
|
||||
id: bucket.id,
|
||||
projectId,
|
||||
position: calculateItemPosition(
|
||||
bucketBefore !== null ? bucketBefore.position : null,
|
||||
bucketAfter !== null ? bucketAfter.position : null,
|
||||
@ -630,6 +662,7 @@ async function saveBucketLimit(bucketId: IBucket['id'], limit: number) {
|
||||
|
||||
await kanbanStore.updateBucket({
|
||||
...kanbanStore.getBucketById(bucketId),
|
||||
projectId,
|
||||
limit,
|
||||
})
|
||||
success({message: t('project.kanban.bucketLimitSavedSuccess')})
|
@ -2,7 +2,7 @@
|
||||
<ProjectWrapper
|
||||
class="project-list"
|
||||
:project-id="projectId"
|
||||
view-name="project"
|
||||
:view-id
|
||||
>
|
||||
<template #header>
|
||||
<div class="filter-container">
|
||||
@ -114,14 +114,18 @@ import type {ITask} from '@/modelTypes/ITask'
|
||||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
import TaskPositionService from '@/services/taskPosition'
|
||||
import TaskPositionModel from '@/models/taskPosition'
|
||||
|
||||
const {
|
||||
projectId,
|
||||
viewId,
|
||||
} = defineProps<{
|
||||
projectId: IProject['id'],
|
||||
viewId: IProjectView['id'],
|
||||
}>()
|
||||
|
||||
const ctaVisible = ref(false)
|
||||
@ -140,7 +144,9 @@ const {
|
||||
loadTasks,
|
||||
params,
|
||||
sortByParam,
|
||||
} = useTaskList(() => projectId, {position: 'asc'})
|
||||
} = useTaskList(() => projectId, () => viewId, {position: 'asc'})
|
||||
|
||||
const taskPositionService = ref(new TaskPositionService())
|
||||
|
||||
const tasks = ref<ITask[]>([])
|
||||
watch(
|
||||
@ -182,7 +188,6 @@ const firstNewPosition = computed(() => {
|
||||
return calculateItemPosition(null, tasks.value[0].position)
|
||||
})
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const baseStore = useBaseStore()
|
||||
const project = computed(() => baseStore.currentProject)
|
||||
|
||||
@ -231,13 +236,17 @@ async function saveTaskPosition(e) {
|
||||
const taskBefore = tasks.value[e.newIndex - 1] ?? null
|
||||
const taskAfter = tasks.value[e.newIndex + 1] ?? null
|
||||
|
||||
const newTask = {
|
||||
...task,
|
||||
position: calculateItemPosition(taskBefore !== null ? taskBefore.position : null, taskAfter !== null ? taskAfter.position : null),
|
||||
}
|
||||
const position = calculateItemPosition(taskBefore !== null ? taskBefore.position : null, taskAfter !== null ? taskAfter.position : null)
|
||||
|
||||
const updatedTask = await taskStore.update(newTask)
|
||||
tasks.value[e.newIndex] = updatedTask
|
||||
await taskPositionService.value.update(new TaskPositionModel({
|
||||
position,
|
||||
projectViewId: viewId,
|
||||
taskId: task.id,
|
||||
}))
|
||||
tasks.value[e.newIndex] = {
|
||||
...task,
|
||||
position,
|
||||
}
|
||||
}
|
||||
|
||||
function prepareFiltersAndLoadTasks() {
|
@ -2,7 +2,7 @@
|
||||
<ProjectWrapper
|
||||
class="project-table"
|
||||
:project-id="projectId"
|
||||
view-name="table"
|
||||
:view-id
|
||||
>
|
||||
<template #header>
|
||||
<div class="filter-container">
|
||||
@ -289,11 +289,14 @@ import {useTaskList} from '@/composables/useTaskList'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
|
||||
const {
|
||||
projectId,
|
||||
viewId,
|
||||
} = defineProps<{
|
||||
projectId: IProject['id'],
|
||||
viewId: IProjectView['id'],
|
||||
}>()
|
||||
|
||||
const ACTIVE_COLUMNS_DEFAULT = {
|
||||
@ -320,7 +323,7 @@ const SORT_BY_DEFAULT: SortBy = {
|
||||
const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT})
|
||||
const sortBy = useStorage<SortBy>('tableViewSortBy', {...SORT_BY_DEFAULT})
|
||||
|
||||
const taskList = useTaskList(() => projectId, sortBy.value)
|
||||
const taskList = useTaskList(() => projectId, () => viewId, sortBy.value)
|
||||
|
||||
const {
|
||||
loading,
|
180
frontend/src/components/project/views/viewEditForm.vue
Normal file
180
frontend/src/components/project/views/viewEditForm.vue
Normal file
@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
import XButton from '@/components/input/button.vue'
|
||||
import FilterInput from '@/components/project/partials/FilterInput.vue'
|
||||
import {ref} from 'vue'
|
||||
|
||||
const model = defineModel<IProjectView>()
|
||||
const titleValid = ref(true)
|
||||
function validateTitle() {
|
||||
titleValid.value = model.value.title !== ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="title"
|
||||
>
|
||||
{{ $t('project.views.title') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="title"
|
||||
v-model="model.title"
|
||||
v-focus
|
||||
class="input"
|
||||
:placeholder="$t('project.share.links.namePlaceholder')"
|
||||
@blur="validateTitle"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="!titleValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('project.views.titleRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="kind"
|
||||
>
|
||||
{{ $t('project.views.kind') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select
|
||||
id="kind"
|
||||
v-model="model.viewKind"
|
||||
>
|
||||
<option value="list">
|
||||
{{ $t('project.list.title') }}
|
||||
</option>
|
||||
<option value="gantt">
|
||||
{{ $t('project.gantt.title') }}
|
||||
</option>
|
||||
<option value="table">
|
||||
{{ $t('project.table.title') }}
|
||||
</option>
|
||||
<option value="kanban">
|
||||
{{ $t('project.kanban.title') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterInput
|
||||
v-model="model.filter"
|
||||
:input-label="$t('project.views.filter')"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="model.viewKind === 'kanban'"
|
||||
class="field"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
for="configMode"
|
||||
>
|
||||
{{ $t('project.views.bucketConfigMode') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select
|
||||
id="configMode"
|
||||
v-model="model.bucketConfigurationMode"
|
||||
>
|
||||
<option value="manual">
|
||||
{{ $t('project.views.bucketConfigManual') }}
|
||||
</option>
|
||||
<option value="filter">
|
||||
{{ $t('project.views.filter') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="model.viewKind === 'kanban' && model.bucketConfigurationMode === 'filter'"
|
||||
class="field"
|
||||
>
|
||||
<label class="label">
|
||||
{{ $t('project.views.bucketConfig') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<div
|
||||
v-for="(b, index) in model.bucketConfiguration"
|
||||
:key="'bucket_'+index"
|
||||
class="filter-bucket"
|
||||
>
|
||||
<button
|
||||
class="is-danger"
|
||||
@click.prevent="() => model.bucketConfiguration.splice(index, 1)"
|
||||
>
|
||||
<icon icon="trash-alt" />
|
||||
</button>
|
||||
<div class="filter-bucket-form">
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
:for="'bucket_'+index+'_title'"
|
||||
>
|
||||
{{ $t('project.views.title') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
:id="'bucket_'+index+'_title'"
|
||||
v-model="model.bucketConfiguration[index].title"
|
||||
class="input"
|
||||
:placeholder="$t('project.share.links.namePlaceholder')"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterInput
|
||||
v-model="model.bucketConfiguration[index].filter"
|
||||
:input-label="$t('project.views.filter')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-flex is-justify-content-end">
|
||||
<XButton
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
@click="() => model.bucketConfiguration.push({title: '', filter: ''})"
|
||||
>
|
||||
{{ $t('project.kanban.addBucket') }}
|
||||
</XButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.filter-bucket {
|
||||
display: flex;
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--danger);
|
||||
padding-right: .75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&-form {
|
||||
margin-bottom: .5rem;
|
||||
padding: .5rem;
|
||||
border: 1px solid var(--grey-200);
|
||||
border-radius: $radius;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -173,11 +173,11 @@
|
||||
<div class="select">
|
||||
<select v-model="selectedView[s.id]">
|
||||
<option
|
||||
v-for="(title, key) in availableViews"
|
||||
:key="key"
|
||||
:value="key"
|
||||
v-for="(view) in availableViews"
|
||||
:key="view.id"
|
||||
:value="view.id"
|
||||
>
|
||||
{{ title }}
|
||||
{{ view.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -230,9 +230,9 @@ import LinkShareService from '@/services/linkShare'
|
||||
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
|
||||
import {success} from '@/message'
|
||||
import {getDisplayName} from '@/models/user'
|
||||
import type {ProjectView} from '@/types/ProjectView'
|
||||
import {PROJECT_VIEWS} from '@/types/ProjectView'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
|
||||
const props = defineProps({
|
||||
projectId: {
|
||||
@ -252,17 +252,13 @@ const showDeleteModal = ref(false)
|
||||
const linkIdToDelete = ref(0)
|
||||
const showNewForm = ref(false)
|
||||
|
||||
type SelectedViewMapper = Record<IProject['id'], ProjectView>
|
||||
type SelectedViewMapper = Record<IProject['id'], IProjectView['id']>
|
||||
|
||||
const selectedView = ref<SelectedViewMapper>({})
|
||||
|
||||
const availableViews = computed<Record<ProjectView, string>>(() => ({
|
||||
list: t('project.list.title'),
|
||||
gantt: t('project.gantt.title'),
|
||||
table: t('project.table.title'),
|
||||
kanban: t('project.kanban.title'),
|
||||
}))
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const availableViews = computed<IProjectView[]>(() => projectStore.projects[props.projectId]?.views || [])
|
||||
const copy = useCopyToClipboard()
|
||||
watch(
|
||||
() => props.projectId,
|
||||
@ -281,7 +277,7 @@ async function load(projectId: IProject['id']) {
|
||||
|
||||
const links = await linkShareService.getAll({projectId})
|
||||
links.forEach((l: ILinkShare) => {
|
||||
selectedView.value[l.id] = 'list'
|
||||
selectedView.value[l.id] = availableViews.value[0].id
|
||||
})
|
||||
linkShares.value = links
|
||||
}
|
||||
@ -315,8 +311,8 @@ async function remove(projectId: IProject['id']) {
|
||||
}
|
||||
}
|
||||
|
||||
function getShareLink(hash: string, view: ProjectView = PROJECT_VIEWS.LIST) {
|
||||
return frontendUrl.value + 'share/' + hash + '/auth?view=' + view
|
||||
function getShareLink(hash: string, viewId: IProjectView['id']) {
|
||||
return frontendUrl.value + 'share/' + hash + '/auth?view=' + viewId
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -30,7 +30,7 @@
|
||||
<router-link
|
||||
v-if="showProject && typeof project !== 'undefined'"
|
||||
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
|
||||
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
|
||||
:to="{ name: 'project.index', params: { projectId: task.projectId } }"
|
||||
class="task-project mr-1"
|
||||
:class="{'mr-2': task.hexColor !== ''}"
|
||||
>
|
||||
@ -136,7 +136,7 @@
|
||||
<router-link
|
||||
v-if="showProjectSeparately"
|
||||
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
|
||||
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
|
||||
:to="{ name: 'project.index', params: { projectId: task.projectId } }"
|
||||
class="task-project"
|
||||
>
|
||||
{{ project.title }}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -7,6 +7,7 @@ import type {ITask} from '@/modelTypes/ITask'
|
||||
import {error} from '@/message'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
|
||||
export type Order = 'asc' | 'desc' | 'none'
|
||||
|
||||
@ -54,9 +55,14 @@ const SORT_BY_DEFAULT: SortBy = {
|
||||
/**
|
||||
* This mixin provides a base set of methods and properties to get tasks.
|
||||
*/
|
||||
export function useTaskList(projectIdGetter: ComputedGetter<IProject['id']>, sortByDefault: SortBy = SORT_BY_DEFAULT) {
|
||||
export function useTaskList(
|
||||
projectIdGetter: ComputedGetter<IProject['id']>,
|
||||
projectViewIdGetter: ComputedGetter<IProjectView['id']>,
|
||||
sortByDefault: SortBy = SORT_BY_DEFAULT,
|
||||
) {
|
||||
|
||||
const projectId = computed(() => projectIdGetter())
|
||||
const projectViewId = computed(() => projectViewIdGetter())
|
||||
|
||||
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
|
||||
|
||||
@ -87,7 +93,10 @@ export function useTaskList(projectIdGetter: ComputedGetter<IProject['id']>, sor
|
||||
|
||||
const getAllTasksParams = computed(() => {
|
||||
return [
|
||||
{projectId: projectId.value},
|
||||
{
|
||||
projectId: projectId.value,
|
||||
viewId: projectViewId.value,
|
||||
},
|
||||
{
|
||||
...allParams.value,
|
||||
filter_timezone: authStore.settings.timezone,
|
||||
|
@ -1,64 +1,17 @@
|
||||
import type { RouteRecordName } from 'vue-router'
|
||||
import router from '@/router'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
export type ProjectRouteName = Extract<RouteRecordName, string>
|
||||
export type ProjectViewSettings = Record<
|
||||
IProject['id'],
|
||||
Extract<RouteRecordName, ProjectRouteName>
|
||||
>
|
||||
export type ProjectViewSettings = Record<IProject['id'], number>
|
||||
|
||||
const SETTINGS_KEY_PROJECT_VIEW = 'projectView'
|
||||
|
||||
// TODO: remove migration when releasing 1.0
|
||||
type ListViewSettings = ProjectViewSettings
|
||||
const SETTINGS_KEY_DEPRECATED_LIST_VIEW = 'listView'
|
||||
function migrateStoredProjectRouteSettings() {
|
||||
try {
|
||||
const listViewSettingsString = localStorage.getItem(SETTINGS_KEY_DEPRECATED_LIST_VIEW)
|
||||
if (listViewSettingsString === null) {
|
||||
return
|
||||
}
|
||||
|
||||
// A) the first version stored one setting for all lists in a string
|
||||
if (listViewSettingsString.startsWith('list.')) {
|
||||
const projectView = listViewSettingsString.replace('list.', 'project.')
|
||||
|
||||
if (!router.hasRoute(projectView)) {
|
||||
return
|
||||
}
|
||||
return projectView as RouteRecordName
|
||||
}
|
||||
|
||||
// B) the last version used a 'list.' prefix
|
||||
const listViewSettings: ListViewSettings = JSON.parse(listViewSettingsString)
|
||||
|
||||
const projectViewSettingEntries = Object.entries(listViewSettings).map(([id, value]) => {
|
||||
return [id, value.replace('list.', 'project.')]
|
||||
})
|
||||
const projectViewSettings = Object.fromEntries(projectViewSettingEntries)
|
||||
|
||||
localStorage.setItem(SETTINGS_KEY_PROJECT_VIEW, JSON.stringify(projectViewSettings))
|
||||
} catch(e) {
|
||||
//
|
||||
} finally {
|
||||
localStorage.removeItem(SETTINGS_KEY_DEPRECATED_LIST_VIEW)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current project view to local storage
|
||||
*/
|
||||
export function saveProjectView(projectId: IProject['id'], routeName: string) {
|
||||
if (routeName.includes('settings.')) {
|
||||
export function saveProjectView(projectId: IProject['id'], viewId: number) {
|
||||
if (!projectId || !viewId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// We use local storage and not the store here to make it persistent across reloads.
|
||||
const savedProjectView = localStorage.getItem(SETTINGS_KEY_PROJECT_VIEW)
|
||||
let savedProjectViewSettings: ProjectViewSettings | false = false
|
||||
@ -71,30 +24,19 @@ export function saveProjectView(projectId: IProject['id'], routeName: string) {
|
||||
projectViewSettings = savedProjectViewSettings
|
||||
}
|
||||
|
||||
projectViewSettings[projectId] = routeName
|
||||
projectViewSettings[projectId] = viewId
|
||||
localStorage.setItem(SETTINGS_KEY_PROJECT_VIEW, JSON.stringify(projectViewSettings))
|
||||
}
|
||||
|
||||
export const getProjectView = (projectId: IProject['id']) => {
|
||||
// TODO: remove migration when releasing 1.0
|
||||
const migratedProjectView = migrateStoredProjectRouteSettings()
|
||||
|
||||
if (migratedProjectView !== undefined && router.hasRoute(migratedProjectView)) {
|
||||
return migratedProjectView
|
||||
export function getProjectViewId(projectId: IProject['id']): number {
|
||||
const projectViewSettingsString = localStorage.getItem(SETTINGS_KEY_PROJECT_VIEW)
|
||||
if (!projectViewSettingsString) {
|
||||
return 0
|
||||
}
|
||||
|
||||
try {
|
||||
const projectViewSettingsString = localStorage.getItem(SETTINGS_KEY_PROJECT_VIEW)
|
||||
if (!projectViewSettingsString) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
const projectViewSettings = JSON.parse(projectViewSettingsString) as ProjectViewSettings
|
||||
if (!router.hasRoute(projectViewSettings[projectId])) {
|
||||
throw new Error()
|
||||
}
|
||||
return projectViewSettings[projectId]
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
const projectViewSettings = JSON.parse(projectViewSettingsString) as ProjectViewSettings
|
||||
if (isNaN(projectViewSettings[projectId])) {
|
||||
return 0
|
||||
}
|
||||
return projectViewSettings[projectId]
|
||||
}
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
31
frontend/src/modelTypes/IProjectView.ts
Normal file
31
frontend/src/modelTypes/IProjectView.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import type {IAbstract} from './IAbstract'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
export const PROJECT_VIEW_KINDS = ['list', 'gantt', 'table', 'kanban']
|
||||
export type ProjectViewKind = typeof PROJECT_VIEW_KINDS[number]
|
||||
|
||||
export const PROJECT_VIEW_BUCKET_CONFIGURATION_MODES = ['none', 'manual', 'filter']
|
||||
export type ProjectViewBucketConfigurationMode = typeof PROJECT_VIEW_BUCKET_CONFIGURATION_MODES[number]
|
||||
|
||||
export interface IProjectViewBucketConfiguration {
|
||||
title: string
|
||||
filter: string
|
||||
}
|
||||
|
||||
export interface IProjectView extends IAbstract {
|
||||
id: number
|
||||
title: string
|
||||
projectId: IProject['id']
|
||||
viewKind: ProjectViewKind
|
||||
|
||||
filter: string
|
||||
position: number
|
||||
|
||||
bucketConfigurationMode: ProjectViewBucketConfigurationMode
|
||||
bucketConfiguration: IProjectViewBucketConfiguration[]
|
||||
defaultBucketId: number
|
||||
doneBucketId: number
|
||||
|
||||
created: Date
|
||||
updated: Date
|
||||
}
|
8
frontend/src/modelTypes/ITaskPosition.ts
Normal file
8
frontend/src/modelTypes/ITaskPosition.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
import type {IAbstract} from '@/modelTypes/IAbstract'
|
||||
|
||||
export interface ITaskPosition extends IAbstract {
|
||||
position: number
|
||||
projectViewId: IProjectView['id']
|
||||
taskId: number
|
||||
}
|
@ -7,6 +7,7 @@ import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {ISubscription} from '@/modelTypes/ISubscription'
|
||||
import ProjectViewModel from '@/models/projectView'
|
||||
|
||||
export default class ProjectModel extends AbstractModel<IProject> implements IProject {
|
||||
id = 0
|
||||
@ -25,6 +26,7 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
|
||||
parentProjectId = 0
|
||||
doneBucketId = 0
|
||||
defaultBucketId = 0
|
||||
views = []
|
||||
|
||||
created: Date = null
|
||||
updated: Date = null
|
||||
@ -48,6 +50,8 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
|
||||
this.subscription = new SubscriptionModel(this.subscription)
|
||||
}
|
||||
|
||||
this.views = this.views.map(v => new ProjectViewModel(v))
|
||||
|
||||
this.created = new Date(this.created)
|
||||
this.updated = new Date(this.updated)
|
||||
}
|
||||
|
29
frontend/src/models/projectView.ts
Normal file
29
frontend/src/models/projectView.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type {IProjectView, ProjectViewBucketConfigurationMode, ProjectViewKind} from '@/modelTypes/IProjectView'
|
||||
import AbstractModel from '@/models/abstractModel'
|
||||
|
||||
export default class ProjectViewModel extends AbstractModel<IProjectView> implements IProjectView {
|
||||
id = 0
|
||||
title = ''
|
||||
projectId = 0
|
||||
viewKind: ProjectViewKind = 'list'
|
||||
|
||||
filter = ''
|
||||
position = 0
|
||||
|
||||
bucketConfiguration = []
|
||||
bucketConfigurationMode: ProjectViewBucketConfigurationMode = 'manual'
|
||||
defaultBucketId = 0
|
||||
doneBucketId = 0
|
||||
|
||||
created: Date = new Date()
|
||||
updated: Date = new Date()
|
||||
|
||||
constructor(data: Partial<IProjectView>) {
|
||||
super()
|
||||
this.assignData(data)
|
||||
|
||||
if (!this.bucketConfiguration) {
|
||||
this.bucketConfiguration = []
|
||||
}
|
||||
}
|
||||
}
|
13
frontend/src/models/taskPosition.ts
Normal file
13
frontend/src/models/taskPosition.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import AbstractModel from '@/models/abstractModel'
|
||||
import type {ITaskPosition} from '@/modelTypes/ITaskPosition'
|
||||
|
||||
export default class TaskPositionModel extends AbstractModel<ITaskPosition> implements ITaskPosition {
|
||||
position = 0
|
||||
projectViewId = 0
|
||||
taskId = 0
|
||||
|
||||
constructor(data: Partial<ITaskPosition>) {
|
||||
super()
|
||||
this.assignData(data)
|
||||
}
|
||||
}
|
@ -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',
|
||||
|
@ -6,10 +6,10 @@ import type { IBucket } from '@/modelTypes/IBucket'
|
||||
export default class BucketService extends AbstractService<IBucket> {
|
||||
constructor() {
|
||||
super({
|
||||
getAll: '/projects/{projectId}/buckets',
|
||||
create: '/projects/{projectId}/buckets',
|
||||
update: '/projects/{projectId}/buckets/{id}',
|
||||
delete: '/projects/{projectId}/buckets/{id}',
|
||||
getAll: '/projects/{projectId}/views/{projectViewId}/buckets',
|
||||
create: '/projects/{projectId}/views/{projectViewId}/buckets',
|
||||
update: '/projects/{projectId}/views/{projectViewId}/buckets/{id}',
|
||||
delete: '/projects/{projectId}/views/{projectViewId}/buckets/{id}',
|
||||
})
|
||||
}
|
||||
|
||||
|
20
frontend/src/services/projectViews.ts
Normal file
20
frontend/src/services/projectViews.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import AbstractService from '@/services/abstractService'
|
||||
import type {IAbstract} from '@/modelTypes/IAbstract'
|
||||
import ProjectViewModel from '@/models/projectView'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
|
||||
export default class ProjectViewService extends AbstractService<IProjectView> {
|
||||
constructor() {
|
||||
super({
|
||||
get: '/projects/{projectId}/views/{id}',
|
||||
getAll: '/projects/{projectId}/views',
|
||||
create: '/projects/{projectId}/views',
|
||||
update: '/projects/{projectId}/views/{id}',
|
||||
delete: '/projects/{projectId}/views/{id}',
|
||||
})
|
||||
}
|
||||
|
||||
modelFactory(data: Partial<IAbstract>): ProjectViewModel {
|
||||
return new ProjectViewModel(data)
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ import AbstractService from '@/services/abstractService'
|
||||
import TaskModel from '@/models/task'
|
||||
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import BucketModel from '@/models/bucket'
|
||||
|
||||
export interface TaskFilterParams {
|
||||
sort_by: ('start_date' | 'end_date' | 'due_date' | 'done' | 'id' | 'position' | 'kanban_position')[],
|
||||
@ -27,11 +28,15 @@ export function getDefaultTaskFilterParams(): TaskFilterParams {
|
||||
export default class TaskCollectionService extends AbstractService<ITask> {
|
||||
constructor() {
|
||||
super({
|
||||
getAll: '/projects/{projectId}/tasks',
|
||||
getAll: '/projects/{projectId}/views/{viewId}/tasks',
|
||||
})
|
||||
}
|
||||
|
||||
modelFactory(data) {
|
||||
// FIXME: There must be a better way for this…
|
||||
if (typeof data.project_view_id !== 'undefined') {
|
||||
return new BucketModel(data)
|
||||
}
|
||||
return new TaskModel(data)
|
||||
}
|
||||
}
|
15
frontend/src/services/taskPosition.ts
Normal file
15
frontend/src/services/taskPosition.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import AbstractService from '@/services/abstractService'
|
||||
import type {ITaskPosition} from '@/modelTypes/ITaskPosition'
|
||||
import TaskPositionModel from '@/models/taskPosition'
|
||||
|
||||
export default class TaskPositionService extends AbstractService<ITaskPosition> {
|
||||
constructor() {
|
||||
super({
|
||||
update: '/tasks/{taskId}/position',
|
||||
})
|
||||
}
|
||||
|
||||
modelFactory(data: Partial<ITaskPosition>) {
|
||||
return new TaskPositionModel(data)
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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]
|
80
frontend/src/views/project/ProjectView.vue
Normal file
80
frontend/src/views/project/ProjectView.vue
Normal file
@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, watch} from 'vue'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
|
||||
import ProjectList from '@/components/project/views/ProjectList.vue'
|
||||
import ProjectGantt from '@/components/project/views/ProjectGantt.vue'
|
||||
import ProjectTable from '@/components/project/views/ProjectTable.vue'
|
||||
import ProjectKanban from '@/components/project/views/ProjectKanban.vue'
|
||||
|
||||
const {
|
||||
projectId,
|
||||
viewId,
|
||||
} = defineProps<{
|
||||
projectId: number,
|
||||
viewId: number,
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const currentView = computed(() => {
|
||||
const project = projectStore.projects[projectId]
|
||||
|
||||
return project?.views.find(v => v.id === viewId)
|
||||
})
|
||||
|
||||
function redirectToFirstViewIfNecessary() {
|
||||
if (viewId === 0) {
|
||||
// Ideally, we would do that in the router redirect, but the projects (and therefore, the views)
|
||||
// are not always loaded then.
|
||||
const firstViewId = projectStore.projects[projectId]?.views[0].id
|
||||
if (firstViewId) {
|
||||
router.replace({
|
||||
name: 'project.view',
|
||||
params: {
|
||||
projectId,
|
||||
viewId: firstViewId,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => viewId,
|
||||
redirectToFirstViewIfNecessary,
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => projectStore.projects[projectId],
|
||||
redirectToFirstViewIfNecessary,
|
||||
)
|
||||
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProjectList
|
||||
v-if="currentView?.viewKind === 'list'"
|
||||
:project-id="projectId"
|
||||
:view-id
|
||||
/>
|
||||
<ProjectGantt
|
||||
v-if="currentView?.viewKind === 'gantt'"
|
||||
:route
|
||||
:view-id
|
||||
/>
|
||||
<ProjectTable
|
||||
v-if="currentView?.viewKind === 'table'"
|
||||
:project-id="projectId"
|
||||
:view-id
|
||||
/>
|
||||
<ProjectKanban
|
||||
v-if="currentView?.viewKind === 'kanban'"
|
||||
:project-id="projectId"
|
||||
:view-id
|
||||
/>
|
||||
</template>
|
@ -12,10 +12,12 @@ import type {TaskFilterParams} from '@/services/taskCollection'
|
||||
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {DateKebab} from '@/types/DateKebab'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
|
||||
// convenient internal filter object
|
||||
export interface GanttFilters {
|
||||
projectId: IProject['id']
|
||||
viewId: IProjectView['id'],
|
||||
dateFrom: DateISO
|
||||
dateTo: DateISO
|
||||
showTasksWithoutDates: boolean
|
||||
@ -41,6 +43,7 @@ function ganttRouteToFilters(route: Partial<RouteLocationNormalized>): GanttFilt
|
||||
const ganttRoute = route
|
||||
return {
|
||||
projectId: Number(ganttRoute.params?.projectId),
|
||||
viewId: Number(ganttRoute.params?.viewId),
|
||||
dateFrom: parseDateProp(ganttRoute.query?.dateFrom as DateKebab) || getDefaultDateFrom(),
|
||||
dateTo: parseDateProp(ganttRoute.query?.dateTo as DateKebab) || getDefaultDateTo(),
|
||||
showTasksWithoutDates: parseBooleanProp(ganttRoute.query?.showTasksWithoutDates as string) || DEFAULT_SHOW_TASKS_WITHOUT_DATES,
|
||||
@ -69,8 +72,11 @@ function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw {
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'project.gantt',
|
||||
params: {projectId: filters.projectId},
|
||||
name: 'project.view',
|
||||
params: {
|
||||
projectId: filters.projectId,
|
||||
viewId: filters.viewId,
|
||||
},
|
||||
query,
|
||||
}
|
||||
}
|
||||
@ -88,7 +94,7 @@ export type UseGanttFiltersReturn =
|
||||
ReturnType<typeof useRouteFilters<GanttFilters>> &
|
||||
ReturnType<typeof useGanttTaskList<GanttFilters>>
|
||||
|
||||
export function useGanttFilters(route: Ref<RouteLocationNormalized>): UseGanttFiltersReturn {
|
||||
export function useGanttFilters(route: Ref<RouteLocationNormalized>, viewId: IProjectView['id']): UseGanttFiltersReturn {
|
||||
const {
|
||||
filters,
|
||||
hasDefaultFilters,
|
||||
@ -98,7 +104,7 @@ export function useGanttFilters(route: Ref<RouteLocationNormalized>): UseGanttFi
|
||||
ganttGetDefaultFilters,
|
||||
ganttRouteToFilters,
|
||||
ganttFiltersToRoute,
|
||||
['project.gantt'],
|
||||
['project.view'],
|
||||
)
|
||||
|
||||
const {
|
||||
@ -108,7 +114,7 @@ export function useGanttFilters(route: Ref<RouteLocationNormalized>): UseGanttFi
|
||||
isLoading,
|
||||
addTask,
|
||||
updateTask,
|
||||
} = useGanttTaskList<GanttFilters>(filters, ganttFiltersToApiParams)
|
||||
} = useGanttTaskList<GanttFilters>(filters, ganttFiltersToApiParams, viewId)
|
||||
|
||||
return {
|
||||
filters,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {computed, ref, shallowReactive, watch, type Ref} from 'vue'
|
||||
import {computed, ref, type Ref, shallowReactive, watch} from 'vue'
|
||||
import {klona} from 'klona/lite'
|
||||
|
||||
import type {Filters} from '@/composables/useRouteFilters'
|
||||
@ -10,16 +10,15 @@ import TaskService from '@/services/task'
|
||||
import TaskModel from '@/models/task'
|
||||
import {error, success} from '@/message'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
|
||||
// FIXME: unify with general `useTaskList`
|
||||
export function useGanttTaskList<F extends Filters>(
|
||||
filters: Ref<F>,
|
||||
filterToApiParams: (filters: F) => TaskFilterParams,
|
||||
options: {
|
||||
loadAll?: boolean,
|
||||
} = {
|
||||
loadAll: true,
|
||||
}) {
|
||||
viewId: IProjectView['id'],
|
||||
loadAll: boolean = true,
|
||||
) {
|
||||
const taskCollectionService = shallowReactive(new TaskCollectionService())
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
const authStore = useAuthStore()
|
||||
@ -29,13 +28,13 @@ export function useGanttTaskList<F extends Filters>(
|
||||
const tasks = ref<Map<ITask['id'], ITask>>(new Map())
|
||||
|
||||
async function fetchTasks(params: TaskFilterParams, page = 1): Promise<ITask[]> {
|
||||
|
||||
if(params.filter_timezone === '') {
|
||||
|
||||
if (params.filter_timezone === '') {
|
||||
params.filter_timezone = authStore.settings.timezone
|
||||
}
|
||||
|
||||
const tasks = await taskCollectionService.getAll({projectId: filters.value.projectId}, params, page) as ITask[]
|
||||
if (options.loadAll && page < taskCollectionService.totalPages) {
|
||||
const tasks = await taskCollectionService.getAll({projectId: filters.value.projectId, viewId}, params, page) as ITask[]
|
||||
if (loadAll && page < taskCollectionService.totalPages) {
|
||||
const nextTasks = await fetchTasks(params, page + 1)
|
||||
return tasks.concat(nextTasks)
|
||||
}
|
||||
|
178
frontend/src/views/project/settings/views.vue
Normal file
178
frontend/src/views/project/settings/views.vue
Normal file
@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import {computed, ref} from 'vue'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import ProjectViewModel from '@/models/projectView'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
import ViewEditForm from '@/components/project/views/viewEditForm.vue'
|
||||
import ProjectViewService from '@/services/projectViews'
|
||||
import XButton from '@/components/input/button.vue'
|
||||
import {error, success} from '@/message'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
const {
|
||||
projectId,
|
||||
} = defineProps<{
|
||||
projectId: number
|
||||
}>()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const {t} = useI18n()
|
||||
|
||||
const views = computed(() => projectStore.projects[projectId]?.views)
|
||||
const showCreateForm = ref(false)
|
||||
|
||||
const projectViewService = ref(new ProjectViewService())
|
||||
const newView = ref<IProjectView>(new ProjectViewModel({}))
|
||||
const viewIdToDelete = ref<number | null>(null)
|
||||
const showDeleteModal = ref(false)
|
||||
const viewToEdit = ref<IProjectView | null>(null)
|
||||
|
||||
async function createView() {
|
||||
if (!showCreateForm.value) {
|
||||
showCreateForm.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (newView.value.title === '') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
newView.value.bucketConfigurationMode = newView.value.viewKind === 'kanban'
|
||||
? newView.value.bucketConfigurationMode
|
||||
: 'none'
|
||||
newView.value.projectId = projectId
|
||||
|
||||
const result: IProjectView = await projectViewService.value.create(newView.value)
|
||||
success({message: t('project.views.createSuccess')})
|
||||
showCreateForm.value = false
|
||||
projectStore.setProjectView(result)
|
||||
newView.value = new ProjectViewModel({})
|
||||
} catch (e) {
|
||||
error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteView() {
|
||||
if (!viewIdToDelete.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await projectViewService.value.delete(new ProjectViewModel({
|
||||
id: viewIdToDelete.value,
|
||||
projectId,
|
||||
}))
|
||||
|
||||
projectStore.removeProjectView(projectId, viewIdToDelete.value)
|
||||
|
||||
showDeleteModal.value = false
|
||||
}
|
||||
|
||||
async function saveView() {
|
||||
if (viewToEdit.value?.viewKind !== 'kanban') {
|
||||
viewToEdit.value.bucketConfigurationMode = 'none'
|
||||
}
|
||||
const result = await projectViewService.value.update(viewToEdit.value)
|
||||
projectStore.setProjectView(result)
|
||||
viewToEdit.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CreateEdit
|
||||
:title="$t('project.views.header')"
|
||||
:primary-label="$t('misc.save')"
|
||||
>
|
||||
<ViewEditForm
|
||||
v-if="showCreateForm"
|
||||
v-model="newView"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div class="is-flex is-justify-content-end">
|
||||
<XButton
|
||||
:loading="projectViewService.loading"
|
||||
@click="createView"
|
||||
>
|
||||
{{ $t('project.views.create') }}
|
||||
</XButton>
|
||||
</div>
|
||||
|
||||
<table
|
||||
v-if="views?.length > 0"
|
||||
class="table has-actions is-striped is-hoverable is-fullwidth"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('project.views.title') }}</th>
|
||||
<th>{{ $t('project.views.kind') }}</th>
|
||||
<th class="has-text-right">
|
||||
{{ $t('project.views.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="v in views"
|
||||
:key="v.id"
|
||||
>
|
||||
<template v-if="viewToEdit !== null && viewToEdit.id === v.id">
|
||||
<td colspan="3">
|
||||
<ViewEditForm
|
||||
v-model="viewToEdit"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div class="is-flex is-justify-content-end">
|
||||
<XButton
|
||||
variant="tertiary"
|
||||
class="mr-2"
|
||||
@click="viewToEdit = null"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
:loading="projectViewService.loading"
|
||||
@click="saveView"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</XButton>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td>{{ v.title }}</td>
|
||||
<td>{{ v.viewKind }}</td>
|
||||
<td class="has-text-right">
|
||||
<XButton
|
||||
class="is-danger mr-2"
|
||||
icon="trash-alt"
|
||||
@click="() => {
|
||||
viewIdToDelete = v.id
|
||||
showDeleteModal = true
|
||||
}"
|
||||
/>
|
||||
<XButton
|
||||
icon="pen"
|
||||
@click="viewToEdit = {...v}"
|
||||
/>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</CreateEdit>
|
||||
|
||||
<modal
|
||||
:enabled="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="deleteView"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('project.views.delete') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>{{ $t('project.views.deleteText') }}</p>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
@ -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) {
|
||||
|
Reference in New Issue
Block a user