Merge branch 'main' into feature/login-improvements
This commit is contained in:
commit
a8ac2fc2dd
72 changed files with 2486 additions and 2407 deletions
28
.drone.yml
28
.drone.yml
|
@ -116,36 +116,16 @@ steps:
|
||||||
YARN_CACHE_FOLDER: .cache/yarn/
|
YARN_CACHE_FOLDER: .cache/yarn/
|
||||||
CYPRESS_CACHE_FOLDER: .cache/cypress/
|
CYPRESS_CACHE_FOLDER: .cache/cypress/
|
||||||
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
|
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
|
||||||
|
CYPRESS_RECORD_KEY:
|
||||||
|
from_secret: cypress_project_key
|
||||||
commands:
|
commands:
|
||||||
- sed -i 's/localhost/api/g' dist/index.html
|
- sed -i 's/localhost/api/g' dist/index.html
|
||||||
- yarn serve:dist & npx wait-on http://localhost:5000
|
- yarn serve:dist & npx wait-on http://localhost:5000
|
||||||
- yarn test:frontend --browser chrome
|
- yarn test:frontend --browser chrome --record
|
||||||
depends_on:
|
depends_on:
|
||||||
- dependencies
|
- dependencies
|
||||||
- build-prod
|
- build-prod
|
||||||
|
|
||||||
- name: upload-test-results
|
|
||||||
image: plugins/s3
|
|
||||||
pull: true
|
|
||||||
settings:
|
|
||||||
bucket: drone-test-results
|
|
||||||
access_key:
|
|
||||||
from_secret: test_results_aws_access_key_id
|
|
||||||
secret_key:
|
|
||||||
from_secret: test_results_aws_secret_access_key
|
|
||||||
endpoint: https://s3.fr-par.scw.cloud
|
|
||||||
region: fr-par
|
|
||||||
path_style: true
|
|
||||||
source: cypress/screenshots/**/**/*
|
|
||||||
strip_prefix: cypress/screenshots/
|
|
||||||
target: /${DRONE_REPO}/${DRONE_PULL_REQUEST}_${DRONE_BRANCH}/${DRONE_BUILD_NUMBER}/
|
|
||||||
depends_on:
|
|
||||||
- test-frontend
|
|
||||||
when:
|
|
||||||
status:
|
|
||||||
- failure
|
|
||||||
- success
|
|
||||||
|
|
||||||
- name: deploy-preview
|
- name: deploy-preview
|
||||||
image: node:16
|
image: node:16
|
||||||
pull: true
|
pull: true
|
||||||
|
@ -665,6 +645,6 @@ steps:
|
||||||
from_secret: crowdin_key
|
from_secret: crowdin_key
|
||||||
---
|
---
|
||||||
kind: signature
|
kind: signature
|
||||||
hmac: 188ee90100c5fc5922a445e531e7a47453121edddb2a64a182eb23ed2bf602de
|
hmac: 997e1badebe484ac29557c4af356e63db4d3d57f3d32e92d482f117f8cec64da
|
||||||
|
|
||||||
...
|
...
|
||||||
|
|
|
@ -7,5 +7,6 @@
|
||||||
"video": false,
|
"video": false,
|
||||||
"retries": {
|
"retries": {
|
||||||
"runMode": 2
|
"runMode": 2
|
||||||
}
|
},
|
||||||
|
"projectId": "181c7x"
|
||||||
}
|
}
|
||||||
|
|
56
cypress/integration/list/list-history.spec.js
Normal file
56
cypress/integration/list/list-history.spec.js
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import {ListFactory} from '../../factories/list'
|
||||||
|
|
||||||
|
import '../../support/authenticateUser'
|
||||||
|
import {prepareLists} from './prepareLists'
|
||||||
|
|
||||||
|
describe('List History', () => {
|
||||||
|
prepareLists()
|
||||||
|
|
||||||
|
it('should show a list history on the home page', () => {
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces')
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/lists/*').as('loadList')
|
||||||
|
|
||||||
|
const lists = ListFactory.create(6)
|
||||||
|
|
||||||
|
cy.visit('/')
|
||||||
|
cy.wait('@loadNamespaces')
|
||||||
|
cy.get('body')
|
||||||
|
.should('not.contain', 'Last viewed')
|
||||||
|
|
||||||
|
cy.visit(`/lists/${lists[0].id}`)
|
||||||
|
cy.wait('@loadNamespaces')
|
||||||
|
cy.wait('@loadList')
|
||||||
|
cy.visit(`/lists/${lists[1].id}`)
|
||||||
|
cy.wait('@loadNamespaces')
|
||||||
|
cy.wait('@loadList')
|
||||||
|
cy.visit(`/lists/${lists[2].id}`)
|
||||||
|
cy.wait('@loadNamespaces')
|
||||||
|
cy.wait('@loadList')
|
||||||
|
cy.visit(`/lists/${lists[3].id}`)
|
||||||
|
cy.wait('@loadNamespaces')
|
||||||
|
cy.wait('@loadList')
|
||||||
|
cy.visit(`/lists/${lists[4].id}`)
|
||||||
|
cy.wait('@loadNamespaces')
|
||||||
|
cy.wait('@loadList')
|
||||||
|
cy.visit(`/lists/${lists[5].id}`)
|
||||||
|
cy.wait('@loadNamespaces')
|
||||||
|
cy.wait('@loadList')
|
||||||
|
|
||||||
|
// cy.visit('/')
|
||||||
|
// cy.wait('@loadNamespaces')
|
||||||
|
// Not using cy.visit here to work around the redirect issue fixed in #1337
|
||||||
|
cy.get('nav.menu.top-menu a')
|
||||||
|
.contains('Overview')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('body')
|
||||||
|
.should('contain', 'Last viewed')
|
||||||
|
cy.get('.list-cards-wrapper-2-rows')
|
||||||
|
.should('not.contain', lists[0].title)
|
||||||
|
.should('contain', lists[1].title)
|
||||||
|
.should('contain', lists[2].title)
|
||||||
|
.should('contain', lists[3].title)
|
||||||
|
.should('contain', lists[4].title)
|
||||||
|
.should('contain', lists[5].title)
|
||||||
|
})
|
||||||
|
})
|
76
cypress/integration/list/list-view-gantt.spec.js
Normal file
76
cypress/integration/list/list-view-gantt.spec.js
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import {formatISO, format} from 'date-fns'
|
||||||
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
import {prepareLists} from './prepareLists'
|
||||||
|
|
||||||
|
import '../../support/authenticateUser'
|
||||||
|
|
||||||
|
describe('List View Gantt', () => {
|
||||||
|
prepareLists()
|
||||||
|
|
||||||
|
it('Hides tasks with no dates', () => {
|
||||||
|
const tasks = TaskFactory.create(1)
|
||||||
|
cy.visit('/lists/1/gantt')
|
||||||
|
|
||||||
|
cy.get('.gantt-chart .tasks')
|
||||||
|
.should('not.contain', tasks[0].title)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Shows tasks from the current and next month', () => {
|
||||||
|
const now = new Date()
|
||||||
|
const nextMonth = now
|
||||||
|
nextMonth.setDate(1)
|
||||||
|
nextMonth.setMonth(now.getMonth() + 1)
|
||||||
|
|
||||||
|
cy.visit('/lists/1/gantt')
|
||||||
|
|
||||||
|
cy.get('.gantt-chart .months')
|
||||||
|
.should('contain', format(now, 'MMMM'))
|
||||||
|
.should('contain', format(nextMonth, 'MMMM'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Shows tasks with dates', () => {
|
||||||
|
const now = new Date()
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
start_date: formatISO(now),
|
||||||
|
end_date: formatISO(now.setDate(now.getDate() + 4))
|
||||||
|
})
|
||||||
|
cy.visit('/lists/1/gantt')
|
||||||
|
|
||||||
|
cy.get('.gantt-chart .tasks')
|
||||||
|
.should('not.be.empty')
|
||||||
|
cy.get('.gantt-chart .tasks')
|
||||||
|
.should('contain', tasks[0].title)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Shows tasks with no dates after enabling them', () => {
|
||||||
|
TaskFactory.create(1, {
|
||||||
|
start_date: null,
|
||||||
|
end_date: null,
|
||||||
|
})
|
||||||
|
cy.visit('/lists/1/gantt')
|
||||||
|
|
||||||
|
cy.get('.gantt-options .fancycheckbox')
|
||||||
|
.contains('Show tasks which don\'t have dates set')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.gantt-chart .tasks')
|
||||||
|
.should('not.be.empty')
|
||||||
|
cy.get('.gantt-chart .tasks .task.nodate')
|
||||||
|
.should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Drags a task around', () => {
|
||||||
|
const now = new Date()
|
||||||
|
TaskFactory.create(1, {
|
||||||
|
start_date: formatISO(now),
|
||||||
|
end_date: formatISO(now.setDate(now.getDate() + 4))
|
||||||
|
})
|
||||||
|
cy.visit('/lists/1/gantt')
|
||||||
|
|
||||||
|
cy.get('.gantt-chart .tasks .task')
|
||||||
|
.first()
|
||||||
|
.trigger('mousedown', {which: 1})
|
||||||
|
.trigger('mousemove', {clientX: 500, clientY: 0})
|
||||||
|
.trigger('mouseup', {force: true})
|
||||||
|
})
|
||||||
|
})
|
196
cypress/integration/list/list-view-kanban.spec.js
Normal file
196
cypress/integration/list/list-view-kanban.spec.js
Normal file
|
@ -0,0 +1,196 @@
|
||||||
|
import {BucketFactory} from '../../factories/bucket'
|
||||||
|
import {ListFactory} from '../../factories/list'
|
||||||
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
import {prepareLists} from './prepareLists'
|
||||||
|
|
||||||
|
import '../../support/authenticateUser'
|
||||||
|
|
||||||
|
describe('List View Kanban', () => {
|
||||||
|
let buckets
|
||||||
|
prepareLists()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
buckets = BucketFactory.create(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Shows all buckets with their tasks', () => {
|
||||||
|
const data = TaskFactory.create(10, {
|
||||||
|
list_id: 1,
|
||||||
|
bucket_id: 1,
|
||||||
|
})
|
||||||
|
cy.visit('/lists/1/kanban')
|
||||||
|
|
||||||
|
cy.get('.kanban .bucket .title')
|
||||||
|
.contains(buckets[0].title)
|
||||||
|
.should('exist')
|
||||||
|
cy.get('.kanban .bucket .title')
|
||||||
|
.contains(buckets[1].title)
|
||||||
|
.should('exist')
|
||||||
|
cy.get('.kanban .bucket')
|
||||||
|
.first()
|
||||||
|
.should('contain', data[0].title)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Can add a new task to a bucket', () => {
|
||||||
|
TaskFactory.create(2, {
|
||||||
|
list_id: 1,
|
||||||
|
bucket_id: 1,
|
||||||
|
})
|
||||||
|
cy.visit('/lists/1/kanban')
|
||||||
|
|
||||||
|
cy.getSettled('.kanban .bucket')
|
||||||
|
.contains(buckets[0].title)
|
||||||
|
.get('.bucket-footer .button')
|
||||||
|
.contains('Add another task')
|
||||||
|
.click()
|
||||||
|
cy.get('.kanban .bucket')
|
||||||
|
.contains(buckets[0].title)
|
||||||
|
.get('.bucket-footer .field .control input.input')
|
||||||
|
.type('New Task{enter}')
|
||||||
|
|
||||||
|
cy.get('.kanban .bucket')
|
||||||
|
.first()
|
||||||
|
.should('contain', 'New Task')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Can create a new bucket', () => {
|
||||||
|
cy.visit('/lists/1/kanban')
|
||||||
|
|
||||||
|
cy.get('.kanban .bucket.new-bucket .button')
|
||||||
|
.click()
|
||||||
|
cy.get('.kanban .bucket.new-bucket input.input')
|
||||||
|
.type('New Bucket{enter}')
|
||||||
|
|
||||||
|
cy.wait(1000) // Wait for the request to finish
|
||||||
|
cy.get('.kanban .bucket .title')
|
||||||
|
.contains('New Bucket')
|
||||||
|
.should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Can set a bucket limit', () => {
|
||||||
|
cy.visit('/lists/1/kanban')
|
||||||
|
|
||||||
|
cy.getSettled('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
|
||||||
|
.contains('Limit: Not Set')
|
||||||
|
.click()
|
||||||
|
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
|
||||||
|
.first()
|
||||||
|
.type(3)
|
||||||
|
cy.get('[data-cy="setBucketLimit"]')
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.kanban .bucket .bucket-header span.limit')
|
||||||
|
.contains('0/3')
|
||||||
|
.should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Can rename a bucket', () => {
|
||||||
|
cy.visit('/lists/1/kanban')
|
||||||
|
|
||||||
|
cy.getSettled('.kanban .bucket .bucket-header .title')
|
||||||
|
.first()
|
||||||
|
.type('{selectall}New Bucket Title{enter}')
|
||||||
|
cy.get('.kanban .bucket .bucket-header .title')
|
||||||
|
.first()
|
||||||
|
.should('contain', 'New Bucket Title')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Can delete a bucket', () => {
|
||||||
|
cy.visit('/lists/1/kanban')
|
||||||
|
|
||||||
|
cy.getSettled('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
|
||||||
|
.contains('Delete')
|
||||||
|
.click()
|
||||||
|
cy.get('.modal-mask .modal-container .modal-content .header')
|
||||||
|
.should('contain', 'Delete the bucket')
|
||||||
|
cy.get('.modal-mask .modal-container .modal-content .actions .button')
|
||||||
|
.contains('Do it!')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.kanban .bucket .title')
|
||||||
|
.contains(buckets[0].title)
|
||||||
|
.should('not.exist')
|
||||||
|
cy.get('.kanban .bucket .title')
|
||||||
|
.contains(buckets[1].title)
|
||||||
|
.should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Can drag tasks around', () => {
|
||||||
|
const tasks = TaskFactory.create(2, {
|
||||||
|
list_id: 1,
|
||||||
|
bucket_id: 1,
|
||||||
|
})
|
||||||
|
cy.visit('/lists/1/kanban')
|
||||||
|
|
||||||
|
cy.getSettled('.kanban .bucket .tasks .task')
|
||||||
|
.contains(tasks[0].title)
|
||||||
|
.first()
|
||||||
|
.drag('.kanban .bucket:nth-child(2) .tasks .dropper')
|
||||||
|
|
||||||
|
cy.get('.kanban .bucket:nth-child(2) .tasks')
|
||||||
|
.should('contain', tasks[0].title)
|
||||||
|
cy.get('.kanban .bucket:nth-child(1) .tasks')
|
||||||
|
.should('not.contain', tasks[0].title)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should navigate to the task when the task card is clicked', () => {
|
||||||
|
const tasks = TaskFactory.create(5, {
|
||||||
|
id: '{increment}',
|
||||||
|
list_id: 1,
|
||||||
|
bucket_id: 1,
|
||||||
|
})
|
||||||
|
cy.visit('/lists/1/kanban')
|
||||||
|
|
||||||
|
cy.getSettled('.kanban .bucket .tasks .task')
|
||||||
|
.contains(tasks[0].title)
|
||||||
|
.should('be.visible')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.url()
|
||||||
|
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should remove a task from the kanban board when moving it to another list', () => {
|
||||||
|
const lists = ListFactory.create(2)
|
||||||
|
BucketFactory.create(2, {
|
||||||
|
list_id: '{increment}',
|
||||||
|
})
|
||||||
|
const tasks = TaskFactory.create(5, {
|
||||||
|
id: '{increment}',
|
||||||
|
list_id: 1,
|
||||||
|
bucket_id: 1,
|
||||||
|
})
|
||||||
|
const task = tasks[0]
|
||||||
|
cy.visit('/lists/1/kanban')
|
||||||
|
|
||||||
|
cy.getSettled('.kanban .bucket .tasks .task')
|
||||||
|
.contains(task.title)
|
||||||
|
.should('be.visible')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.task-view .action-buttons .button', { timeout: 3000 })
|
||||||
|
.contains('Move task')
|
||||||
|
.click()
|
||||||
|
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
|
||||||
|
.type(`${lists[1].title}{enter}`)
|
||||||
|
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
|
||||||
|
// presses enter and we can't simulate pressing on enter to select the item.
|
||||||
|
cy.get('.task-view .content.details .field .multiselect.control .search-results')
|
||||||
|
.children()
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.global-notification', { timeout: 1000 })
|
||||||
|
.should('contain', 'Success')
|
||||||
|
cy.go('back')
|
||||||
|
cy.get('.kanban .bucket')
|
||||||
|
.should('not.contain', task.title)
|
||||||
|
})
|
||||||
|
})
|
97
cypress/integration/list/list-view-list.spec.js
Normal file
97
cypress/integration/list/list-view-list.spec.js
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
import {UserListFactory} from '../../factories/users_list'
|
||||||
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
import {UserFactory} from '../../factories/user'
|
||||||
|
import {ListFactory} from '../../factories/list'
|
||||||
|
import {prepareLists} from './prepareLists'
|
||||||
|
|
||||||
|
import '../../support/authenticateUser'
|
||||||
|
|
||||||
|
describe('List View List', () => {
|
||||||
|
prepareLists()
|
||||||
|
|
||||||
|
it('Should be an empty list', () => {
|
||||||
|
cy.visit('/lists/1')
|
||||||
|
cy.url()
|
||||||
|
.should('contain', '/lists/1/list')
|
||||||
|
cy.get('.list-title h1')
|
||||||
|
.should('contain', 'First List')
|
||||||
|
cy.get('.list-title .dropdown')
|
||||||
|
.should('exist')
|
||||||
|
cy.get('p')
|
||||||
|
.contains('This list is currently empty.')
|
||||||
|
.should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should navigate to the task when the title is clicked', () => {
|
||||||
|
const tasks = TaskFactory.create(5, {
|
||||||
|
id: '{increment}',
|
||||||
|
list_id: 1,
|
||||||
|
})
|
||||||
|
cy.visit('/lists/1/list')
|
||||||
|
|
||||||
|
cy.get('.tasks .task .tasktext')
|
||||||
|
.contains(tasks[0].title)
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.url()
|
||||||
|
.should('contain', `/tasks/${tasks[0].id}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not see any elements for a list which is shared read only', () => {
|
||||||
|
UserFactory.create(2)
|
||||||
|
UserListFactory.create(1, {
|
||||||
|
list_id: 2,
|
||||||
|
user_id: 1,
|
||||||
|
right: 0,
|
||||||
|
})
|
||||||
|
const lists = ListFactory.create(2, {
|
||||||
|
owner_id: '{increment}',
|
||||||
|
namespace_id: '{increment}',
|
||||||
|
})
|
||||||
|
cy.visit(`/lists/${lists[1].id}/`)
|
||||||
|
|
||||||
|
cy.get('.list-title a.icon')
|
||||||
|
.should('not.exist')
|
||||||
|
cy.get('input.input[placeholder="Add a new task..."')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should only show the color of a list in the navigation and not in the list view', () => {
|
||||||
|
const lists = ListFactory.create(1, {
|
||||||
|
hex_color: '00db60',
|
||||||
|
})
|
||||||
|
TaskFactory.create(10, {
|
||||||
|
list_id: lists[0].id,
|
||||||
|
})
|
||||||
|
cy.visit(`/lists/${lists[0].id}/`)
|
||||||
|
|
||||||
|
cy.get('.menu-list li .list-menu-link .color-bubble')
|
||||||
|
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
|
||||||
|
cy.get('.tasks-container .tasks .color-bubble')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should paginate for > 50 tasks', () => {
|
||||||
|
const tasks = TaskFactory.create(100, {
|
||||||
|
id: '{increment}',
|
||||||
|
title: i => `task${i}`,
|
||||||
|
list_id: 1,
|
||||||
|
})
|
||||||
|
cy.visit('/lists/1/list')
|
||||||
|
|
||||||
|
cy.get('.tasks-container .tasks')
|
||||||
|
.should('contain', tasks[99].title)
|
||||||
|
|
||||||
|
cy.get('.card-content .pagination .pagination-link')
|
||||||
|
.contains('2')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.url()
|
||||||
|
.should('contain', '?page=2')
|
||||||
|
cy.get('.tasks-container .tasks')
|
||||||
|
.should('contain', tasks[1].title)
|
||||||
|
cy.get('.tasks-container .tasks')
|
||||||
|
.should('not.contain', tasks[99].title)
|
||||||
|
})
|
||||||
|
})
|
52
cypress/integration/list/list-view-table.spec.js
Normal file
52
cypress/integration/list/list-view-table.spec.js
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
|
||||||
|
import '../../support/authenticateUser'
|
||||||
|
|
||||||
|
describe('List View Table', () => {
|
||||||
|
it('Should show a table with tasks', () => {
|
||||||
|
const tasks = TaskFactory.create(1)
|
||||||
|
cy.visit('/lists/1/table')
|
||||||
|
|
||||||
|
cy.get('.list-table table.table')
|
||||||
|
.should('exist')
|
||||||
|
cy.get('.list-table table.table')
|
||||||
|
.should('contain', tasks[0].title)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should have working column switches', () => {
|
||||||
|
TaskFactory.create(1)
|
||||||
|
cy.visit('/lists/1/table')
|
||||||
|
|
||||||
|
cy.get('.list-table .filter-container .items .button')
|
||||||
|
.contains('Columns')
|
||||||
|
.click()
|
||||||
|
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
|
||||||
|
.contains('Priority')
|
||||||
|
.click()
|
||||||
|
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
|
||||||
|
.contains('Done')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.list-table table.table th')
|
||||||
|
.contains('Priority')
|
||||||
|
.should('exist')
|
||||||
|
cy.get('.list-table table.table th')
|
||||||
|
.contains('Done')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should navigate to the task when the title is clicked', () => {
|
||||||
|
const tasks = TaskFactory.create(5, {
|
||||||
|
id: '{increment}',
|
||||||
|
list_id: 1,
|
||||||
|
})
|
||||||
|
cy.visit('/lists/1/table')
|
||||||
|
|
||||||
|
cy.get('.list-table table.table')
|
||||||
|
.contains(tasks[0].title)
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.url()
|
||||||
|
.should('contain', `/tasks/${tasks[0].id}`)
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,25 +1,11 @@
|
||||||
import {formatISO, format} from 'date-fns'
|
|
||||||
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
import {TaskFactory} from '../../factories/task'
|
||||||
import {ListFactory} from '../../factories/list'
|
import {prepareLists} from './prepareLists'
|
||||||
import {UserListFactory} from '../../factories/users_list'
|
|
||||||
import {UserFactory} from '../../factories/user'
|
|
||||||
import {NamespaceFactory} from '../../factories/namespace'
|
|
||||||
import {BucketFactory} from '../../factories/bucket'
|
|
||||||
|
|
||||||
import '../../support/authenticateUser'
|
import '../../support/authenticateUser'
|
||||||
|
|
||||||
describe('Lists', () => {
|
describe('Lists', () => {
|
||||||
let lists
|
let lists
|
||||||
|
prepareLists((newLists) => (lists = newLists))
|
||||||
beforeEach(() => {
|
|
||||||
UserFactory.create(1)
|
|
||||||
NamespaceFactory.create(1)
|
|
||||||
lists = ListFactory.create(1, {
|
|
||||||
title: 'First List'
|
|
||||||
})
|
|
||||||
TaskFactory.truncate()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should create a new list', () => {
|
it('Should create a new list', () => {
|
||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
|
@ -29,7 +15,7 @@ describe('Lists', () => {
|
||||||
.contains('New list')
|
.contains('New list')
|
||||||
.click()
|
.click()
|
||||||
cy.url()
|
cy.url()
|
||||||
.should('contain', '/namespaces/1/list')
|
.should('contain', '/lists/new/1')
|
||||||
cy.get('.card-header-title')
|
cy.get('.card-header-title')
|
||||||
.contains('New list')
|
.contains('New list')
|
||||||
cy.get('input.input')
|
cy.get('input.input')
|
||||||
|
@ -56,7 +42,7 @@ describe('Lists', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should rename the list in all places', () => {
|
it('Should rename the list in all places', () => {
|
||||||
const tasks = TaskFactory.create(5, {
|
TaskFactory.create(5, {
|
||||||
id: '{increment}',
|
id: '{increment}',
|
||||||
list_id: 1,
|
list_id: 1,
|
||||||
})
|
})
|
||||||
|
@ -112,429 +98,4 @@ describe('Lists', () => {
|
||||||
cy.location('pathname')
|
cy.location('pathname')
|
||||||
.should('equal', '/')
|
.should('equal', '/')
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('List View', () => {
|
|
||||||
it('Should be an empty list', () => {
|
|
||||||
cy.visit('/lists/1')
|
|
||||||
cy.url()
|
|
||||||
.should('contain', '/lists/1/list')
|
|
||||||
cy.get('.list-title h1')
|
|
||||||
.should('contain', 'First List')
|
|
||||||
cy.get('.list-title .dropdown')
|
|
||||||
.should('exist')
|
|
||||||
cy.get('p')
|
|
||||||
.contains('This list is currently empty.')
|
|
||||||
.should('exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should navigate to the task when the title is clicked', () => {
|
|
||||||
const tasks = TaskFactory.create(5, {
|
|
||||||
id: '{increment}',
|
|
||||||
list_id: 1,
|
|
||||||
})
|
|
||||||
cy.visit('/lists/1/list')
|
|
||||||
|
|
||||||
cy.get('.tasks .task .tasktext')
|
|
||||||
.contains(tasks[0].title)
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.url()
|
|
||||||
.should('contain', `/tasks/${tasks[0].id}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should not see any elements for a list which is shared read only', () => {
|
|
||||||
UserFactory.create(2)
|
|
||||||
UserListFactory.create(1, {
|
|
||||||
list_id: 2,
|
|
||||||
user_id: 1,
|
|
||||||
right: 0,
|
|
||||||
})
|
|
||||||
const lists = ListFactory.create(2, {
|
|
||||||
owner_id: '{increment}',
|
|
||||||
namespace_id: '{increment}',
|
|
||||||
})
|
|
||||||
cy.visit(`/lists/${lists[1].id}/`)
|
|
||||||
|
|
||||||
cy.get('.list-title a.icon')
|
|
||||||
.should('not.exist')
|
|
||||||
cy.get('input.input[placeholder="Add a new task..."')
|
|
||||||
.should('not.exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should only show the color of a list in the navigation and not in the list view', () => {
|
|
||||||
const lists = ListFactory.create(1, {
|
|
||||||
hex_color: '00db60',
|
|
||||||
})
|
|
||||||
TaskFactory.create(10, {
|
|
||||||
list_id: lists[0].id,
|
|
||||||
})
|
|
||||||
cy.visit(`/lists/${lists[0].id}/`)
|
|
||||||
|
|
||||||
cy.get('.menu-list li .list-menu-link .color-bubble')
|
|
||||||
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
|
|
||||||
cy.get('.tasks-container .tasks .color-bubble')
|
|
||||||
.should('not.exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should paginate for > 50 tasks', () => {
|
|
||||||
const tasks = TaskFactory.create(100, {
|
|
||||||
id: '{increment}',
|
|
||||||
title: i => `task${i}`,
|
|
||||||
list_id: 1,
|
|
||||||
})
|
|
||||||
cy.visit('/lists/1/list')
|
|
||||||
|
|
||||||
cy.get('.tasks-container .tasks')
|
|
||||||
.should('contain', tasks[99].title)
|
|
||||||
|
|
||||||
cy.get('.card-content .pagination .pagination-link')
|
|
||||||
.contains('2')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.url()
|
|
||||||
.should('contain', '?page=2')
|
|
||||||
cy.get('.tasks-container .tasks')
|
|
||||||
.should('contain', tasks[1].title)
|
|
||||||
cy.get('.tasks-container .tasks')
|
|
||||||
.should('not.contain', tasks[99].title)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Table View', () => {
|
|
||||||
it('Should show a table with tasks', () => {
|
|
||||||
const tasks = TaskFactory.create(1)
|
|
||||||
cy.visit('/lists/1/table')
|
|
||||||
|
|
||||||
cy.get('.table-view table.table')
|
|
||||||
.should('exist')
|
|
||||||
cy.get('.table-view table.table')
|
|
||||||
.should('contain', tasks[0].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should have working column switches', () => {
|
|
||||||
TaskFactory.create(1)
|
|
||||||
cy.visit('/lists/1/table')
|
|
||||||
|
|
||||||
cy.get('.table-view .filter-container .items .button')
|
|
||||||
.contains('Columns')
|
|
||||||
.click()
|
|
||||||
cy.get('.table-view .filter-container .card.columns-filter .card-content .fancycheckbox .check')
|
|
||||||
.contains('Priority')
|
|
||||||
.click()
|
|
||||||
cy.get('.table-view .filter-container .card.columns-filter .card-content .fancycheckbox .check')
|
|
||||||
.contains('Done')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.table-view table.table th')
|
|
||||||
.contains('Priority')
|
|
||||||
.should('exist')
|
|
||||||
cy.get('.table-view table.table th')
|
|
||||||
.contains('Done')
|
|
||||||
.should('not.exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should navigate to the task when the title is clicked', () => {
|
|
||||||
const tasks = TaskFactory.create(5, {
|
|
||||||
id: '{increment}',
|
|
||||||
list_id: 1,
|
|
||||||
})
|
|
||||||
cy.visit('/lists/1/table')
|
|
||||||
|
|
||||||
cy.get('.table-view table.table')
|
|
||||||
.contains(tasks[0].title)
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.url()
|
|
||||||
.should('contain', `/tasks/${tasks[0].id}`)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Gantt View', () => {
|
|
||||||
it('Hides tasks with no dates', () => {
|
|
||||||
const tasks = TaskFactory.create(1)
|
|
||||||
cy.visit('/lists/1/gantt')
|
|
||||||
|
|
||||||
cy.get('.gantt-chart-container .gantt-chart .tasks')
|
|
||||||
.should('not.contain', tasks[0].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Shows tasks from the current and next month', () => {
|
|
||||||
const now = new Date()
|
|
||||||
const nextMonth = now
|
|
||||||
nextMonth.setDate(1)
|
|
||||||
nextMonth.setMonth(now.getMonth() + 1)
|
|
||||||
|
|
||||||
cy.visit('/lists/1/gantt')
|
|
||||||
|
|
||||||
cy.get('.gantt-chart-container .gantt-chart .months')
|
|
||||||
.should('contain', format(now, 'MMMM'))
|
|
||||||
.should('contain', format(nextMonth, 'MMMM'))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Shows tasks with dates', () => {
|
|
||||||
const now = new Date()
|
|
||||||
const tasks = TaskFactory.create(1, {
|
|
||||||
start_date: formatISO(now),
|
|
||||||
end_date: formatISO(now.setDate(now.getDate() + 4))
|
|
||||||
})
|
|
||||||
cy.visit('/lists/1/gantt')
|
|
||||||
|
|
||||||
cy.get('.gantt-chart-container .gantt-chart .tasks')
|
|
||||||
.should('not.be.empty')
|
|
||||||
cy.get('.gantt-chart-container .gantt-chart .tasks')
|
|
||||||
.should('contain', tasks[0].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Shows tasks with no dates after enabling them', () => {
|
|
||||||
TaskFactory.create(1, {
|
|
||||||
start_date: null,
|
|
||||||
end_date: null,
|
|
||||||
})
|
|
||||||
cy.visit('/lists/1/gantt')
|
|
||||||
|
|
||||||
cy.get('.gantt-chart-container .gantt-options .fancycheckbox')
|
|
||||||
.contains('Show tasks which don\'t have dates set')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.gantt-chart-container .gantt-chart .tasks')
|
|
||||||
.should('not.be.empty')
|
|
||||||
cy.get('.gantt-chart-container .gantt-chart .tasks .task.nodate')
|
|
||||||
.should('exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Drags a task around', () => {
|
|
||||||
const now = new Date()
|
|
||||||
TaskFactory.create(1, {
|
|
||||||
start_date: formatISO(now),
|
|
||||||
end_date: formatISO(now.setDate(now.getDate() + 4))
|
|
||||||
})
|
|
||||||
cy.visit('/lists/1/gantt')
|
|
||||||
|
|
||||||
cy.get('.gantt-chart-container .gantt-chart .tasks .task')
|
|
||||||
.first()
|
|
||||||
.trigger('mousedown', {which: 1})
|
|
||||||
.trigger('mousemove', {clientX: 500, clientY: 0})
|
|
||||||
.trigger('mouseup', {force: true})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Kanban', () => {
|
|
||||||
let buckets
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
buckets = BucketFactory.create(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Shows all buckets with their tasks', () => {
|
|
||||||
const data = TaskFactory.create(10, {
|
|
||||||
list_id: 1,
|
|
||||||
bucket_id: 1,
|
|
||||||
})
|
|
||||||
cy.visit('/lists/1/kanban')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .title')
|
|
||||||
.contains(buckets[0].title)
|
|
||||||
.should('exist')
|
|
||||||
cy.get('.kanban .bucket .title')
|
|
||||||
.contains(buckets[1].title)
|
|
||||||
.should('exist')
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.first()
|
|
||||||
.should('contain', data[0].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can add a new task to a bucket', () => {
|
|
||||||
const data = TaskFactory.create(2, {
|
|
||||||
list_id: 1,
|
|
||||||
bucket_id: 1,
|
|
||||||
})
|
|
||||||
cy.visit('/lists/1/kanban')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.contains(buckets[0].title)
|
|
||||||
.get('.bucket-footer .button')
|
|
||||||
.contains('Add another task')
|
|
||||||
.click()
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.contains(buckets[0].title)
|
|
||||||
.get('.bucket-footer .field .control input.input')
|
|
||||||
.type('New Task{enter}')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.first()
|
|
||||||
.should('contain', 'New Task')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can create a new bucket', () => {
|
|
||||||
cy.visit('/lists/1/kanban')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket.new-bucket .button')
|
|
||||||
.click()
|
|
||||||
cy.get('.kanban .bucket.new-bucket input.input')
|
|
||||||
.type('New Bucket{enter}')
|
|
||||||
|
|
||||||
cy.wait(1000) // Wait for the request to finish
|
|
||||||
cy.get('.kanban .bucket .title')
|
|
||||||
.contains('New Bucket')
|
|
||||||
.should('exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can set a bucket limit', () => {
|
|
||||||
cy.visit('/lists/1/kanban')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
|
|
||||||
.contains('Limit: Not Set')
|
|
||||||
.click()
|
|
||||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
|
|
||||||
.first()
|
|
||||||
.type(3)
|
|
||||||
cy.get('[data-cy="setBucketLimit"]')
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .bucket-header span.limit')
|
|
||||||
.contains('0/3')
|
|
||||||
.should('exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can rename a bucket', () => {
|
|
||||||
cy.visit('/lists/1/kanban')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .bucket-header .title')
|
|
||||||
.first()
|
|
||||||
.type('{selectall}New Bucket Title{enter}')
|
|
||||||
cy.get('.kanban .bucket .bucket-header .title')
|
|
||||||
.first()
|
|
||||||
.should('contain', 'New Bucket Title')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can delete a bucket', () => {
|
|
||||||
cy.visit('/lists/1/kanban')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
|
|
||||||
.contains('Delete')
|
|
||||||
.click()
|
|
||||||
cy.get('.modal-mask .modal-container .modal-content .header')
|
|
||||||
.should('contain', 'Delete the bucket')
|
|
||||||
cy.get('.modal-mask .modal-container .modal-content .actions .button')
|
|
||||||
.contains('Do it!')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .title')
|
|
||||||
.contains(buckets[0].title)
|
|
||||||
.should('not.exist')
|
|
||||||
cy.get('.kanban .bucket .title')
|
|
||||||
.contains(buckets[1].title)
|
|
||||||
.should('exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can drag tasks around', () => {
|
|
||||||
const tasks = TaskFactory.create(2, {
|
|
||||||
list_id: 1,
|
|
||||||
bucket_id: 1,
|
|
||||||
})
|
|
||||||
cy.visit('/lists/1/kanban')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket .tasks .task')
|
|
||||||
.contains(tasks[0].title)
|
|
||||||
.first()
|
|
||||||
.drag('.kanban .bucket:nth-child(2) .tasks .dropper')
|
|
||||||
|
|
||||||
cy.get('.kanban .bucket:nth-child(2) .tasks')
|
|
||||||
.should('contain', tasks[0].title)
|
|
||||||
cy.get('.kanban .bucket:nth-child(1) .tasks')
|
|
||||||
.should('not.contain', tasks[0].title)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should navigate to the task when the task card is clicked', () => {
|
|
||||||
const tasks = TaskFactory.create(5, {
|
|
||||||
id: '{increment}',
|
|
||||||
list_id: 1,
|
|
||||||
bucket_id: 1,
|
|
||||||
})
|
|
||||||
cy.visit('/lists/1/kanban')
|
|
||||||
|
|
||||||
cy.getSettled('.kanban .bucket .tasks .task')
|
|
||||||
.contains(tasks[0].title)
|
|
||||||
.should('be.visible')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.url()
|
|
||||||
.should('contain', `/tasks/${tasks[0].id}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Should remove a task from the kanban board when moving it to another list', () => {
|
|
||||||
const lists = ListFactory.create(2)
|
|
||||||
BucketFactory.create(2, {
|
|
||||||
list_id: '{increment}',
|
|
||||||
})
|
|
||||||
const tasks = TaskFactory.create(5, {
|
|
||||||
id: '{increment}',
|
|
||||||
list_id: 1,
|
|
||||||
bucket_id: 1,
|
|
||||||
})
|
|
||||||
const task = tasks[0]
|
|
||||||
cy.visit('/lists/1/kanban')
|
|
||||||
|
|
||||||
cy.getSettled('.kanban .bucket .tasks .task')
|
|
||||||
.contains(task.title)
|
|
||||||
.should('be.visible')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.task-view .action-buttons .button')
|
|
||||||
.contains('Move task')
|
|
||||||
.click()
|
|
||||||
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
|
|
||||||
.type(`${lists[1].title}{enter}`)
|
|
||||||
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
|
|
||||||
// presses enter and we can't simulate pressing on enter to select the item.
|
|
||||||
cy.get('.task-view .content.details .field .multiselect.control .search-results')
|
|
||||||
.children()
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.get('.global-notification', { timeout: 1000 })
|
|
||||||
.should('contain', 'Success')
|
|
||||||
cy.go('back')
|
|
||||||
cy.get('.kanban .bucket')
|
|
||||||
.should('not.contain', task.title)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('List history', () => {
|
|
||||||
it('should show a list history on the home page', () => {
|
|
||||||
const lists = ListFactory.create(6)
|
|
||||||
|
|
||||||
cy.visit('/')
|
|
||||||
cy.get('h3')
|
|
||||||
.contains('Last viewed')
|
|
||||||
.should('not.exist')
|
|
||||||
|
|
||||||
cy.visit(`/lists/${lists[0].id}`)
|
|
||||||
cy.visit(`/lists/${lists[1].id}`)
|
|
||||||
cy.visit(`/lists/${lists[2].id}`)
|
|
||||||
cy.visit(`/lists/${lists[3].id}`)
|
|
||||||
cy.visit(`/lists/${lists[4].id}`)
|
|
||||||
cy.visit(`/lists/${lists[5].id}`)
|
|
||||||
|
|
||||||
cy.visit('/')
|
|
||||||
cy.get('h3')
|
|
||||||
.contains('Last viewed')
|
|
||||||
.should('exist')
|
|
||||||
cy.get('.list-cards-wrapper-2-rows')
|
|
||||||
.should('not.contain', lists[0].title)
|
|
||||||
.should('contain', lists[1].title)
|
|
||||||
.should('contain', lists[2].title)
|
|
||||||
.should('contain', lists[3].title)
|
|
||||||
.should('contain', lists[4].title)
|
|
||||||
.should('contain', lists[5].title)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
16
cypress/integration/list/prepareLists.js
Normal file
16
cypress/integration/list/prepareLists.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import {ListFactory} from '../../factories/list'
|
||||||
|
import {UserFactory} from '../../factories/user'
|
||||||
|
import {NamespaceFactory} from '../../factories/namespace'
|
||||||
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
|
||||||
|
export function prepareLists(setLists = () => {}) {
|
||||||
|
beforeEach(() => {
|
||||||
|
UserFactory.create(1)
|
||||||
|
NamespaceFactory.create(1)
|
||||||
|
const lists = ListFactory.create(1, {
|
||||||
|
title: 'First List'
|
||||||
|
})
|
||||||
|
setLists(lists)
|
||||||
|
TaskFactory.truncate()
|
||||||
|
})
|
||||||
|
}
|
|
@ -116,6 +116,7 @@ describe('Task', () => {
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
.should('contain', 'Done')
|
.should('contain', 'Done')
|
||||||
cy.get('.task-view .action-buttons p.created')
|
cy.get('.task-view .action-buttons p.created')
|
||||||
|
.scrollIntoView()
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
.should('contain', 'Done')
|
.should('contain', 'Done')
|
||||||
})
|
})
|
||||||
|
@ -372,13 +373,13 @@ describe('Task', () => {
|
||||||
|
|
||||||
cy.visit(`/tasks/${tasks[0].id}`)
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
|
cy.getSettled('.task-view .details.labels-list .multiselect .input-wrapper')
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
.should('contain', labels[0].title)
|
.should('contain', labels[0].title)
|
||||||
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
|
cy.getSettled('.task-view .details.labels-list .multiselect .input-wrapper')
|
||||||
.children()
|
.children()
|
||||||
.first()
|
.first()
|
||||||
.get('a.delete')
|
.get('[data-cy="taskDetail.removeLabel"]')
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
cy.get('.global-notification')
|
cy.get('.global-notification')
|
||||||
|
|
|
@ -8,12 +8,14 @@ describe('User Settings', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Changes the user avatar', () => {
|
it('Changes the user avatar', () => {
|
||||||
|
cy.intercept(`${Cypress.env('API_URL')}/user/settings/avatar/upload`).as('uploadAvatar')
|
||||||
|
|
||||||
cy.visit('/user/settings/avatar')
|
cy.visit('/user/settings/avatar')
|
||||||
|
|
||||||
cy.get('input[name=avatarProvider][value=upload]')
|
cy.get('input[name=avatarProvider][value=upload]')
|
||||||
.click()
|
.click()
|
||||||
cy.get('input[type=file]', { timeout: 1000 })
|
cy.get('input[type=file]', {timeout: 1000})
|
||||||
.attachFile('image.jpg')
|
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
|
||||||
cy.get('.vue-handler-wrapper.vue-handler-wrapper--south .vue-simple-handler.vue-simple-handler--south')
|
cy.get('.vue-handler-wrapper.vue-handler-wrapper--south .vue-simple-handler.vue-simple-handler--south')
|
||||||
.trigger('mousedown', {which: 1})
|
.trigger('mousedown', {which: 1})
|
||||||
.trigger('mousemove', {clientY: 100})
|
.trigger('mousemove', {clientY: 100})
|
||||||
|
@ -22,7 +24,7 @@ describe('User Settings', () => {
|
||||||
.contains('Upload Avatar')
|
.contains('Upload Avatar')
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
cy.wait(3000) // Wait for the request to finish
|
cy.wait('@uploadAvatar')
|
||||||
cy.get('.global-notification')
|
cy.get('.global-notification')
|
||||||
.should('contain', 'Success')
|
.should('contain', 'Success')
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
|
|
||||||
import './commands'
|
import './commands'
|
||||||
import 'cypress-file-upload'
|
|
||||||
import '@4tw/cypress-drag-drop'
|
import '@4tw/cypress-drag-drop'
|
||||||
|
|
||||||
// see https://github.com/cypress-io/cypress/issues/702#issuecomment-587127275
|
// see https://github.com/cypress-io/cypress/issues/702#issuecomment-587127275
|
||||||
|
|
51
package.json
51
package.json
|
@ -18,20 +18,20 @@
|
||||||
"browserslist:update": "npx browserslist@latest --update-db"
|
"browserslist:update": "npx browserslist@latest --update-db"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@github/hotkey": "1.6.1",
|
"@github/hotkey": "2.0.0",
|
||||||
"@kyvg/vue3-notification": "2.3.4",
|
"@kyvg/vue3-notification": "2.3.4",
|
||||||
"@sentry/tracing": "6.16.1",
|
"@sentry/tracing": "6.17.4",
|
||||||
"@sentry/vue": "6.16.1",
|
"@sentry/vue": "6.17.4",
|
||||||
"@types/is-touch-device": "1.0.0",
|
"@types/is-touch-device": "1.0.0",
|
||||||
"@vue/compat": "3.2.29",
|
"@vue/compat": "3.2.29",
|
||||||
"@vueuse/core": "7.5.4",
|
"@vueuse/core": "7.5.5",
|
||||||
"@vueuse/router": "7.5.4",
|
"@vueuse/router": "7.5.5",
|
||||||
"bulma-css-variables": "0.9.33",
|
"bulma-css-variables": "0.9.33",
|
||||||
"camel-case": "4.1.2",
|
"camel-case": "4.1.2",
|
||||||
"codemirror": "5.65.1",
|
"codemirror": "5.65.1",
|
||||||
"copy-to-clipboard": "3.3.1",
|
"copy-to-clipboard": "3.3.1",
|
||||||
"date-fns": "2.28.0",
|
"date-fns": "2.28.0",
|
||||||
"dompurify": "2.3.4",
|
"dompurify": "2.3.5",
|
||||||
"easymde": "2.16.1",
|
"easymde": "2.16.1",
|
||||||
"flatpickr": "4.6.9",
|
"flatpickr": "4.6.9",
|
||||||
"flexsearch": "0.7.21",
|
"flexsearch": "0.7.21",
|
||||||
|
@ -39,16 +39,16 @@
|
||||||
"is-touch-device": "1.0.1",
|
"is-touch-device": "1.0.1",
|
||||||
"lodash.clonedeep": "4.5.0",
|
"lodash.clonedeep": "4.5.0",
|
||||||
"lodash.debounce": "4.0.8",
|
"lodash.debounce": "4.0.8",
|
||||||
"marked": "4.0.10",
|
"marked": "4.0.12",
|
||||||
"register-service-worker": "1.7.2",
|
"register-service-worker": "1.7.2",
|
||||||
"snake-case": "3.0.4",
|
"snake-case": "3.0.4",
|
||||||
"ufo": "0.7.9",
|
"ufo": "0.7.10",
|
||||||
"v-tooltip": "4.0.0-beta.17",
|
"v-tooltip": "4.0.0-beta.17",
|
||||||
"vue": "3.2.29",
|
"vue": "3.2.29",
|
||||||
"vue-advanced-cropper": "2.8.0",
|
"vue-advanced-cropper": "2.8.0",
|
||||||
"vue-drag-resize": "2.0.3",
|
"vue-drag-resize": "2.0.3",
|
||||||
"vue-flatpickr-component": "9.0.5",
|
"vue-flatpickr-component": "9.0.5",
|
||||||
"vue-i18n": "9.2.0-beta.28",
|
"vue-i18n": "9.2.0-beta.30",
|
||||||
"vue-router": "4.0.12",
|
"vue-router": "4.0.12",
|
||||||
"vuedraggable": "4.1.0",
|
"vuedraggable": "4.1.0",
|
||||||
"vuex": "4.0.2",
|
"vuex": "4.0.2",
|
||||||
|
@ -56,40 +56,39 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@4tw/cypress-drag-drop": "2.1.0",
|
"@4tw/cypress-drag-drop": "2.1.0",
|
||||||
"@faker-js/faker": "6.0.0-alpha.3",
|
"@faker-js/faker": "6.0.0-alpha.5",
|
||||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||||
"@fortawesome/vue-fontawesome": "3.0.0-5",
|
"@fortawesome/vue-fontawesome": "3.0.0-5",
|
||||||
"@types/flexsearch": "0.7.2",
|
"@types/flexsearch": "0.7.2",
|
||||||
"@typescript-eslint/eslint-plugin": "5.10.0",
|
"@typescript-eslint/eslint-plugin": "5.10.2",
|
||||||
"@typescript-eslint/parser": "5.10.0",
|
"@typescript-eslint/parser": "5.10.2",
|
||||||
"@vitejs/plugin-legacy": "1.6.4",
|
"@vitejs/plugin-legacy": "1.6.4",
|
||||||
"@vitejs/plugin-vue": "2.1.0",
|
"@vitejs/plugin-vue": "2.1.0",
|
||||||
"@vue/eslint-config-typescript": "10.0.0",
|
"@vue/eslint-config-typescript": "10.0.0",
|
||||||
"autoprefixer": "10.4.2",
|
"autoprefixer": "10.4.2",
|
||||||
"axios": "0.25.0",
|
"axios": "0.25.0",
|
||||||
"browserslist": "4.19.1",
|
"browserslist": "4.19.1",
|
||||||
"caniuse-lite": "1.0.30001301",
|
"caniuse-lite": "1.0.30001307",
|
||||||
"cypress": "9.3.1",
|
"cypress": "9.4.1",
|
||||||
"cypress-file-upload": "5.0.8",
|
"esbuild": "0.14.18",
|
||||||
"esbuild": "0.14.13",
|
"eslint": "8.8.0",
|
||||||
"eslint": "8.7.0",
|
"eslint-plugin-vue": "8.4.1",
|
||||||
"eslint-plugin-vue": "8.3.0",
|
|
||||||
"express": "4.17.2",
|
"express": "4.17.2",
|
||||||
"netlify-cli": "8.8.2",
|
"happy-dom": "2.31.1",
|
||||||
"happy-dom": "2.28.0",
|
"netlify-cli": "8.15.0",
|
||||||
"postcss": "8.4.5",
|
"postcss": "8.4.6",
|
||||||
"postcss-preset-env": "7.2.3",
|
"postcss-preset-env": "7.3.1",
|
||||||
"rollup": "2.66.0",
|
"rollup": "2.67.0",
|
||||||
"rollup-plugin-visualizer": "5.5.4",
|
"rollup-plugin-visualizer": "5.5.4",
|
||||||
"sass": "1.49.0",
|
"sass": "1.49.7",
|
||||||
"slugify": "1.6.5",
|
"slugify": "1.6.5",
|
||||||
"typescript": "4.5.5",
|
"typescript": "4.5.5",
|
||||||
"vite": "2.7.13",
|
"vite": "2.7.13",
|
||||||
"vite-plugin-pwa": "0.11.13",
|
"vite-plugin-pwa": "0.11.13",
|
||||||
"vite-svg-loader": "3.1.2",
|
"vite-svg-loader": "3.1.2",
|
||||||
"vitest": "0.2.0",
|
"vitest": "0.2.7",
|
||||||
"vue-tsc": "0.31.1",
|
"vue-tsc": "0.31.1",
|
||||||
"wait-on": "6.0.0",
|
"wait-on": "6.0.0",
|
||||||
"workbox-cli": "6.4.2"
|
"workbox-cli": "6.4.2"
|
||||||
|
@ -130,7 +129,7 @@
|
||||||
"parser": "vue-eslint-parser",
|
"parser": "vue-eslint-parser",
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"ecmaVersion": 2021
|
"ecmaVersion": 2022
|
||||||
},
|
},
|
||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
"*.test.*",
|
"*.test.*",
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<a @click="$store.commit('menuActive', false)" class="menu-hide-button" v-if="menuActive">
|
<BaseButton
|
||||||
|
v-if="menuActive"
|
||||||
|
@click="$store.commit('menuActive', false)"
|
||||||
|
class="menu-hide-button"
|
||||||
|
>
|
||||||
<icon icon="times" />
|
<icon icon="times" />
|
||||||
</a>
|
</BaseButton>
|
||||||
<div
|
<div
|
||||||
:class="{'has-background': background}"
|
:class="{'has-background': background}"
|
||||||
:style="{'background-image': background && `url(${background})`}"
|
:style="{'background-image': background && `url(${background})`}"
|
||||||
|
@ -16,18 +20,32 @@
|
||||||
]"
|
]"
|
||||||
class="app-content"
|
class="app-content"
|
||||||
>
|
>
|
||||||
<a @click="$store.commit('menuActive', false)" class="mobile-overlay" v-if="menuActive"></a>
|
<BaseButton
|
||||||
|
v-if="menuActive"
|
||||||
|
@click="$store.commit('menuActive', false)"
|
||||||
|
class="mobile-overlay"
|
||||||
|
/>
|
||||||
|
|
||||||
<quick-actions/>
|
<quick-actions/>
|
||||||
|
|
||||||
<router-view/>
|
|
||||||
|
|
||||||
<router-view name="popup" v-slot="{ Component }">
|
<router-view :route="routeWithModal" v-slot="{ Component }">
|
||||||
<transition name="modal">
|
<keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</transition>
|
</keep-alive>
|
||||||
</router-view>
|
</router-view>
|
||||||
|
|
||||||
|
<transition name="modal">
|
||||||
|
<modal
|
||||||
|
v-if="currentModal"
|
||||||
|
@close="closeModal()"
|
||||||
|
variant="scrolling"
|
||||||
|
class="task-detail-view-modal"
|
||||||
|
>
|
||||||
|
<component :is="currentModal" />
|
||||||
|
</modal>
|
||||||
|
</transition>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
class="keyboard-shortcuts-button"
|
class="keyboard-shortcuts-button"
|
||||||
@click="showKeyboardShortcuts()"
|
@click="showKeyboardShortcuts()"
|
||||||
|
@ -41,7 +59,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {watch, computed} from 'vue'
|
import {watch, computed, shallowRef, watchEffect, VNode, h} from 'vue'
|
||||||
import {useStore} from 'vuex'
|
import {useStore} from 'vuex'
|
||||||
import {useRoute, useRouter} from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import {useEventListener} from '@vueuse/core'
|
import {useEventListener} from '@vueuse/core'
|
||||||
|
@ -49,6 +67,59 @@ import {useEventListener} from '@vueuse/core'
|
||||||
import {CURRENT_LIST, KEYBOARD_SHORTCUTS_ACTIVE, MENU_ACTIVE} from '@/store/mutation-types'
|
import {CURRENT_LIST, KEYBOARD_SHORTCUTS_ACTIVE, MENU_ACTIVE} from '@/store/mutation-types'
|
||||||
import Navigation from '@/components/home/navigation.vue'
|
import Navigation from '@/components/home/navigation.vue'
|
||||||
import QuickActions from '@/components/quick-actions/quick-actions.vue'
|
import QuickActions from '@/components/quick-actions/quick-actions.vue'
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
|
function useRouteWithModal() {
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const backdropView = computed(() => route.fullPath && window.history.state.backdropView)
|
||||||
|
|
||||||
|
const routeWithModal = computed(() => {
|
||||||
|
return backdropView.value
|
||||||
|
? router.resolve(backdropView.value)
|
||||||
|
: route
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentModal = shallowRef<VNode>()
|
||||||
|
watchEffect(() => {
|
||||||
|
if (!backdropView.value) {
|
||||||
|
currentModal.value = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// logic from vue-router
|
||||||
|
// https://github.com/vuejs/vue-router-next/blob/798cab0d1e21f9b4d45a2bd12b840d2c7415f38a/src/RouterView.ts#L125
|
||||||
|
const routePropsOption = route.matched[0]?.props.default
|
||||||
|
const routeProps = routePropsOption
|
||||||
|
? routePropsOption === true
|
||||||
|
? route.params
|
||||||
|
: typeof routePropsOption === 'function'
|
||||||
|
? routePropsOption(route)
|
||||||
|
: routePropsOption
|
||||||
|
: null
|
||||||
|
|
||||||
|
currentModal.value = h(
|
||||||
|
route.matched[0]?.components.default,
|
||||||
|
routeProps,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
const historyState = computed(() => route.fullPath && window.history.state)
|
||||||
|
|
||||||
|
if (historyState.value) {
|
||||||
|
router.back()
|
||||||
|
} else {
|
||||||
|
const backdropRoute = historyState.value?.backdropView && router.resolve(historyState.value.backdropView)
|
||||||
|
router.push(backdropRoute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { routeWithModal, currentModal, closeModal }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { routeWithModal, currentModal, closeModal } = useRouteWithModal()
|
||||||
|
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
|
||||||
|
@ -223,4 +294,6 @@ store.dispatch('labels/loadAllLabels')
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include modal-transition();
|
||||||
</style>
|
</style>
|
|
@ -90,13 +90,15 @@
|
||||||
v-bind="dragOptions"
|
v-bind="dragOptions"
|
||||||
:modelValue="activeLists[nk]"
|
:modelValue="activeLists[nk]"
|
||||||
@update:modelValue="(lists) => updateActiveLists(n, lists)"
|
@update:modelValue="(lists) => updateActiveLists(n, lists)"
|
||||||
:group="`namespace-${n.id}-lists`"
|
group="namespace-lists"
|
||||||
@start="() => drag = true"
|
@start="() => drag = true"
|
||||||
@end="e => saveListPosition(e, nk)"
|
@end="saveListPosition"
|
||||||
handle=".handle"
|
handle=".handle"
|
||||||
:disabled="n.id < 0 || null"
|
:disabled="n.id < 0 || null"
|
||||||
tag="transition-group"
|
tag="transition-group"
|
||||||
item-key="id"
|
item-key="id"
|
||||||
|
:data-namespace-id="n.id"
|
||||||
|
:data-namespace-index="nk"
|
||||||
:component-data="{
|
:component-data="{
|
||||||
type: 'transition',
|
type: 'transition',
|
||||||
tag: 'ul',
|
tag: 'ul',
|
||||||
|
@ -198,7 +200,7 @@ export default {
|
||||||
loading: state => state[LOADING] && state[LOADING_MODULE] === 'namespaces',
|
loading: state => state[LOADING] && state[LOADING_MODULE] === 'namespaces',
|
||||||
}),
|
}),
|
||||||
activeLists() {
|
activeLists() {
|
||||||
return this.namespaces.map(({lists}) => lists?.filter(item => !item.isArchived))
|
return this.namespaces.map(({lists}) => lists?.filter(item => typeof item !== 'undefined' && !item.isArchived))
|
||||||
},
|
},
|
||||||
namespaceTitles() {
|
namespaceTitles() {
|
||||||
return this.namespaces.map((namespace) => this.getNamespaceTitle(namespace))
|
return this.namespaces.map((namespace) => this.getNamespaceTitle(namespace))
|
||||||
|
@ -241,15 +243,15 @@ export default {
|
||||||
this.listsVisible[namespaceId] = !this.listsVisible[namespaceId]
|
this.listsVisible[namespaceId] = !this.listsVisible[namespaceId]
|
||||||
},
|
},
|
||||||
updateActiveLists(namespace, activeLists) {
|
updateActiveLists(namespace, activeLists) {
|
||||||
// this is a bit hacky: since we do have to filter out the archived items from the list
|
// This is a bit hacky: since we do have to filter out the archived items from the list
|
||||||
// for vue draggable updating it is not as simple as replacing it.
|
// for vue draggable updating it is not as simple as replacing it.
|
||||||
// instead we iterate over the non archived items in the old list and replace them with the ones in their new order
|
// To work around this, we merge the active lists with the archived ones. Doing so breaks the order
|
||||||
const lists = namespace.lists.map((item) => {
|
// because now all archived lists are sorted after the active ones. This is fine because they are sorted
|
||||||
if (item.isArchived) {
|
// later when showing them anyway, and it makes the merging happening here a lot easier.
|
||||||
return item
|
const lists = [
|
||||||
}
|
...activeLists,
|
||||||
return activeLists.shift()
|
...namespace.lists.filter(l => l.isArchived),
|
||||||
})
|
]
|
||||||
|
|
||||||
const newNamespace = {
|
const newNamespace = {
|
||||||
...namespace,
|
...namespace,
|
||||||
|
@ -259,8 +261,11 @@ export default {
|
||||||
this.$store.commit('namespaces/setNamespaceById', newNamespace)
|
this.$store.commit('namespaces/setNamespaceById', newNamespace)
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveListPosition(e, namespaceIndex) {
|
async saveListPosition(e) {
|
||||||
const listsActive = this.activeLists[namespaceIndex]
|
const namespaceId = parseInt(e.to.dataset.namespaceId)
|
||||||
|
const newNamespaceIndex = parseInt(e.to.dataset.namespaceIndex)
|
||||||
|
|
||||||
|
const listsActive = this.activeLists[newNamespaceIndex]
|
||||||
const list = listsActive[e.newIndex]
|
const list = listsActive[e.newIndex]
|
||||||
const listBefore = listsActive[e.newIndex - 1] ?? null
|
const listBefore = listsActive[e.newIndex - 1] ?? null
|
||||||
const listAfter = listsActive[e.newIndex + 1] ?? null
|
const listAfter = listsActive[e.newIndex + 1] ?? null
|
||||||
|
@ -273,6 +278,7 @@ export default {
|
||||||
await this.$store.dispatch('lists/updateList', {
|
await this.$store.dispatch('lists/updateList', {
|
||||||
...list,
|
...list,
|
||||||
position,
|
position,
|
||||||
|
namespaceId,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
this.listUpdating[list.id] = false
|
this.listUpdating[list.id] = false
|
||||||
|
|
|
@ -2,21 +2,22 @@
|
||||||
<dropdown>
|
<dropdown>
|
||||||
<template v-if="isSavedFilter">
|
<template v-if="isSavedFilter">
|
||||||
<dropdown-item
|
<dropdown-item
|
||||||
:to="{ name: `${listRoutePrefix}.edit`, params: { listId: list.id } }"
|
:to="{ name: 'filter.settings.edit', params: { listId: list.id } }"
|
||||||
icon="pen"
|
icon="pen"
|
||||||
>
|
>
|
||||||
{{ $t('menu.edit') }}
|
{{ $t('menu.edit') }}
|
||||||
</dropdown-item>
|
</dropdown-item>
|
||||||
<dropdown-item
|
<dropdown-item
|
||||||
:to="{ name: `${listRoutePrefix}.delete`, params: { listId: list.id } }"
|
:to="{ name: 'filter.settings.delete', params: { listId: list.id } }"
|
||||||
icon="trash-alt"
|
icon="trash-alt"
|
||||||
>
|
>
|
||||||
{{ $t('misc.delete') }}
|
{{ $t('misc.delete') }}
|
||||||
</dropdown-item>
|
</dropdown-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="list.isArchived">
|
<template v-else-if="list.isArchived">
|
||||||
<dropdown-item
|
<dropdown-item
|
||||||
:to="{ name: `${listRoutePrefix}.archive`, params: { listId: list.id } }"
|
:to="{ name: 'list.settings.archive', params: { listId: list.id } }"
|
||||||
icon="archive"
|
icon="archive"
|
||||||
>
|
>
|
||||||
{{ $t('menu.unarchive') }}
|
{{ $t('menu.unarchive') }}
|
||||||
|
@ -24,37 +25,38 @@
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<dropdown-item
|
<dropdown-item
|
||||||
:to="{ name: `${listRoutePrefix}.edit`, params: { listId: list.id } }"
|
:to="{ name: 'list.settings.edit', params: { listId: list.id } }"
|
||||||
icon="pen"
|
icon="pen"
|
||||||
>
|
>
|
||||||
{{ $t('menu.edit') }}
|
{{ $t('menu.edit') }}
|
||||||
</dropdown-item>
|
</dropdown-item>
|
||||||
<dropdown-item
|
<dropdown-item
|
||||||
:to="{ name: `${listRoutePrefix}.background`, params: { listId: list.id } }"
|
|
||||||
v-if="backgroundsEnabled"
|
v-if="backgroundsEnabled"
|
||||||
|
:to="{ name: 'list.settings.background', params: { listId: list.id } }"
|
||||||
icon="image"
|
icon="image"
|
||||||
>
|
>
|
||||||
{{ $t('menu.setBackground') }}
|
{{ $t('menu.setBackground') }}
|
||||||
</dropdown-item>
|
</dropdown-item>
|
||||||
<dropdown-item
|
<dropdown-item
|
||||||
:to="{ name: `${listRoutePrefix}.share`, params: { listId: list.id } }"
|
:to="{ name: 'list.settings.share', params: { listId: list.id } }"
|
||||||
icon="share-alt"
|
icon="share-alt"
|
||||||
>
|
>
|
||||||
{{ $t('menu.share') }}
|
{{ $t('menu.share') }}
|
||||||
</dropdown-item>
|
</dropdown-item>
|
||||||
<dropdown-item
|
<dropdown-item
|
||||||
:to="{ name: `${listRoutePrefix}.duplicate`, params: { listId: list.id } }"
|
:to="{ name: 'list.settings.duplicate', params: { listId: list.id } }"
|
||||||
icon="paste"
|
icon="paste"
|
||||||
>
|
>
|
||||||
{{ $t('menu.duplicate') }}
|
{{ $t('menu.duplicate') }}
|
||||||
</dropdown-item>
|
</dropdown-item>
|
||||||
<dropdown-item
|
<dropdown-item
|
||||||
:to="{ name: `${listRoutePrefix}.archive`, params: { listId: list.id } }"
|
:to="{ name: 'list.settings.archive', params: { listId: list.id } }"
|
||||||
icon="archive"
|
icon="archive"
|
||||||
>
|
>
|
||||||
{{ $t('menu.archive') }}
|
{{ $t('menu.archive') }}
|
||||||
</dropdown-item>
|
</dropdown-item>
|
||||||
<task-subscription
|
<task-subscription
|
||||||
|
v-if="subscription"
|
||||||
class="dropdown-item has-no-shadow"
|
class="dropdown-item has-no-shadow"
|
||||||
:is-button="false"
|
:is-button="false"
|
||||||
entity="list"
|
entity="list"
|
||||||
|
@ -63,7 +65,7 @@
|
||||||
@change="sub => subscription = sub"
|
@change="sub => subscription = sub"
|
||||||
/>
|
/>
|
||||||
<dropdown-item
|
<dropdown-item
|
||||||
:to="{ name: `${listRoutePrefix}.delete`, params: { listId: list.id } }"
|
:to="{ name: 'list.settings.delete', params: { listId: list.id } }"
|
||||||
icon="trash-alt"
|
icon="trash-alt"
|
||||||
class="has-text-danger"
|
class="has-text-danger"
|
||||||
>
|
>
|
||||||
|
@ -73,56 +75,32 @@
|
||||||
</dropdown>
|
</dropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
|
import {ref, computed, watchEffect} from 'vue'
|
||||||
|
import {useStore} from 'vuex'
|
||||||
|
|
||||||
import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
|
import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
|
||||||
import Dropdown from '@/components/misc/dropdown.vue'
|
import Dropdown from '@/components/misc/dropdown.vue'
|
||||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||||
import TaskSubscription from '@/components/misc/subscription.vue'
|
import TaskSubscription from '@/components/misc/subscription.vue'
|
||||||
|
import ListModel from '@/models/list'
|
||||||
|
import SubscriptionModel from '@/models/subscription'
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: 'list-settings-dropdown',
|
list: {
|
||||||
data() {
|
type: ListModel,
|
||||||
return {
|
required: true,
|
||||||
subscription: null,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
components: {
|
})
|
||||||
TaskSubscription,
|
|
||||||
DropdownItem,
|
|
||||||
Dropdown,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
list: {
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.subscription = this.list.subscription
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
backgroundsEnabled() {
|
|
||||||
return this.$store.state.config.enabledBackgroundProviders !== null && this.$store.state.config.enabledBackgroundProviders.length > 0
|
|
||||||
},
|
|
||||||
listRoutePrefix() {
|
|
||||||
let name = 'list'
|
|
||||||
|
|
||||||
|
const subscription = ref<SubscriptionModel>()
|
||||||
|
watchEffect(() => {
|
||||||
|
if (props.list.subscription) {
|
||||||
|
subscription.value = props.list.subscription
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (this.$route.name !== null && this.$route.name.startsWith('list.')) {
|
const store = useStore()
|
||||||
// HACK: we should implement a better routing for the modals
|
const backgroundsEnabled = computed(() => store.state.config.enabledBackgroundProviders?.length > 0)
|
||||||
const settingsRoutes = ['edit', 'delete', 'archive', 'background', 'share', 'duplicate']
|
const isSavedFilter = computed(() => getSavedFilterIdFromListId(props.list.id) > 0)
|
||||||
const suffix = settingsRoutes.find((route) => this.$route.name.endsWith(`.settings.${route}`))
|
|
||||||
name = this.$route.name.replace(`.settings.${suffix}`,'')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isSavedFilter) {
|
|
||||||
name = name.replace('list.', 'filter.')
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${name}.settings`
|
|
||||||
},
|
|
||||||
isSavedFilter() {
|
|
||||||
return getSavedFilterIdFromListId(this.list.id) > 0
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -29,9 +29,10 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Filters from '@/components/list/partials/filters'
|
import Filters from '@/components/list/partials/filters'
|
||||||
import {getDefaultParams} from '@/components/tasks/mixins/taskList'
|
|
||||||
import Popup from '@/components/misc/popup'
|
import Popup from '@/components/misc/popup'
|
||||||
|
|
||||||
|
import {getDefaultParams} from '@/composables/taskList'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'filter-popup',
|
name: 'filter-popup',
|
||||||
components: {
|
components: {
|
||||||
|
|
|
@ -191,7 +191,7 @@ import NamespaceService from '@/services/namespace'
|
||||||
import EditLabels from '@/components/tasks/partials/editLabels.vue'
|
import EditLabels from '@/components/tasks/partials/editLabels.vue'
|
||||||
|
|
||||||
import {objectToSnakeCase} from '@/helpers/case'
|
import {objectToSnakeCase} from '@/helpers/case'
|
||||||
import {getDefaultParams} from '@/components/tasks/mixins/taskList'
|
import {getDefaultParams} from '@/composables/taskList'
|
||||||
|
|
||||||
// FIXME: merge with DEFAULT_PARAMS in taskList.js
|
// FIXME: merge with DEFAULT_PARAMS in taskList.js
|
||||||
const DEFAULT_PARAMS = {
|
const DEFAULT_PARAMS = {
|
||||||
|
|
|
@ -4,9 +4,11 @@
|
||||||
<template v-for="(s, i) in shortcuts" :key="i">
|
<template v-for="(s, i) in shortcuts" :key="i">
|
||||||
<h3>{{ $t(s.title) }}</h3>
|
<h3>{{ $t(s.title) }}</h3>
|
||||||
|
|
||||||
<message class="mb-4">
|
<message class="mb-4" v-if="s.available">
|
||||||
{{
|
{{
|
||||||
s.available($route) ? $t('keyboardShortcuts.currentPageOnly') : $t('keyboardShortcuts.allPages')
|
s.available($route)
|
||||||
|
? $t('keyboardShortcuts.currentPageOnly')
|
||||||
|
: $t('keyboardShortcuts.allPages')
|
||||||
}}
|
}}
|
||||||
</message>
|
</message>
|
||||||
|
|
||||||
|
@ -17,7 +19,8 @@
|
||||||
class="shortcut-keys"
|
class="shortcut-keys"
|
||||||
is="dd"
|
is="dd"
|
||||||
:keys="sc.keys"
|
:keys="sc.keys"
|
||||||
:combination="typeof sc.combination !== 'undefined' ? $t(`keyboardShortcuts.${sc.combination}`) : null"/>
|
:combination="sc.combination && $t(`keyboardShortcuts.${sc.combination}`)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</dl>
|
</dl>
|
||||||
</template>
|
</template>
|
||||||
|
@ -25,28 +28,18 @@
|
||||||
</modal>
|
</modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts" setup>
|
||||||
import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types'
|
import {useStore} from 'vuex'
|
||||||
import Shortcut from '@/components/misc/shortcut.vue'
|
|
||||||
import Message from '@/components/misc/message'
|
|
||||||
import {KEYBOARD_SHORTCUTS} from './shortcuts'
|
|
||||||
|
|
||||||
export default {
|
import Shortcut from '@/components/misc/shortcut.vue'
|
||||||
name: 'keyboard-shortcuts',
|
import Message from '@/components/misc/message.vue'
|
||||||
components: {
|
|
||||||
Message,
|
import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types'
|
||||||
Shortcut,
|
import {KEYBOARD_SHORTCUTS as shortcuts} from './shortcuts'
|
||||||
},
|
|
||||||
data() {
|
const store = useStore()
|
||||||
return {
|
function close() {
|
||||||
shortcuts: KEYBOARD_SHORTCUTS,
|
store.commit(KEYBOARD_SHORTCUTS_ACTIVE, false)
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
close() {
|
|
||||||
this.$store.commit(KEYBOARD_SHORTCUTS_ACTIVE, false)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,24 @@
|
||||||
|
import {RouteLocation} from 'vue-router'
|
||||||
|
|
||||||
import {isAppleDevice} from '@/helpers/isAppleDevice'
|
import {isAppleDevice} from '@/helpers/isAppleDevice'
|
||||||
|
|
||||||
const ctrl = isAppleDevice() ? '⌘' : 'ctrl'
|
const ctrl = isAppleDevice() ? '⌘' : 'ctrl'
|
||||||
|
|
||||||
export const KEYBOARD_SHORTCUTS = [
|
interface Shortcut {
|
||||||
|
title: string
|
||||||
|
keys: string[]
|
||||||
|
combination?: 'then'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortcutGroup {
|
||||||
|
title: string
|
||||||
|
available?: (route: RouteLocation) => boolean
|
||||||
|
shortcuts: Shortcut[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
|
||||||
{
|
{
|
||||||
title: 'keyboardShortcuts.general',
|
title: 'keyboardShortcuts.general',
|
||||||
available: () => null,
|
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{
|
{
|
||||||
title: 'keyboardShortcuts.toggleMenu',
|
title: 'keyboardShortcuts.toggleMenu',
|
||||||
|
@ -29,7 +42,7 @@ export const KEYBOARD_SHORTCUTS = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'keyboardShortcuts.list.title',
|
title: 'keyboardShortcuts.list.title',
|
||||||
available: (route) => route.name.startsWith('list.'),
|
available: (route) => (route.name as string)?.startsWith('list.'),
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{
|
{
|
||||||
title: 'keyboardShortcuts.list.switchToListView',
|
title: 'keyboardShortcuts.list.switchToListView',
|
||||||
|
@ -55,13 +68,7 @@ export const KEYBOARD_SHORTCUTS = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'keyboardShortcuts.task.title',
|
title: 'keyboardShortcuts.task.title',
|
||||||
available: (route) => [
|
available: (route) => route.name === 'task.detail',
|
||||||
'task.detail',
|
|
||||||
'task.list.detail',
|
|
||||||
'task.gantt.detail',
|
|
||||||
'task.kanban.detail',
|
|
||||||
'task.detail',
|
|
||||||
].includes(route.name),
|
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{
|
{
|
||||||
title: 'keyboardShortcuts.task.assign',
|
title: 'keyboardShortcuts.task.assign',
|
|
@ -52,9 +52,15 @@ import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
|
||||||
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
|
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
|
||||||
import {useOnline} from '@/composables/useOnline'
|
import {useOnline} from '@/composables/useOnline'
|
||||||
|
|
||||||
|
import {useRouter, useRoute} from 'vue-router'
|
||||||
|
import {getAuthForRoute} from '@/router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
|
||||||
const ready = computed(() => store.state.vikunjaReady)
|
const ready = ref(false)
|
||||||
const online = useOnline()
|
const online = useOnline()
|
||||||
|
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
@ -63,7 +69,12 @@ const showLoading = computed(() => !ready.value && error.value === '')
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
await store.dispatch('loadApp')
|
await store.dispatch('loadApp')
|
||||||
} catch(e: any) {
|
const redirectTo = getAuthForRoute(route)
|
||||||
|
if (typeof redirectTo !== 'undefined') {
|
||||||
|
await router.push(redirectTo)
|
||||||
|
}
|
||||||
|
ready.value = true
|
||||||
|
} catch (e: any) {
|
||||||
error.value = e
|
error.value = e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,55 +1,47 @@
|
||||||
<template>
|
<template>
|
||||||
<x-button
|
<x-button
|
||||||
|
v-if="isButton"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
:icon="icon"
|
:icon="iconName"
|
||||||
v-tooltip="tooltipText"
|
v-tooltip="tooltipText"
|
||||||
@click="changeSubscription"
|
@click="changeSubscription"
|
||||||
:disabled="disabled || null"
|
:disabled="disabled || null"
|
||||||
v-if="isButton"
|
|
||||||
>
|
>
|
||||||
{{ buttonText }}
|
{{ buttonText }}
|
||||||
</x-button>
|
</x-button>
|
||||||
<a
|
<BaseButton
|
||||||
|
v-else
|
||||||
v-tooltip="tooltipText"
|
v-tooltip="tooltipText"
|
||||||
@click="changeSubscription"
|
@click="changeSubscription"
|
||||||
:class="{'is-disabled': disabled}"
|
:class="{'is-disabled': disabled}"
|
||||||
v-else
|
|
||||||
>
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<icon :icon="icon"/>
|
<icon :icon="iconName"/>
|
||||||
</span>
|
</span>
|
||||||
{{ buttonText }}
|
{{ buttonText }}
|
||||||
</a>
|
</BaseButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {computed, shallowRef} from 'vue'
|
import {computed, shallowRef} from 'vue'
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
import SubscriptionService from '@/services/subscription'
|
import SubscriptionService from '@/services/subscription'
|
||||||
import SubscriptionModel from '@/models/subscription'
|
import SubscriptionModel from '@/models/subscription'
|
||||||
|
|
||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
|
|
||||||
const props = defineProps({
|
interface Props {
|
||||||
entity: {
|
entity: string
|
||||||
required: true,
|
entityId: number
|
||||||
type: String,
|
subscription: SubscriptionModel
|
||||||
},
|
isButton?: boolean
|
||||||
subscription: {
|
}
|
||||||
required: true,
|
|
||||||
validator(value) {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
return value instanceof SubscriptionModel || value === null
|
isButton: true,
|
||||||
},
|
|
||||||
},
|
|
||||||
entityId: {
|
|
||||||
required: true,
|
|
||||||
type: Number,
|
|
||||||
},
|
|
||||||
isButton: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const subscriptionEntity = computed<string>(() => props.subscription.entity)
|
const subscriptionEntity = computed<string>(() => props.subscription.entity)
|
||||||
|
@ -73,7 +65,7 @@ const tooltipText = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const buttonText = computed(() => props.subscription !== null ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
|
const buttonText = computed(() => props.subscription !== null ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
|
||||||
const icon = computed(() => props.subscription !== null ? ['far', 'bell-slash'] : 'bell')
|
const iconName = computed(() => props.subscription !== null ? ['far', 'bell-slash'] : 'bell')
|
||||||
const disabled = computed(() => {
|
const disabled = computed(() => {
|
||||||
if (props.subscription === null) {
|
if (props.subscription === null) {
|
||||||
return false
|
return false
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
|
<!-- FIXME: transition should not be included in the modal -->
|
||||||
<transition name="modal">
|
<transition name="modal">
|
||||||
<section
|
<section
|
||||||
v-if="enabled"
|
v-if="enabled"
|
||||||
|
@ -21,6 +22,13 @@
|
||||||
'is-wide': wide
|
'is-wide': wide
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
<BaseButton
|
||||||
|
@click="emit('close')"
|
||||||
|
class="close"
|
||||||
|
>
|
||||||
|
<icon icon="times"/>
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
<slot>
|
<slot>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<slot name="header"></slot>
|
<slot name="header"></slot>
|
||||||
|
@ -53,6 +61,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
export const TRANSITION_NAMES = {
|
export const TRANSITION_NAMES = {
|
||||||
MODAL: 'modal',
|
MODAL: 'modal',
|
||||||
FADE: 'fade',
|
FADE: 'fade',
|
||||||
|
@ -70,6 +80,11 @@ function validValue(values) {
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'modal',
|
name: 'modal',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
BaseButton,
|
||||||
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
// Close the model when escape is pressed
|
// Close the model when escape is pressed
|
||||||
|
@ -197,17 +212,22 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: fixed;
|
||||||
|
top: 5px;
|
||||||
|
right: 26px;
|
||||||
|
color: var(--white);
|
||||||
|
font-size: 2rem;
|
||||||
|
|
||||||
|
@media screen and (max-width: $desktop) {
|
||||||
/* Transitions */
|
color: var(--dark);
|
||||||
|
}
|
||||||
.modal-enter,
|
|
||||||
.modal-leave-active {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
.modal-enter .modal-container,
|
<style lang="scss">
|
||||||
.modal-leave-active .modal-container {
|
// Close icon SVG uses currentColor, change the color to keep it visible
|
||||||
transform: scale(0.9);
|
.dark .task-detail-view-modal .close {
|
||||||
|
color: var(--grey-900);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -16,13 +16,13 @@
|
||||||
{{ $t('menu.edit') }}
|
{{ $t('menu.edit') }}
|
||||||
</dropdown-item>
|
</dropdown-item>
|
||||||
<dropdown-item
|
<dropdown-item
|
||||||
:to="{ name: 'namespace.settings.share', params: { id: namespace.id } }"
|
:to="{ name: 'namespace.settings.share', params: { namespaceId: namespace.id } }"
|
||||||
icon="share-alt"
|
icon="share-alt"
|
||||||
>
|
>
|
||||||
{{ $t('menu.share') }}
|
{{ $t('menu.share') }}
|
||||||
</dropdown-item>
|
</dropdown-item>
|
||||||
<dropdown-item
|
<dropdown-item
|
||||||
:to="{ name: 'list.create', params: { id: namespace.id } }"
|
:to="{ name: 'list.create', params: { namespaceId: namespace.id } }"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
>
|
>
|
||||||
{{ $t('menu.newList') }}
|
{{ $t('menu.newList') }}
|
||||||
|
@ -34,6 +34,7 @@
|
||||||
{{ $t('menu.archive') }}
|
{{ $t('menu.archive') }}
|
||||||
</dropdown-item>
|
</dropdown-item>
|
||||||
<task-subscription
|
<task-subscription
|
||||||
|
v-if="subscription"
|
||||||
class="dropdown-item has-no-shadow"
|
class="dropdown-item has-no-shadow"
|
||||||
:is-button="false"
|
:is-button="false"
|
||||||
entity="namespace"
|
entity="namespace"
|
||||||
|
|
|
@ -264,4 +264,6 @@ export default {
|
||||||
.sharables-list:not(.card-content) {
|
.sharables-list:not(.card-content) {
|
||||||
overflow-y: auto
|
overflow-y: auto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include modal-transition();
|
||||||
</style>
|
</style>
|
|
@ -365,3 +365,7 @@ export default {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@include modal-transition();
|
||||||
|
</style>
|
|
@ -67,7 +67,7 @@
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
class="mt-2 has-text-centered is-block"
|
class="mt-2 has-text-centered is-block"
|
||||||
:to="{name: 'task.detail', params: {id: taskEditTask.id}}"
|
:to="taskDetailRoute"
|
||||||
>
|
>
|
||||||
{{ $t('task.openDetail') }}
|
{{ $t('task.openDetail') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
|
@ -97,6 +97,15 @@ export default {
|
||||||
taskEditTask: TaskModel,
|
taskEditTask: TaskModel,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
taskDetailRoute() {
|
||||||
|
return {
|
||||||
|
name: 'task.detail',
|
||||||
|
params: { id: this.taskEditTask.id },
|
||||||
|
state: { backdropView: this.$router.currentRoute.value.fullPath },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
components: {
|
components: {
|
||||||
ColorPicker,
|
ColorPicker,
|
||||||
Reminders,
|
Reminders,
|
||||||
|
|
|
@ -1,101 +0,0 @@
|
||||||
import TaskCollectionService from '@/services/taskCollection'
|
|
||||||
|
|
||||||
// FIXME: merge with DEFAULT_PARAMS in filters.vue
|
|
||||||
export const getDefaultParams = () => ({
|
|
||||||
sort_by: ['position', 'id'],
|
|
||||||
order_by: ['asc', 'desc'],
|
|
||||||
filter_by: ['done'],
|
|
||||||
filter_value: ['false'],
|
|
||||||
filter_comparator: ['equals'],
|
|
||||||
filter_concat: 'and',
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This mixin provides a base set of methods and properties to get tasks on a list.
|
|
||||||
*/
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
taskCollectionService: new TaskCollectionService(),
|
|
||||||
tasks: [],
|
|
||||||
|
|
||||||
currentPage: 0,
|
|
||||||
|
|
||||||
loadedList: null,
|
|
||||||
|
|
||||||
searchTerm: '',
|
|
||||||
|
|
||||||
showTaskFilter: false,
|
|
||||||
params: {...getDefaultParams()},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
// Only listen for query path changes
|
|
||||||
'$route.query': {
|
|
||||||
handler: 'loadTasksForPage',
|
|
||||||
immediate: true,
|
|
||||||
},
|
|
||||||
'$route.path': 'loadTasksOnSavedFilter',
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async loadTasks(
|
|
||||||
page,
|
|
||||||
search = '',
|
|
||||||
params = null,
|
|
||||||
forceLoading = false,
|
|
||||||
) {
|
|
||||||
// Because this function is triggered every time on topNavigation, we're putting a condition here to only load it when we actually want to show tasks
|
|
||||||
// FIXME: This is a bit hacky -> Cleanup.
|
|
||||||
if (
|
|
||||||
this.$route.name !== 'list.list' &&
|
|
||||||
this.$route.name !== 'list.table' &&
|
|
||||||
!forceLoading
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params === null) {
|
|
||||||
params = this.params
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search !== '') {
|
|
||||||
params.s = search
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = {listId: parseInt(this.$route.params.listId)}
|
|
||||||
|
|
||||||
const currentList = {
|
|
||||||
id: list.listId,
|
|
||||||
params,
|
|
||||||
search,
|
|
||||||
page,
|
|
||||||
}
|
|
||||||
if (JSON.stringify(currentList) === JSON.stringify(this.loadedList) && !forceLoading) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tasks = []
|
|
||||||
this.tasks = await this.taskCollectionService.getAll(list, params, page)
|
|
||||||
this.currentPage = page
|
|
||||||
this.loadedList = JSON.parse(JSON.stringify(currentList))
|
|
||||||
},
|
|
||||||
|
|
||||||
loadTasksForPage(e) {
|
|
||||||
// The page parameter can be undefined, in the case where the user loads a new list from the side bar menu
|
|
||||||
let page = Number(e.page)
|
|
||||||
if (typeof e.page === 'undefined') {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
let search = e.search
|
|
||||||
if (typeof e.search === 'undefined') {
|
|
||||||
search = ''
|
|
||||||
}
|
|
||||||
this.initTasks(page, search)
|
|
||||||
},
|
|
||||||
loadTasksOnSavedFilter() {
|
|
||||||
if (typeof this.$route.params.listId !== 'undefined' && parseInt(this.$route.params.listId) < 0) {
|
|
||||||
this.loadTasks(1, '', null, true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
|
@ -400,4 +400,6 @@ export default {
|
||||||
transform: translate3d(0, -4px, 0);
|
transform: translate3d(0, -4px, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include modal-transition();
|
||||||
</style>
|
</style>
|
|
@ -162,7 +162,7 @@ import {mapState} from 'vuex'
|
||||||
export default {
|
export default {
|
||||||
name: 'comments',
|
name: 'comments',
|
||||||
components: {
|
components: {
|
||||||
editor: AsyncEditor,
|
Editor: AsyncEditor,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
taskId: {
|
taskId: {
|
||||||
|
@ -339,4 +339,6 @@ export default {
|
||||||
.media-content {
|
.media-content {
|
||||||
width: calc(100% - 48px - 2rem);
|
width: calc(100% - 48px - 2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include modal-transition();
|
||||||
</style>
|
</style>
|
|
@ -38,7 +38,7 @@ import {mapState} from 'vuex'
|
||||||
export default {
|
export default {
|
||||||
name: 'description',
|
name: 'description',
|
||||||
components: {
|
components: {
|
||||||
editor: AsyncEditor,
|
Editor: AsyncEditor,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
:style="{'background': props.item.hexColor, 'color': props.item.textColor}"
|
:style="{'background': props.item.hexColor, 'color': props.item.textColor}"
|
||||||
class="tag">
|
class="tag">
|
||||||
<span>{{ props.item.title }}</span>
|
<span>{{ props.item.title }}</span>
|
||||||
<a @click="removeLabel(props.item)" class="delete is-small"></a>
|
<button type="button" v-cy="'taskDetail.removeLabel'" @click="removeLabel(props.item)" class="delete is-small" />
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template #searchResult="props">
|
<template #searchResult="props">
|
||||||
|
@ -114,23 +114,17 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
async removeLabel(label) {
|
async removeLabel(label) {
|
||||||
const removeFromState = () => {
|
if (!this.taskId === 0) {
|
||||||
for (const l in this.labels) {
|
await this.$store.dispatch('tasks/removeLabel', {label: label, taskId: this.taskId})
|
||||||
if (this.labels[l].id === label.id) {
|
}
|
||||||
this.labels.splice(l, 1)
|
|
||||||
}
|
for (const l in this.labels) {
|
||||||
|
if (this.labels[l].id === label.id) {
|
||||||
|
this.labels.splice(l, 1)
|
||||||
}
|
}
|
||||||
this.$emit('update:modelValue', this.labels)
|
|
||||||
this.$emit('change', this.labels)
|
|
||||||
}
|
}
|
||||||
|
this.$emit('update:modelValue', this.labels)
|
||||||
if (this.taskId === 0) {
|
this.$emit('change', this.labels)
|
||||||
removeFromState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.$store.dispatch('tasks/removeLabel', {label: label, taskId: this.taskId})
|
|
||||||
removeFromState()
|
|
||||||
this.$message.success({message: this.$t('task.label.removeSuccess')})
|
this.$message.success({message: this.$t('task.label.removeSuccess')})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -7,8 +7,8 @@
|
||||||
'has-light-text': !colorIsDark(task.hexColor) && task.hexColor !== `#${task.defaultColor}` && task.hexColor !== task.defaultColor,
|
'has-light-text': !colorIsDark(task.hexColor) && task.hexColor !== `#${task.defaultColor}` && task.hexColor !== task.defaultColor,
|
||||||
}"
|
}"
|
||||||
:style="{'background-color': task.hexColor !== '#' && task.hexColor !== `#${task.defaultColor}` ? task.hexColor : false}"
|
:style="{'background-color': task.hexColor !== '#' && task.hexColor !== `#${task.defaultColor}` ? task.hexColor : false}"
|
||||||
|
@click.exact="openTaskDetail()"
|
||||||
@click.ctrl="() => toggleTaskDone(task)"
|
@click.ctrl="() => toggleTaskDone(task)"
|
||||||
@click.exact="() => $router.push({ name: 'task.kanban.detail', params: { id: task.id } })"
|
|
||||||
@click.meta="() => toggleTaskDone(task)"
|
@click.meta="() => toggleTaskDone(task)"
|
||||||
>
|
>
|
||||||
<span class="task-id">
|
<span class="task-id">
|
||||||
|
@ -115,6 +115,13 @@ export default {
|
||||||
this.loadingInternal = false
|
this.loadingInternal = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
openTaskDetail() {
|
||||||
|
this.$router.push({
|
||||||
|
name: 'task.detail',
|
||||||
|
params: { id: this.task.id },
|
||||||
|
state: { backdropView: this.$router.currentRoute.value.fullPath },
|
||||||
|
})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -274,10 +274,11 @@ export default {
|
||||||
return tasks
|
return tasks
|
||||||
.map(task => {
|
.map(task => {
|
||||||
// by doing this here once we can save a lot of duplicate calls in the template
|
// by doing this here once we can save a lot of duplicate calls in the template
|
||||||
|
const listAndNamespace = this.$store.getters['namespaces/getListAndNamespaceById'](task.listId, true)
|
||||||
const {
|
const {
|
||||||
list,
|
list,
|
||||||
namespace,
|
namespace,
|
||||||
} = this.$store.getters['namespaces/getListAndNamespaceById'](task.listId, true)
|
} = listAndNamespace === null ? {list: null, namespace: null} : listAndNamespace
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
|
@ -364,4 +365,6 @@ export default {
|
||||||
:deep(.multiselect .search-results button) {
|
:deep(.multiselect .search-results button) {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include modal-transition();
|
||||||
</style>
|
</style>
|
|
@ -8,7 +8,7 @@
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: taskDetailRoute, params: { id: task.id } }"
|
:to="taskDetailRoute"
|
||||||
:class="{ 'done': task.done}"
|
:class="{ 'done': task.done}"
|
||||||
class="tasktext">
|
class="tasktext">
|
||||||
<span>
|
<span>
|
||||||
|
@ -129,10 +129,6 @@ export default {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
taskDetailRoute: {
|
|
||||||
type: String,
|
|
||||||
default: 'task.list.detail',
|
|
||||||
},
|
|
||||||
showList: {
|
showList: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
|
@ -170,6 +166,14 @@ export default {
|
||||||
title: '',
|
title: '',
|
||||||
} : this.$store.state.currentList
|
} : this.$store.state.currentList
|
||||||
},
|
},
|
||||||
|
taskDetailRoute() {
|
||||||
|
return {
|
||||||
|
name: 'task.detail',
|
||||||
|
params: { id: this.task.id },
|
||||||
|
// TODO: re-enable opening task detail in modal
|
||||||
|
// state: { backdropView: this.$router.currentRoute.value.fullPath },
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async markAsDone(checked) {
|
async markAsDone(checked) {
|
||||||
|
|
|
@ -1,20 +1,21 @@
|
||||||
<template>
|
<template>
|
||||||
<a @click="$emit('click')">
|
<BaseButton>
|
||||||
<icon icon="sort-up" v-if="order === 'asc'"/>
|
<icon icon="sort-up" v-if="order === 'asc'"/>
|
||||||
<icon icon="sort-up" rotation="180" v-else-if="order === 'desc'"/>
|
<icon icon="sort-up" v-else-if="order === 'desc'" rotation="180"/>
|
||||||
<icon icon="sort" v-else/>
|
<icon icon="sort" v-else/>
|
||||||
</a>
|
</BaseButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
export default {
|
import {PropType} from 'vue'
|
||||||
name: 'sort',
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
props: {
|
|
||||||
order: {
|
type Order = 'asc' | 'desc' | 'none'
|
||||||
type: String,
|
|
||||||
default: 'none',
|
defineProps({
|
||||||
},
|
order: {
|
||||||
|
type: String as PropType<Order>,
|
||||||
|
default: 'none',
|
||||||
},
|
},
|
||||||
emits: ['click'],
|
})
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
111
src/composables/taskList.js
Normal file
111
src/composables/taskList.js
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import { ref, shallowReactive, watch, computed } from 'vue'
|
||||||
|
import {useRoute} from 'vue-router'
|
||||||
|
|
||||||
|
import TaskCollectionService from '@/services/taskCollection'
|
||||||
|
|
||||||
|
// FIXME: merge with DEFAULT_PARAMS in filters.vue
|
||||||
|
export const getDefaultParams = () => ({
|
||||||
|
sort_by: ['position', 'id'],
|
||||||
|
order_by: ['asc', 'desc'],
|
||||||
|
filter_by: ['done'],
|
||||||
|
filter_value: ['false'],
|
||||||
|
filter_comparator: ['equals'],
|
||||||
|
filter_concat: 'and',
|
||||||
|
})
|
||||||
|
|
||||||
|
const SORT_BY_DEFAULT = {
|
||||||
|
id: 'desc',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This mixin provides a base set of methods and properties to get tasks on a list.
|
||||||
|
*/
|
||||||
|
export function useTaskList(listId) {
|
||||||
|
const params = ref({...getDefaultParams()})
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
|
||||||
|
const sortBy = ref({ ...SORT_BY_DEFAULT })
|
||||||
|
|
||||||
|
|
||||||
|
// This makes sure an id sort order is always sorted last.
|
||||||
|
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
|
||||||
|
// precedence over everything else, making any other sort columns pretty useless.
|
||||||
|
function formatSortOrder(params) {
|
||||||
|
let hasIdFilter = false
|
||||||
|
const sortKeys = Object.keys(sortBy.value)
|
||||||
|
for (const s of sortKeys) {
|
||||||
|
if (s === 'id') {
|
||||||
|
sortKeys.splice(s, 1)
|
||||||
|
hasIdFilter = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasIdFilter) {
|
||||||
|
sortKeys.push('id')
|
||||||
|
}
|
||||||
|
params.sort_by = sortKeys
|
||||||
|
params.order_by = sortKeys.map(s => sortBy.value[s])
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllTasksParams = computed(() => {
|
||||||
|
let loadParams = {...params.value}
|
||||||
|
|
||||||
|
if (search.value !== '') {
|
||||||
|
loadParams.s = search.value
|
||||||
|
}
|
||||||
|
|
||||||
|
loadParams = formatSortOrder(loadParams)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{listId: listId.value},
|
||||||
|
loadParams,
|
||||||
|
page.value || 1,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const taskCollectionService = shallowReactive(new TaskCollectionService())
|
||||||
|
const loading = computed(() => taskCollectionService.loading)
|
||||||
|
const totalPages = computed(() => taskCollectionService.totalPages)
|
||||||
|
|
||||||
|
const tasks = ref([])
|
||||||
|
async function loadTasks() {
|
||||||
|
tasks.value = await taskCollectionService.getAll(...getAllTasksParams.value)
|
||||||
|
return tasks.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
watch(() => route.query, (query) => {
|
||||||
|
const { page: pageQueryValue, search: searchQuery } = query
|
||||||
|
if (searchQuery !== undefined) {
|
||||||
|
search.value = searchQuery
|
||||||
|
}
|
||||||
|
if (pageQueryValue !== undefined) {
|
||||||
|
page.value = parseInt(pageQueryValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
|
||||||
|
// Only listen for query path changes
|
||||||
|
watch(() => JSON.stringify(getAllTasksParams.value), (newParams, oldParams) => {
|
||||||
|
if (oldParams === newParams) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTasks()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks,
|
||||||
|
loading,
|
||||||
|
totalPages,
|
||||||
|
currentPage: page,
|
||||||
|
loadTasks,
|
||||||
|
searchTerm: search,
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,9 @@
|
||||||
import { computed, watchEffect } from 'vue'
|
import { computed, watchEffect } from 'vue'
|
||||||
import { setTitle } from '@/helpers/setTitle'
|
import { setTitle } from '@/helpers/setTitle'
|
||||||
|
|
||||||
import { ComputedGetter, ComputedRef } from '@vue/reactivity'
|
import { ComputedGetter } from '@vue/reactivity'
|
||||||
|
|
||||||
export function useTitle<T>(titleGetter: ComputedGetter<T>) : ComputedRef<T> {
|
export function useTitle(titleGetter: ComputedGetter<string>) {
|
||||||
const titleRef = computed(titleGetter)
|
const titleRef = computed(titleGetter)
|
||||||
|
|
||||||
watchEffect(() => setTitle(titleRef.value))
|
watchEffect(() => setTitle(titleRef.value))
|
||||||
|
|
|
@ -53,6 +53,7 @@ export async function refreshToken(persist: boolean): Promise<AxiosResponse> {
|
||||||
return response
|
return response
|
||||||
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
|
// @ts-ignore
|
||||||
throw new Error('Error renewing token: ', { cause: e })
|
throw new Error('Error renewing token: ', { cause: e })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Save the current list view to local storage
|
||||||
|
// We use local storage and not vuex here to make it persistent across reloads.
|
||||||
export const saveListView = (listId, routeName) => {
|
export const saveListView = (listId, routeName) => {
|
||||||
if (routeName.includes('settings.')) {
|
if (routeName.includes('settings.')) {
|
||||||
return
|
return
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export function setTitle(title) {
|
export function setTitle(title : undefined | string) {
|
||||||
document.title = (typeof title === 'undefined' || title === '')
|
document.title = (typeof title === 'undefined' || title === '')
|
||||||
? 'Vikunja'
|
? 'Vikunja'
|
||||||
: `${title} | Vikunja`
|
: `${title} | Vikunja`
|
|
@ -288,7 +288,7 @@ const getDateFromWeekday = (text: string): dateFoundResult => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDayFromText = (text: string) => {
|
const getDayFromText = (text: string) => {
|
||||||
const matcher = /(([1-2][0-9])|(3[01])|(0?[1-9]))(st|nd|rd|th|\.)/ig
|
const matcher = /($| )(([1-2][0-9])|(3[01])|(0?[1-9]))(st|nd|rd|th|\.)($| )/ig
|
||||||
const results = matcher.exec(text)
|
const results = matcher.exec(text)
|
||||||
if (results === null) {
|
if (results === null) {
|
||||||
return {
|
return {
|
||||||
|
@ -302,17 +302,17 @@ const getDayFromText = (text: string) => {
|
||||||
const day = parseInt(results[0])
|
const day = parseInt(results[0])
|
||||||
date.setDate(day)
|
date.setDate(day)
|
||||||
|
|
||||||
// If the parsed day is the 31st but the next month only has 30 days, setting the day to 31 will "overflow" the
|
// If the parsed day is the 31st (or 29+ and the next month is february) but the next month only has 30 days,
|
||||||
// date to the next month, but the first.
|
// setting the day to 31 will "overflow" the date to the next month, but the first.
|
||||||
// This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after
|
// This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after
|
||||||
// setting it for the first time and set it again if it isn't - that would mean the month overflowed.
|
// setting it for the first time and set it again if it isn't - that would mean the month overflowed.
|
||||||
if (day === 31 && date.getDate() !== day) {
|
while (date < now) {
|
||||||
date.setDate(day)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (date < now) {
|
|
||||||
date.setMonth(date.getMonth() + 1)
|
date.setMonth(date.getMonth() + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (date.getDate() !== day) {
|
||||||
|
date.setDate(day)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
foundText: results[0],
|
foundText: results[0],
|
||||||
|
|
|
@ -10,9 +10,6 @@ import {parseDateOrNull} from '@/helpers/parseDateOrNull'
|
||||||
const SUPPORTS_TRIGGERED_NOTIFICATION = 'Notification' in window && 'showTrigger' in Notification.prototype
|
const SUPPORTS_TRIGGERED_NOTIFICATION = 'Notification' in window && 'showTrigger' in Notification.prototype
|
||||||
|
|
||||||
export default class TaskModel extends AbstractModel {
|
export default class TaskModel extends AbstractModel {
|
||||||
|
|
||||||
defaultColor = '198CFF'
|
|
||||||
|
|
||||||
constructor(data) {
|
constructor(data) {
|
||||||
super(data)
|
super(data)
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {describe, it, expect} from 'vitest'
|
import {beforeEach, afterEach, describe, it, expect, vi} from 'vitest'
|
||||||
|
|
||||||
import {parseTaskText} from './parseTaskText'
|
import {parseTaskText} from './parseTaskText'
|
||||||
import {getDateFromText, getDateFromTextIn} from '../helpers/time/parseDate'
|
import {getDateFromText, getDateFromTextIn} from '../helpers/time/parseDate'
|
||||||
|
@ -6,6 +6,14 @@ import {calculateDayInterval} from '../helpers/time/calculateDayInterval'
|
||||||
import priorities from '../models/constants/priorities.json'
|
import priorities from '../models/constants/priorities.json'
|
||||||
|
|
||||||
describe('Parse Task Text', () => {
|
describe('Parse Task Text', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should return text with no intents as is', () => {
|
it('should return text with no intents as is', () => {
|
||||||
expect(parseTaskText('Lorem Ipsum').text).toBe('Lorem Ipsum')
|
expect(parseTaskText('Lorem Ipsum').text).toBe('Lorem Ipsum')
|
||||||
})
|
})
|
||||||
|
@ -32,7 +40,7 @@ describe('Parse Task Text', () => {
|
||||||
expect(result.assignees).toHaveLength(1)
|
expect(result.assignees).toHaveLength(1)
|
||||||
expect(result.assignees[0]).toBe('user')
|
expect(result.assignees[0]).toBe('user')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ignore email addresses', () => {
|
it('should ignore email addresses', () => {
|
||||||
const text = 'Lorem Ipsum email@example.com'
|
const text = 'Lorem Ipsum email@example.com'
|
||||||
const result = parseTaskText(text)
|
const result = parseTaskText(text)
|
||||||
|
@ -211,17 +219,36 @@ describe('Parse Task Text', () => {
|
||||||
expect(`${result.date.getHours()}:${result.date.getMinutes()}`).toBe('14:0')
|
expect(`${result.date.getHours()}:${result.date.getMinutes()}`).toBe('14:0')
|
||||||
})
|
})
|
||||||
it('should recognize dates of the month in the past but next month', () => {
|
it('should recognize dates of the month in the past but next month', () => {
|
||||||
const date = new Date()
|
const time = new Date(2022, 0, 15)
|
||||||
date.setDate(date.getDate() - 1)
|
vi.setSystemTime(time)
|
||||||
const result = parseTaskText(`Lorem Ipsum ${date.getDate()}nd`)
|
|
||||||
|
const result = parseTaskText(`Lorem Ipsum ${time.getDate() - 1}th`)
|
||||||
|
|
||||||
expect(result.text).toBe('Lorem Ipsum')
|
expect(result.text).toBe('Lorem Ipsum')
|
||||||
expect(result.date.getDate()).toBe(date.getDate())
|
expect(result.date.getDate()).toBe(time.getDate() - 1)
|
||||||
|
expect(result.date.getMonth()).toBe(time.getMonth() + 1)
|
||||||
|
})
|
||||||
|
it('should recognize dates of the month in the past but next month when february is the next month', () => {
|
||||||
|
const jan = new Date(2022, 0, 30)
|
||||||
|
vi.setSystemTime(jan)
|
||||||
|
|
||||||
const nextMonthWithDate = result.date.getDate() === 31
|
const result = parseTaskText(`Lorem Ipsum ${jan.getDate() - 1}th`)
|
||||||
? (date.getMonth() + 2) % 12
|
|
||||||
: (date.getMonth() + 1) % 12
|
const expectedDate = new Date(2022, 2, jan.getDate() - 1)
|
||||||
expect(result.date.getMonth()).toBe(nextMonthWithDate)
|
expect(result.text).toBe('Lorem Ipsum')
|
||||||
|
expect(result.date.getDate()).toBe(expectedDate.getDate())
|
||||||
|
expect(result.date.getMonth()).toBe(expectedDate.getMonth())
|
||||||
|
})
|
||||||
|
it('should recognize dates of the month in the past but next month when the next month has less days than this one', () => {
|
||||||
|
const mar = new Date(2022, 2, 32)
|
||||||
|
vi.setSystemTime(mar)
|
||||||
|
|
||||||
|
const result = parseTaskText(`Lorem Ipsum 31st`)
|
||||||
|
|
||||||
|
const expectedDate = new Date(2022, 4, 31)
|
||||||
|
expect(result.text).toBe('Lorem Ipsum')
|
||||||
|
expect(result.date.getDate()).toBe(expectedDate.getDate())
|
||||||
|
expect(result.date.getMonth()).toBe(expectedDate.getMonth())
|
||||||
})
|
})
|
||||||
it('should recognize dates of the month in the future', () => {
|
it('should recognize dates of the month in the future', () => {
|
||||||
const nextDay = new Date(+new Date() + 60 * 60 * 24 * 1000)
|
const nextDay = new Date(+new Date() + 60 * 60 * 24 * 1000)
|
||||||
|
@ -242,6 +269,12 @@ describe('Parse Task Text', () => {
|
||||||
expect(result.text).toBe('Lorem Ipsum github')
|
expect(result.text).toBe('Lorem Ipsum github')
|
||||||
expect(result.date).toBeNull()
|
expect(result.date).toBeNull()
|
||||||
})
|
})
|
||||||
|
it('should not recognize date number with no spacing around them', () => {
|
||||||
|
const result = parseTaskText('Lorem Ispum v1.1.1')
|
||||||
|
|
||||||
|
expect(result.text).toBe('Lorem Ispum v1.1.1')
|
||||||
|
expect(result.date).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
describe('Parse weekdays', () => {
|
describe('Parse weekdays', () => {
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { createRouter, createWebHistory, RouteLocation } from 'vue-router'
|
||||||
import {saveLastVisited} from '@/helpers/saveLastVisited'
|
import {saveLastVisited} from '@/helpers/saveLastVisited'
|
||||||
import {store} from '@/store'
|
import {store} from '@/store'
|
||||||
|
|
||||||
|
import {saveListView, getListView} from '@/helpers/saveListView'
|
||||||
|
|
||||||
import HomeComponent from '../views/Home.vue'
|
import HomeComponent from '../views/Home.vue'
|
||||||
import NotFoundComponent from '../views/404.vue'
|
import NotFoundComponent from '../views/404.vue'
|
||||||
import About from '../views/About.vue'
|
import About from '../views/About.vue'
|
||||||
|
@ -13,9 +15,8 @@ import DataExportDownload from '../views/user/DataExportDownload.vue'
|
||||||
// Tasks
|
// Tasks
|
||||||
import ShowTasksInRangeComponent from '../views/tasks/ShowTasksInRange.vue'
|
import ShowTasksInRangeComponent from '../views/tasks/ShowTasksInRange.vue'
|
||||||
import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth.vue'
|
import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth.vue'
|
||||||
import TaskDetailViewModal from '../views/tasks/TaskDetailViewModal.vue'
|
|
||||||
import TaskDetailView from '../views/tasks/TaskDetailView.vue'
|
|
||||||
import ListNamespaces from '../views/namespaces/ListNamespaces.vue'
|
import ListNamespaces from '../views/namespaces/ListNamespaces.vue'
|
||||||
|
import TaskDetailView from '../views/tasks/TaskDetailView.vue'
|
||||||
// Team Handling
|
// Team Handling
|
||||||
import ListTeamsComponent from '../views/teams/ListTeams.vue'
|
import ListTeamsComponent from '../views/teams/ListTeams.vue'
|
||||||
// Label Handling
|
// Label Handling
|
||||||
|
@ -25,11 +26,11 @@ import NewLabelComponent from '../views/labels/NewLabel.vue'
|
||||||
import MigrationComponent from '../views/migrator/Migrate.vue'
|
import MigrationComponent from '../views/migrator/Migrate.vue'
|
||||||
import MigrateServiceComponent from '../views/migrator/MigrateService.vue'
|
import MigrateServiceComponent from '../views/migrator/MigrateService.vue'
|
||||||
// List Views
|
// List Views
|
||||||
import ShowListComponent from '../views/list/ShowList.vue'
|
import ListList from '../views/list/ListList.vue'
|
||||||
import Kanban from '../views/list/views/Kanban.vue'
|
import ListGantt from '../views/list/ListGantt.vue'
|
||||||
import List from '../views/list/views/List.vue'
|
import ListTable from '../views/list/ListTable.vue'
|
||||||
import Gantt from '../views/list/views/Gantt.vue'
|
import ListKanban from '../views/list/ListKanban.vue'
|
||||||
import Table from '../views/list/views/Table.vue'
|
|
||||||
// List Settings
|
// List Settings
|
||||||
import ListSettingEdit from '../views/list/settings/edit.vue'
|
import ListSettingEdit from '../views/list/settings/edit.vue'
|
||||||
import ListSettingBackground from '../views/list/settings/background.vue'
|
import ListSettingBackground from '../views/list/settings/background.vue'
|
||||||
|
@ -80,7 +81,7 @@ const router = createRouter({
|
||||||
|
|
||||||
// Scroll to anchor should still work
|
// Scroll to anchor should still work
|
||||||
if (to.hash) {
|
if (to.hash) {
|
||||||
return {el: document.getElementById(to.hash.slice(1))}
|
return {el: to.hash}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise just scroll to the top
|
// Otherwise just scroll to the top
|
||||||
|
@ -201,320 +202,170 @@ const router = createRouter({
|
||||||
{
|
{
|
||||||
path: '/namespaces/new',
|
path: '/namespaces/new',
|
||||||
name: 'namespace.create',
|
name: 'namespace.create',
|
||||||
components: {
|
component: NewNamespaceComponent,
|
||||||
popup: NewNamespaceComponent,
|
meta: {
|
||||||
},
|
showAsModal: true,
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/namespaces/:id/list',
|
|
||||||
name: 'list.create',
|
|
||||||
components: {
|
|
||||||
popup: NewListComponent,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/namespaces/:id/settings/edit',
|
path: '/namespaces/:id/settings/edit',
|
||||||
name: 'namespace.settings.edit',
|
name: 'namespace.settings.edit',
|
||||||
components: {
|
component: NamespaceSettingEdit,
|
||||||
popup: NamespaceSettingEdit,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/namespaces/:id/settings/share',
|
path: '/namespaces/:namespaceId/settings/share',
|
||||||
name: 'namespace.settings.share',
|
name: 'namespace.settings.share',
|
||||||
components: {
|
component: NamespaceSettingShare,
|
||||||
popup: NamespaceSettingShare,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/namespaces/:id/settings/archive',
|
path: '/namespaces/:id/settings/archive',
|
||||||
name: 'namespace.settings.archive',
|
name: 'namespace.settings.archive',
|
||||||
components: {
|
component: NamespaceSettingArchive,
|
||||||
popup: NamespaceSettingArchive,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/namespaces/:id/settings/delete',
|
path: '/namespaces/:id/settings/delete',
|
||||||
name: 'namespace.settings.delete',
|
name: 'namespace.settings.delete',
|
||||||
components: {
|
component: NamespaceSettingDelete,
|
||||||
popup: NamespaceSettingDelete,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/tasks/:id',
|
path: '/tasks/:id',
|
||||||
name: 'task.detail',
|
name: 'task.detail',
|
||||||
component: TaskDetailView,
|
component: TaskDetailView,
|
||||||
|
props: route => ({ taskId: parseInt(route.params.id as string) }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/tasks/by/upcoming',
|
path: '/tasks/by/upcoming',
|
||||||
name: 'tasks.range',
|
name: 'tasks.range',
|
||||||
component: ShowTasksInRangeComponent,
|
component: ShowTasksInRangeComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/lists/new/:namespaceId/',
|
||||||
|
name: 'list.create',
|
||||||
|
component: NewListComponent,
|
||||||
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/lists/:listId/settings/edit',
|
path: '/lists/:listId/settings/edit',
|
||||||
name: 'list.settings.edit',
|
name: 'list.settings.edit',
|
||||||
components: {
|
component: ListSettingEdit,
|
||||||
popup: ListSettingEdit,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/lists/:listId/settings/background',
|
path: '/lists/:listId/settings/background',
|
||||||
name: 'list.settings.background',
|
name: 'list.settings.background',
|
||||||
components: {
|
component: ListSettingBackground,
|
||||||
popup: ListSettingBackground,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/lists/:listId/settings/duplicate',
|
path: '/lists/:listId/settings/duplicate',
|
||||||
name: 'list.settings.duplicate',
|
name: 'list.settings.duplicate',
|
||||||
components: {
|
component: ListSettingDuplicate,
|
||||||
popup: ListSettingDuplicate,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/lists/:listId/settings/share',
|
path: '/lists/:listId/settings/share',
|
||||||
name: 'list.settings.share',
|
name: 'list.settings.share',
|
||||||
components: {
|
component: ListSettingShare,
|
||||||
popup: ListSettingShare,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/lists/:listId/settings/delete',
|
path: '/lists/:listId/settings/delete',
|
||||||
name: 'list.settings.delete',
|
name: 'list.settings.delete',
|
||||||
components: {
|
component: ListSettingDelete,
|
||||||
popup: ListSettingDelete,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/lists/:listId/settings/archive',
|
path: '/lists/:listId/settings/archive',
|
||||||
name: 'list.settings.archive',
|
name: 'list.settings.archive',
|
||||||
components: {
|
component: ListSettingArchive,
|
||||||
popup: ListSettingArchive,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/lists/:listId/settings/edit',
|
path: '/lists/:listId/settings/edit',
|
||||||
name: 'filter.settings.edit',
|
name: 'filter.settings.edit',
|
||||||
components: {
|
component: FilterEdit,
|
||||||
popup: FilterEdit,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/lists/:listId/settings/delete',
|
path: '/lists/:listId/settings/delete',
|
||||||
name: 'filter.settings.delete',
|
name: 'filter.settings.delete',
|
||||||
components: {
|
component: FilterDelete,
|
||||||
popup: FilterDelete,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/lists/:listId',
|
path: '/lists/:listId',
|
||||||
name: 'list.index',
|
name: 'list.index',
|
||||||
component: ShowListComponent,
|
redirect(to) {
|
||||||
children: [
|
// Redirect the user to list view by default
|
||||||
{
|
|
||||||
path: '/lists/:listId/list',
|
const savedListView = getListView(to.params.listId)
|
||||||
name: 'list.list',
|
console.debug('Replaced list view with', savedListView)
|
||||||
component: List,
|
|
||||||
children: [
|
return {
|
||||||
{
|
name: router.hasRoute(savedListView)
|
||||||
path: '/tasks/:id',
|
? savedListView
|
||||||
name: 'task.list.detail',
|
: 'list.list',
|
||||||
component: TaskDetailViewModal,
|
params: {listId: to.params.listId},
|
||||||
},
|
}
|
||||||
{
|
},
|
||||||
path: '/lists/:listId/settings/edit',
|
},
|
||||||
name: 'list.list.settings.edit',
|
{
|
||||||
component: ListSettingEdit,
|
path: '/lists/:listId/list',
|
||||||
},
|
name: 'list.list',
|
||||||
{
|
component: ListList,
|
||||||
path: '/lists/:listId/settings/background',
|
beforeEnter: (to) => saveListView(to.params.listId, to.name),
|
||||||
name: 'list.list.settings.background',
|
props: route => ({ listId: parseInt(route.params.listId as string) }),
|
||||||
component: ListSettingBackground,
|
},
|
||||||
},
|
{
|
||||||
{
|
path: '/lists/:listId/gantt',
|
||||||
path: '/lists/:listId/settings/duplicate',
|
name: 'list.gantt',
|
||||||
name: 'list.list.settings.duplicate',
|
component: ListGantt,
|
||||||
component: ListSettingDuplicate,
|
beforeEnter: (to) => saveListView(to.params.listId, to.name),
|
||||||
},
|
props: route => ({ listId: parseInt(route.params.listId as string) }),
|
||||||
{
|
},
|
||||||
path: '/lists/:listId/settings/share',
|
{
|
||||||
name: 'list.list.settings.share',
|
path: '/lists/:listId/table',
|
||||||
component: ListSettingShare,
|
name: 'list.table',
|
||||||
},
|
component: ListTable,
|
||||||
{
|
beforeEnter: (to) => saveListView(to.params.listId, to.name),
|
||||||
path: '/lists/:listId/settings/delete',
|
props: route => ({ listId: parseInt(route.params.listId as string) }),
|
||||||
name: 'list.list.settings.delete',
|
},
|
||||||
component: ListSettingDelete,
|
{
|
||||||
},
|
path: '/lists/:listId/kanban',
|
||||||
{
|
name: 'list.kanban',
|
||||||
path: '/lists/:listId/settings/archive',
|
component: ListKanban,
|
||||||
name: 'list.list.settings.archive',
|
beforeEnter: (to) => saveListView(to.params.listId, to.name),
|
||||||
component: ListSettingArchive,
|
props: route => ({ listId: parseInt(route.params.listId as string) }),
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/edit',
|
|
||||||
name: 'filter.list.settings.edit',
|
|
||||||
component: FilterEdit,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/delete',
|
|
||||||
name: 'filter.list.settings.delete',
|
|
||||||
component: FilterDelete,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/gantt',
|
|
||||||
name: 'list.gantt',
|
|
||||||
component: Gantt,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '/tasks/:id',
|
|
||||||
name: 'task.gantt.detail',
|
|
||||||
component: TaskDetailViewModal,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/edit',
|
|
||||||
name: 'list.gantt.settings.edit',
|
|
||||||
component: ListSettingEdit,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/background',
|
|
||||||
name: 'list.gantt.settings.background',
|
|
||||||
component: ListSettingBackground,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/duplicate',
|
|
||||||
name: 'list.gantt.settings.duplicate',
|
|
||||||
component: ListSettingDuplicate,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/share',
|
|
||||||
name: 'list.gantt.settings.share',
|
|
||||||
component: ListSettingShare,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/delete',
|
|
||||||
name: 'list.gantt.settings.delete',
|
|
||||||
component: ListSettingDelete,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/archive',
|
|
||||||
name: 'list.gantt.settings.archive',
|
|
||||||
component: ListSettingArchive,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/edit',
|
|
||||||
name: 'filter.gantt.settings.edit',
|
|
||||||
component: FilterEdit,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/delete',
|
|
||||||
name: 'filter.gantt.settings.delete',
|
|
||||||
component: FilterDelete,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/table',
|
|
||||||
name: 'list.table',
|
|
||||||
component: Table,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/edit',
|
|
||||||
name: 'list.table.settings.edit',
|
|
||||||
component: ListSettingEdit,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/background',
|
|
||||||
name: 'list.table.settings.background',
|
|
||||||
component: ListSettingBackground,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/duplicate',
|
|
||||||
name: 'list.table.settings.duplicate',
|
|
||||||
component: ListSettingDuplicate,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/share',
|
|
||||||
name: 'list.table.settings.share',
|
|
||||||
component: ListSettingShare,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/delete',
|
|
||||||
name: 'list.table.settings.delete',
|
|
||||||
component: ListSettingDelete,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/archive',
|
|
||||||
name: 'list.table.settings.archive',
|
|
||||||
component: ListSettingArchive,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/edit',
|
|
||||||
name: 'filter.table.settings.edit',
|
|
||||||
component: FilterEdit,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/delete',
|
|
||||||
name: 'filter.table.settings.delete',
|
|
||||||
component: FilterDelete,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/kanban',
|
|
||||||
name: 'list.kanban',
|
|
||||||
component: Kanban,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '/tasks/:id',
|
|
||||||
name: 'task.kanban.detail',
|
|
||||||
component: TaskDetailViewModal,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/edit',
|
|
||||||
name: 'list.kanban.settings.edit',
|
|
||||||
component: ListSettingEdit,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/background',
|
|
||||||
name: 'list.kanban.settings.background',
|
|
||||||
component: ListSettingBackground,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/duplicate',
|
|
||||||
name: 'list.kanban.settings.duplicate',
|
|
||||||
component: ListSettingDuplicate,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/share',
|
|
||||||
name: 'list.kanban.settings.share',
|
|
||||||
component: ListSettingShare,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/delete',
|
|
||||||
name: 'list.kanban.settings.delete',
|
|
||||||
component: ListSettingDelete,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/archive',
|
|
||||||
name: 'list.kanban.settings.archive',
|
|
||||||
component: ListSettingArchive,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/edit',
|
|
||||||
name: 'filter.kanban.settings.edit',
|
|
||||||
component: FilterEdit,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/lists/:listId/settings/delete',
|
|
||||||
name: 'filter.kanban.settings.delete',
|
|
||||||
component: FilterDelete,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/teams',
|
path: '/teams',
|
||||||
|
@ -524,8 +375,9 @@ const router = createRouter({
|
||||||
{
|
{
|
||||||
path: '/teams/new',
|
path: '/teams/new',
|
||||||
name: 'teams.create',
|
name: 'teams.create',
|
||||||
components: {
|
component: NewTeamComponent,
|
||||||
popup: NewTeamComponent,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -541,8 +393,9 @@ const router = createRouter({
|
||||||
{
|
{
|
||||||
path: '/labels/new',
|
path: '/labels/new',
|
||||||
name: 'labels.create',
|
name: 'labels.create',
|
||||||
components: {
|
component: NewLabelComponent,
|
||||||
popup: NewLabelComponent,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -558,8 +411,9 @@ const router = createRouter({
|
||||||
{
|
{
|
||||||
path: '/filters/new',
|
path: '/filters/new',
|
||||||
name: 'filters.create',
|
name: 'filters.create',
|
||||||
components: {
|
component: FilterNew,
|
||||||
popup: FilterNew,
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -575,11 +429,7 @@ const router = createRouter({
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
export function getAuthForRoute(route: RouteLocation) {
|
||||||
return checkAuth(to)
|
|
||||||
})
|
|
||||||
|
|
||||||
function checkAuth(route: RouteLocation) {
|
|
||||||
const authUser = store.getters['auth/authUser']
|
const authUser = store.getters['auth/authUser']
|
||||||
const authLinkShare = store.getters['auth/authLinkShare']
|
const authLinkShare = store.getters['auth/authLinkShare']
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,8 @@ import lists from './modules/lists'
|
||||||
import attachments from './modules/attachments'
|
import attachments from './modules/attachments'
|
||||||
import labels from './modules/labels'
|
import labels from './modules/labels'
|
||||||
|
|
||||||
|
import ListModel from '@/models/list'
|
||||||
|
|
||||||
import ListService from '../services/list'
|
import ListService from '../services/list'
|
||||||
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
|
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
|
||||||
|
|
||||||
|
@ -37,13 +39,15 @@ export const store = createStore({
|
||||||
loading: false,
|
loading: false,
|
||||||
loadingModule: null,
|
loadingModule: null,
|
||||||
// This is used to highlight the current list in menu for all list related views
|
// This is used to highlight the current list in menu for all list related views
|
||||||
currentList: {id: 0},
|
currentList: new ListModel({
|
||||||
|
id: 0,
|
||||||
|
isArchived: false,
|
||||||
|
}),
|
||||||
background: '',
|
background: '',
|
||||||
hasTasks: false,
|
hasTasks: false,
|
||||||
menuActive: true,
|
menuActive: true,
|
||||||
keyboardShortcutsActive: false,
|
keyboardShortcutsActive: false,
|
||||||
quickActionsActive: false,
|
quickActionsActive: false,
|
||||||
vikunjaReady: false,
|
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
[LOADING](state, loading) {
|
[LOADING](state, loading) {
|
||||||
|
@ -79,9 +83,6 @@ export const store = createStore({
|
||||||
[BACKGROUND](state, background) {
|
[BACKGROUND](state, background) {
|
||||||
state.background = background
|
state.background = background
|
||||||
},
|
},
|
||||||
vikunjaReady(state, ready) {
|
|
||||||
state.vikunjaReady = ready
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async [CURRENT_LIST]({state, commit}, currentList) {
|
async [CURRENT_LIST]({state, commit}, currentList) {
|
||||||
|
@ -136,10 +137,9 @@ export const store = createStore({
|
||||||
|
|
||||||
commit(CURRENT_LIST, currentList)
|
commit(CURRENT_LIST, currentList)
|
||||||
},
|
},
|
||||||
async loadApp({commit, dispatch}) {
|
async loadApp({dispatch}) {
|
||||||
await checkAndSetApiUrl(window.API_URL)
|
await checkAndSetApiUrl(window.API_URL)
|
||||||
await dispatch('auth/checkAuth')
|
await dispatch('auth/checkAuth')
|
||||||
commit('vikunjaReady', true)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import {HTTPFactory} from '@/http-common'
|
import {HTTPFactory} from '@/http-common'
|
||||||
import {getCurrentLanguage, saveLanguage} from '@/i18n'
|
import {i18n, getCurrentLanguage, saveLanguage} from '@/i18n'
|
||||||
import {LOADING} from '../mutation-types'
|
import {LOADING} from '../mutation-types'
|
||||||
import UserModel from '@/models/user'
|
import UserModel from '@/models/user'
|
||||||
import UserSettingsService from '@/services/userSettings'
|
import UserSettingsService from '@/services/userSettings'
|
||||||
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
|
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
|
||||||
import {setLoading} from '@/store/helper'
|
import {setLoading} from '@/store/helper'
|
||||||
import {i18n} from '@/i18n'
|
|
||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
|
import {redirectToProvider} from '@/helpers/redirectToProvider'
|
||||||
|
|
||||||
const AUTH_TYPES = {
|
const AUTH_TYPES = {
|
||||||
'UNKNOWN': 0,
|
'UNKNOWN': 0,
|
||||||
|
@ -201,7 +201,19 @@ export default {
|
||||||
ctx.commit('authenticated', authenticated)
|
ctx.commit('authenticated', authenticated)
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
ctx.commit('info', null)
|
ctx.commit('info', null)
|
||||||
ctx.dispatch('config/redirectToProviderIfNothingElseIsEnabled', null, {root: true})
|
ctx.dispatch('redirectToProviderIfNothingElseIsEnabled')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
redirectToProviderIfNothingElseIsEnabled({rootState}) {
|
||||||
|
const {auth} = rootState.config
|
||||||
|
if (
|
||||||
|
auth.local.enabled === false &&
|
||||||
|
auth.openidConnect.enabled &&
|
||||||
|
auth.openidConnect.providers?.length === 1 &&
|
||||||
|
window.location.pathname.startsWith('/login') // Kinda hacky, but prevents an endless loop.
|
||||||
|
) {
|
||||||
|
redirectToProvider(auth.openidConnect.providers[0], auth.openidConnect.redirectUrl)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import {CONFIG} from '../mutation-types'
|
import {CONFIG} from '../mutation-types'
|
||||||
import {HTTPFactory} from '@/http-common'
|
import {HTTPFactory} from '@/http-common'
|
||||||
import {objectToCamelCase} from '@/helpers/case'
|
import {objectToCamelCase} from '@/helpers/case'
|
||||||
import {redirectToProvider} from '../../helpers/redirectToProvider'
|
|
||||||
import {parseURL} from 'ufo'
|
import {parseURL} from 'ufo'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -75,16 +74,5 @@ export default {
|
||||||
ctx.commit(CONFIG, info)
|
ctx.commit(CONFIG, info)
|
||||||
return info
|
return info
|
||||||
},
|
},
|
||||||
|
|
||||||
redirectToProviderIfNothingElseIsEnabled(ctx) {
|
|
||||||
if (ctx.state.auth.local.enabled === false &&
|
|
||||||
ctx.state.auth.openidConnect.enabled &&
|
|
||||||
ctx.state.auth.openidConnect.providers &&
|
|
||||||
ctx.state.auth.openidConnect.providers.length === 1 &&
|
|
||||||
window.location.pathname.startsWith('/login') // Kinda hacky, but prevents an endless loop.
|
|
||||||
) {
|
|
||||||
redirectToProvider(ctx.state.auth.openidConnect.providers[0])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
|
@ -23,8 +23,6 @@ export default {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: direct manipulation of the prop
|
|
||||||
// might not be a problem since this is happening in the mutation
|
|
||||||
if (!namespace.lists || namespace.lists.length === 0) {
|
if (!namespace.lists || namespace.lists.length === 0) {
|
||||||
namespace.lists = state.namespaces[namespaceIndex].lists
|
namespace.lists = state.namespaces[namespaceIndex].lists
|
||||||
}
|
}
|
||||||
|
@ -136,8 +134,8 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
loadNamespacesIfFavoritesDontExist(ctx) {
|
loadNamespacesIfFavoritesDontExist(ctx) {
|
||||||
// The first namespace should be the one holding all favorites
|
// The first or second namespace should be the one holding all favorites
|
||||||
if (ctx.state.namespaces[0].id !== -2) {
|
if (ctx.state.namespaces[0].id !== -2 && ctx.state.namespaces[1]?.id !== -2) {
|
||||||
return ctx.dispatch('loadNamespaces')
|
return ctx.dispatch('loadNamespaces')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -16,6 +16,8 @@
|
||||||
// since $tablet is defined by bulma we can just define it after importing the utilities
|
// since $tablet is defined by bulma we can just define it after importing the utilities
|
||||||
$mobile: math.div($tablet, 2);
|
$mobile: math.div($tablet, 2);
|
||||||
|
|
||||||
|
@import "mixins";
|
||||||
|
|
||||||
$family-sans-serif: 'Open Sans', Helvetica, Arial, sans-serif;
|
$family-sans-serif: 'Open Sans', Helvetica, Arial, sans-serif;
|
||||||
$vikunja-font: 'Quicksand', sans-serif;
|
$vikunja-font: 'Quicksand', sans-serif;
|
||||||
|
|
||||||
|
|
12
src/styles/mixins.scss
Normal file
12
src/styles/mixins.scss
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
/* Transitions */
|
||||||
|
@mixin modal-transition() {
|
||||||
|
.modal-enter,
|
||||||
|
.modal-leave-active {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter .modal-container,
|
||||||
|
.modal-leave-active .modal-container {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,7 +14,7 @@
|
||||||
.box,
|
.box,
|
||||||
.card,
|
.card,
|
||||||
.switch-view,
|
.switch-view,
|
||||||
.table-view .button,
|
.list-table .button,
|
||||||
.filter-container .button,
|
.filter-container .button,
|
||||||
.search .button {
|
.search .button {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|
7
src/types/shims-vue.d.ts
vendored
7
src/types/shims-vue.d.ts
vendored
|
@ -1,8 +1,11 @@
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
import { CompatVue } from '@vue/runtime-dom'
|
import { CompatVue } from '@vue/runtime-dom'
|
||||||
const Vue: CompatVue
|
const Vue: CompatVue
|
||||||
export default Vue
|
export default Vue
|
||||||
export * from '@vue/runtime-dom'
|
export * from '@vue/runtime-dom'
|
||||||
|
|
||||||
|
const { configureCompat } = Vue
|
||||||
|
export { configureCompat }
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://next.vuex.vuejs.org/guide/migrating-to-4-0-from-3-x.html#typescript-support
|
// https://next.vuex.vuejs.org/guide/migrating-to-4-0-from-3-x.html#typescript-support
|
||||||
|
|
1
src/types/vue-flatpickr-component.d.ts
vendored
Normal file
1
src/types/vue-flatpickr-component.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
declare module 'vue-flatpickr-component';
|
|
@ -23,7 +23,7 @@
|
||||||
<template v-if="defaultNamespaceId > 0">
|
<template v-if="defaultNamespaceId > 0">
|
||||||
<p class="mt-4">{{ $t('home.list.newText') }}</p>
|
<p class="mt-4">{{ $t('home.list.newText') }}</p>
|
||||||
<x-button
|
<x-button
|
||||||
:to="{ name: 'list.create', params: { id: defaultNamespaceId } }"
|
:to="{ name: 'list.create', params: { namespaceId: defaultNamespaceId } }"
|
||||||
:shadow="false"
|
:shadow="false"
|
||||||
class="ml-2"
|
class="ml-2"
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="gantt-chart-container">
|
<ListWrapper class="list-gantt" :list-id="props.listId" viewName="gantt">
|
||||||
<card :padding="false" class="has-overflow">
|
<template #header>
|
||||||
<div class="gantt-options p-4">
|
<div class="gantt-options p-4">
|
||||||
<fancycheckbox class="is-block" v-model="showTaskswithoutDates">
|
<fancycheckbox class="is-block" v-model="showTaskswithoutDates">
|
||||||
{{ $t('list.gantt.showTasksWithoutDates') }}
|
{{ $t('list.gantt.showTasksWithoutDates') }}
|
||||||
|
@ -44,65 +44,64 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default>
|
||||||
|
<div class="gantt-chart-container">
|
||||||
|
<card :padding="false" class="has-overflow">
|
||||||
|
|
||||||
<gantt-chart
|
<gantt-chart
|
||||||
:date-from="dateFrom"
|
:date-from="dateFrom"
|
||||||
:date-to="dateTo"
|
:date-to="dateTo"
|
||||||
:day-width="dayWidth"
|
:day-width="dayWidth"
|
||||||
:list-id="Number($route.params.listId)"
|
:list-id="props.listId"
|
||||||
:show-taskswithout-dates="showTaskswithoutDates"
|
:show-taskswithout-dates="showTaskswithoutDates"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- This router view is used to show the task popup while keeping the gantt chart itself -->
|
|
||||||
<router-view v-slot="{ Component }">
|
|
||||||
<transition name="modal">
|
|
||||||
<component :is="Component" />
|
|
||||||
</transition>
|
|
||||||
</router-view>
|
|
||||||
|
|
||||||
</card>
|
</card>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
import GanttChart from '../../../components/tasks/gantt-component'
|
import { ref, computed } from 'vue'
|
||||||
import flatPickr from 'vue-flatpickr-component'
|
import flatPickr from 'vue-flatpickr-component'
|
||||||
import Fancycheckbox from '../../../components/input/fancycheckbox'
|
|
||||||
import {saveListView} from '@/helpers/saveListView'
|
|
||||||
|
|
||||||
export default {
|
import { useI18n } from 'vue-i18n'
|
||||||
name: 'Gantt',
|
import { useStore } from 'vuex'
|
||||||
components: {
|
|
||||||
Fancycheckbox,
|
import ListWrapper from './ListWrapper.vue'
|
||||||
flatPickr,
|
import GanttChart from '@/components/tasks/gantt-component.vue'
|
||||||
GanttChart,
|
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
listId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
created() {
|
})
|
||||||
// Save the current list view to local storage
|
|
||||||
// We use local storage and not vuex here to make it persistent across reloads.
|
const DEFAULT_DAY_COUNT = 35
|
||||||
saveListView(this.$route.params.listId, this.$route.name)
|
|
||||||
|
const showTaskswithoutDates = ref(false)
|
||||||
|
const dayWidth = ref(DEFAULT_DAY_COUNT)
|
||||||
|
|
||||||
|
const now = ref(new Date())
|
||||||
|
const dateFrom = ref(new Date((new Date()).setDate(now.value.getDate() - 15)))
|
||||||
|
const dateTo = ref(new Date((new Date()).setDate(now.value.getDate() + 30)))
|
||||||
|
|
||||||
|
const {t} = useI18n()
|
||||||
|
const store = useStore()
|
||||||
|
const flatPickerConfig = computed(() => ({
|
||||||
|
altFormat: t('date.altFormatShort'),
|
||||||
|
altInput: true,
|
||||||
|
dateFormat: 'Y-m-d',
|
||||||
|
enableTime: false,
|
||||||
|
locale: {
|
||||||
|
firstDayOfWeek: store.state.auth.settings.weekStart,
|
||||||
},
|
},
|
||||||
data() {
|
}))
|
||||||
return {
|
|
||||||
showTaskswithoutDates: false,
|
|
||||||
dayWidth: 35,
|
|
||||||
dateFrom: new Date((new Date()).setDate((new Date()).getDate() - 15)),
|
|
||||||
dateTo: new Date((new Date()).setDate((new Date()).getDate() + 30)),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
flatPickerConfig() {
|
|
||||||
return {
|
|
||||||
altFormat: this.$t('date.altFormatShort'),
|
|
||||||
altInput: true,
|
|
||||||
dateFormat: 'Y-m-d',
|
|
||||||
enableTime: false,
|
|
||||||
locale: {
|
|
||||||
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
|
@ -1,17 +1,22 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="kanban-view">
|
<ListWrapper class="list-kanban" :list-id="listId" viewName="kanban">
|
||||||
<div class="filter-container" v-if="isSavedFilter">
|
<template #header>
|
||||||
|
<div class="filter-container" v-if="isSavedFilter">
|
||||||
<div class="items">
|
<div class="items">
|
||||||
<filter-popup
|
<filter-popup
|
||||||
v-model="params"
|
v-model="params"
|
||||||
@update:modelValue="loadBuckets"
|
@update:modelValue="loadBuckets"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
</template>
|
||||||
:class="{ 'is-loading': loading && !oneTaskUpdating}"
|
|
||||||
class="kanban kanban-bucket-container loader-container"
|
<template #default>
|
||||||
>
|
<div class="kanban-view">
|
||||||
|
<div
|
||||||
|
:class="{ 'is-loading': loading && !oneTaskUpdating}"
|
||||||
|
class="kanban kanban-bucket-container loader-container"
|
||||||
|
>
|
||||||
<draggable
|
<draggable
|
||||||
v-bind="dragOptions"
|
v-bind="dragOptions"
|
||||||
:modelValue="buckets"
|
:modelValue="buckets"
|
||||||
|
@ -204,18 +209,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- This router view is used to show the task popup while keeping the kanban board itself -->
|
|
||||||
<router-view v-slot="{ Component }">
|
|
||||||
<transition name="modal">
|
|
||||||
<component :is="Component"/>
|
|
||||||
</transition>
|
|
||||||
</router-view>
|
|
||||||
|
|
||||||
<transition name="modal">
|
<transition name="modal">
|
||||||
<modal
|
<modal
|
||||||
|
v-if="showBucketDeleteModal"
|
||||||
@close="showBucketDeleteModal = false"
|
@close="showBucketDeleteModal = false"
|
||||||
@submit="deleteBucket()"
|
@submit="deleteBucket()"
|
||||||
v-if="showBucketDeleteModal"
|
|
||||||
>
|
>
|
||||||
<template #header><span>{{ $t('list.kanban.deleteHeaderBucket') }}</span></template>
|
<template #header><span>{{ $t('list.kanban.deleteHeaderBucket') }}</span></template>
|
||||||
|
|
||||||
|
@ -225,22 +223,24 @@
|
||||||
</template>
|
</template>
|
||||||
</modal>
|
</modal>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import cloneDeep from 'lodash.clonedeep'
|
import cloneDeep from 'lodash.clonedeep'
|
||||||
|
|
||||||
import BucketModel from '../../../models/bucket'
|
import BucketModel from '../../models/bucket'
|
||||||
import {mapState} from 'vuex'
|
import {mapState} from 'vuex'
|
||||||
import {saveListView} from '@/helpers/saveListView'
|
import Rights from '../../models/constants/rights.json'
|
||||||
import Rights from '../../../models/constants/rights.json'
|
|
||||||
import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
|
import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
|
||||||
|
import ListWrapper from './ListWrapper'
|
||||||
import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
||||||
import Dropdown from '@/components/misc/dropdown.vue'
|
import Dropdown from '@/components/misc/dropdown.vue'
|
||||||
import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState'
|
import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState'
|
||||||
import {calculateItemPosition} from '../../../helpers/calculateItemPosition'
|
import {calculateItemPosition} from '../../helpers/calculateItemPosition'
|
||||||
import KanbanCard from '@/components/tasks/partials/kanban-card'
|
import KanbanCard from '@/components/tasks/partials/kanban-card'
|
||||||
|
|
||||||
const DRAG_OPTIONS = {
|
const DRAG_OPTIONS = {
|
||||||
|
@ -257,11 +257,20 @@ const MIN_SCROLL_HEIGHT_PERCENT = 0.25
|
||||||
export default {
|
export default {
|
||||||
name: 'Kanban',
|
name: 'Kanban',
|
||||||
components: {
|
components: {
|
||||||
|
ListWrapper,
|
||||||
KanbanCard,
|
KanbanCard,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
FilterPopup,
|
FilterPopup,
|
||||||
draggable,
|
draggable,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
listId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
taskContainerRefs: {},
|
taskContainerRefs: {},
|
||||||
|
@ -296,11 +305,7 @@ export default {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
|
||||||
// Save the current list view to local storage
|
|
||||||
// We use local storage and not vuex here to make it persistent across reloads.
|
|
||||||
saveListView(this.$route.params.listId, this.$route.name)
|
|
||||||
},
|
|
||||||
watch: {
|
watch: {
|
||||||
loadBucketParameter: {
|
loadBucketParameter: {
|
||||||
handler: 'loadBuckets',
|
handler: 'loadBuckets',
|
||||||
|
@ -313,7 +318,7 @@ export default {
|
||||||
},
|
},
|
||||||
loadBucketParameter() {
|
loadBucketParameter() {
|
||||||
return {
|
return {
|
||||||
listId: this.$route.params.listId,
|
listId: this.listId,
|
||||||
params: this.params,
|
params: this.params,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -353,16 +358,11 @@ export default {
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
loadBuckets() {
|
loadBuckets() {
|
||||||
// Prevent trying to load buckets if the task popup view is active
|
|
||||||
if (this.$route.name !== 'list.kanban') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const {listId, params} = this.loadBucketParameter
|
const {listId, params} = this.loadBucketParameter
|
||||||
|
|
||||||
this.collapsedBuckets = getCollapsedBucketState(listId)
|
this.collapsedBuckets = getCollapsedBucketState(listId)
|
||||||
|
|
||||||
console.debug(`Loading buckets, loadedListId = ${this.loadedListId}, $route.params =`, this.$route.params)
|
console.debug(`Loading buckets, loadedListId = ${this.loadedListId}, $attrs = ${this.$attrs} $route.params =`, this.$route.params)
|
||||||
|
|
||||||
this.$store.dispatch('kanban/loadBucketsForList', {listId, params})
|
this.$store.dispatch('kanban/loadBucketsForList', {listId, params})
|
||||||
},
|
},
|
||||||
|
@ -437,7 +437,7 @@ export default {
|
||||||
const task = await this.$store.dispatch('tasks/createNewTask', {
|
const task = await this.$store.dispatch('tasks/createNewTask', {
|
||||||
title: this.newTaskText,
|
title: this.newTaskText,
|
||||||
bucketId,
|
bucketId,
|
||||||
listId: this.$route.params.listId,
|
listId: this.listId,
|
||||||
})
|
})
|
||||||
this.newTaskText = ''
|
this.newTaskText = ''
|
||||||
this.$store.commit('kanban/addTaskToBucket', task)
|
this.$store.commit('kanban/addTaskToBucket', task)
|
||||||
|
@ -459,7 +459,7 @@ export default {
|
||||||
|
|
||||||
const newBucket = new BucketModel({
|
const newBucket = new BucketModel({
|
||||||
title: this.newBucketTitle,
|
title: this.newBucketTitle,
|
||||||
listId: parseInt(this.$route.params.listId),
|
listId: this.listId,
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.$store.dispatch('kanban/createBucket', newBucket)
|
await this.$store.dispatch('kanban/createBucket', newBucket)
|
||||||
|
@ -479,7 +479,7 @@ export default {
|
||||||
async deleteBucket() {
|
async deleteBucket() {
|
||||||
const bucket = new BucketModel({
|
const bucket = new BucketModel({
|
||||||
id: this.bucketToDelete,
|
id: this.bucketToDelete,
|
||||||
listId: parseInt(this.$route.params.listId),
|
listId: this.listId,
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -567,7 +567,7 @@ export default {
|
||||||
|
|
||||||
collapseBucket(bucket) {
|
collapseBucket(bucket) {
|
||||||
this.collapsedBuckets[bucket.id] = true
|
this.collapsedBuckets[bucket.id] = true
|
||||||
saveCollapsedBucketState(this.$route.params.listId, this.collapsedBuckets)
|
saveCollapsedBucketState(this.listId, this.collapsedBuckets)
|
||||||
},
|
},
|
||||||
unCollapseBucket(bucket) {
|
unCollapseBucket(bucket) {
|
||||||
if (!this.collapsedBuckets[bucket.id]) {
|
if (!this.collapsedBuckets[bucket.id]) {
|
||||||
|
@ -575,7 +575,7 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.collapsedBuckets[bucket.id] = false
|
this.collapsedBuckets[bucket.id] = false
|
||||||
saveCollapsedBucketState(this.$route.params.listId, this.collapsedBuckets)
|
saveCollapsedBucketState(this.listId, this.collapsedBuckets)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -746,4 +746,6 @@ $filter-container-height: '1rem - #{$switch-view-height}';
|
||||||
.move-card-leave-active {
|
.move-card-leave-active {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include modal-transition();
|
||||||
</style>
|
</style>
|
|
@ -1,8 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<ListWrapper class="list-list" :list-id="listId" viewName="list">
|
||||||
:class="{ 'is-loading': taskCollectionService.loading }"
|
<template #header>
|
||||||
class="loader-container is-max-width-desktop list-view"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="filter-container"
|
class="filter-container"
|
||||||
v-if="list.isSavedFilter && !list.isSavedFilter()"
|
v-if="list.isSavedFilter && !list.isSavedFilter()"
|
||||||
|
@ -26,7 +24,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<x-button
|
<x-button
|
||||||
:loading="taskCollectionService.loading"
|
:loading="loading"
|
||||||
@click="searchTasks"
|
@click="searchTasks"
|
||||||
:shadow="false"
|
:shadow="false"
|
||||||
>
|
>
|
||||||
|
@ -47,7 +45,13 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default>
|
||||||
|
<div
|
||||||
|
:class="{ 'is-loading': loading }"
|
||||||
|
class="loader-container is-max-width-desktop list-view"
|
||||||
|
>
|
||||||
<card :padding="false" :has-content="false" class="has-overflow">
|
<card :padding="false" :has-content="false" class="has-overflow">
|
||||||
<template
|
<template
|
||||||
v-if="!list.isArchived && canWrite && list.id > 0"
|
v-if="!list.isArchived && canWrite && list.id > 0"
|
||||||
|
@ -59,7 +63,7 @@
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<nothing v-if="ctaVisible && tasks.length === 0 && !taskCollectionService.loading">
|
<nothing v-if="ctaVisible && tasks.length === 0 && !loading">
|
||||||
{{ $t('list.list.empty') }}
|
{{ $t('list.list.empty') }}
|
||||||
<a @click="focusNewTaskInput()">
|
<a @click="focusNewTaskInput()">
|
||||||
{{ $t('list.list.newTaskCta') }}
|
{{ $t('list.list.newTaskCta') }}
|
||||||
|
@ -90,7 +94,6 @@
|
||||||
:disabled="!canWrite"
|
:disabled="!canWrite"
|
||||||
:the-task="t"
|
:the-task="t"
|
||||||
@taskUpdated="updateTasks"
|
@taskUpdated="updateTasks"
|
||||||
task-detail-route="task.detail"
|
|
||||||
>
|
>
|
||||||
<template v-if="canWrite">
|
<template v-if="canWrite">
|
||||||
<span class="icon handle">
|
<span class="icon handle">
|
||||||
|
@ -118,40 +121,33 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
:total-pages="taskCollectionService.totalPages"
|
:total-pages="totalPages"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
/>
|
/>
|
||||||
</card>
|
</card>
|
||||||
|
</div>
|
||||||
<!-- This router view is used to show the task popup while keeping the kanban board itself -->
|
</template>
|
||||||
<router-view v-slot="{ Component }">
|
</ListWrapper>
|
||||||
<transition name="modal">
|
|
||||||
<component :is="Component"/>
|
|
||||||
</transition>
|
|
||||||
</router-view>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import TaskService from '../../../services/task'
|
import { ref, toRef, defineComponent } from 'vue'
|
||||||
import TaskModel from '../../../models/task'
|
|
||||||
|
|
||||||
import EditTask from '../../../components/tasks/edit-task'
|
import ListWrapper from './ListWrapper.vue'
|
||||||
import AddTask from '../../../components/tasks/add-task'
|
import EditTask from '@/components/tasks/edit-task'
|
||||||
import SingleTaskInList from '../../../components/tasks/partials/singleTaskInList'
|
import AddTask from '@/components/tasks/add-task'
|
||||||
import taskList from '../../../components/tasks/mixins/taskList'
|
import SingleTaskInList from '@/components/tasks/partials/singleTaskInList'
|
||||||
import {saveListView} from '@/helpers/saveListView'
|
import { useTaskList } from '@/composables/taskList'
|
||||||
import Rights from '../../../models/constants/rights.json'
|
import Rights from '../../models/constants/rights.json'
|
||||||
import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
||||||
import {HAS_TASKS} from '@/store/mutation-types'
|
import {HAS_TASKS} from '@/store/mutation-types'
|
||||||
import Nothing from '@/components/misc/nothing.vue'
|
import Nothing from '@/components/misc/nothing.vue'
|
||||||
import Pagination from '@/components/misc/pagination.vue'
|
import Pagination from '@/components/misc/pagination.vue'
|
||||||
import Popup from '@/components/misc/popup'
|
import {ALPHABETICAL_SORT} from '@/components/list/partials/filters.vue'
|
||||||
import { ALPHABETICAL_SORT } from '@/components/list/partials/filters'
|
|
||||||
|
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import {calculateItemPosition} from '../../../helpers/calculateItemPosition'
|
import {calculateItemPosition} from '../../helpers/calculateItemPosition'
|
||||||
|
|
||||||
function sortTasks(tasks) {
|
function sortTasks(tasks) {
|
||||||
if (tasks === null || tasks === []) {
|
if (tasks === null || tasks === []) {
|
||||||
|
@ -171,13 +167,18 @@ function sortTasks(tasks) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: 'List',
|
name: 'List',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
listId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
taskService: new TaskService(),
|
|
||||||
isTaskEdit: false,
|
|
||||||
taskEditTask: TaskModel,
|
|
||||||
ctaVisible: false,
|
ctaVisible: false,
|
||||||
showTaskSearch: false,
|
showTaskSearch: false,
|
||||||
|
|
||||||
|
@ -188,11 +189,8 @@ export default {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixins: [
|
|
||||||
taskList,
|
|
||||||
],
|
|
||||||
components: {
|
components: {
|
||||||
Popup,
|
ListWrapper,
|
||||||
Nothing,
|
Nothing,
|
||||||
FilterPopup,
|
FilterPopup,
|
||||||
SingleTaskInList,
|
SingleTaskInList,
|
||||||
|
@ -201,10 +199,24 @@ export default {
|
||||||
draggable,
|
draggable,
|
||||||
Pagination,
|
Pagination,
|
||||||
},
|
},
|
||||||
created() {
|
|
||||||
// Save the current list view to local storage
|
setup(props) {
|
||||||
// We use local storage and not vuex here to make it persistent across reloads.
|
const taskEditTask = ref(null)
|
||||||
saveListView(this.$route.params.listId, this.$route.name)
|
const isTaskEdit = ref(false)
|
||||||
|
|
||||||
|
// This function initializes the tasks page and loads the first page of tasks
|
||||||
|
// function beforeLoad() {
|
||||||
|
// taskEditTask.value = null
|
||||||
|
// isTaskEdit.value = false
|
||||||
|
// }
|
||||||
|
|
||||||
|
const taskList = useTaskList(toRef(props, 'listId'))
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskEditTask,
|
||||||
|
isTaskEdit,
|
||||||
|
...taskList,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isAlphabeticalSorting() {
|
isAlphabeticalSorting() {
|
||||||
|
@ -244,17 +256,11 @@ export default {
|
||||||
// When clicking on the search button, @blur from the input is fired. If we
|
// When clicking on the search button, @blur from the input is fired. If we
|
||||||
// would then directly hide the whole search bar directly, no click event
|
// would then directly hide the whole search bar directly, no click event
|
||||||
// from the button gets fired. To prevent this, we wait 200ms until we hide
|
// from the button gets fired. To prevent this, we wait 200ms until we hide
|
||||||
// everything so the button has a chance of firering the search event.
|
// everything so the button has a chance of firing the search event.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.showTaskSearch = false
|
this.showTaskSearch = false
|
||||||
}, 200)
|
}, 200)
|
||||||
},
|
},
|
||||||
// This function initializes the tasks page and loads the first page of tasks
|
|
||||||
initTasks(page, search = '') {
|
|
||||||
this.taskEditTask = null
|
|
||||||
this.isTaskEdit = false
|
|
||||||
this.loadTasks(page, search)
|
|
||||||
},
|
|
||||||
focusNewTaskInput() {
|
focusNewTaskInput() {
|
||||||
this.$refs.newTaskInput.$refs.newTaskInput.focus()
|
this.$refs.newTaskInput.$refs.newTaskInput.focus()
|
||||||
},
|
},
|
||||||
|
@ -312,7 +318,7 @@ export default {
|
||||||
this.tasks[e.newIndex] = updatedTask
|
this.tasks[e.newIndex] = updatedTask
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
311
src/views/list/ListTable.vue
Normal file
311
src/views/list/ListTable.vue
Normal file
|
@ -0,0 +1,311 @@
|
||||||
|
<template>
|
||||||
|
<ListWrapper class="list-table" :list-id="listId" viewName="table">
|
||||||
|
<template #header>
|
||||||
|
<div class="filter-container">
|
||||||
|
<div class="items">
|
||||||
|
<popup>
|
||||||
|
<template #trigger="{toggle}">
|
||||||
|
<x-button
|
||||||
|
@click.prevent.stop="toggle()"
|
||||||
|
icon="th"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{{ $t('list.table.columns') }}
|
||||||
|
</x-button>
|
||||||
|
</template>
|
||||||
|
<template #content="{isOpen}">
|
||||||
|
<card class="columns-filter" :class="{'is-open': isOpen}">
|
||||||
|
<fancycheckbox v-model="activeColumns.id">#</fancycheckbox>
|
||||||
|
<fancycheckbox v-model="activeColumns.done">
|
||||||
|
{{ $t('task.attributes.done') }}
|
||||||
|
</fancycheckbox>
|
||||||
|
<fancycheckbox v-model="activeColumns.title">
|
||||||
|
{{ $t('task.attributes.title') }}
|
||||||
|
</fancycheckbox>
|
||||||
|
<fancycheckbox v-model="activeColumns.priority">
|
||||||
|
{{ $t('task.attributes.priority') }}
|
||||||
|
</fancycheckbox>
|
||||||
|
<fancycheckbox v-model="activeColumns.labels">
|
||||||
|
{{ $t('task.attributes.labels') }}
|
||||||
|
</fancycheckbox>
|
||||||
|
<fancycheckbox v-model="activeColumns.assignees">
|
||||||
|
{{ $t('task.attributes.assignees') }}
|
||||||
|
</fancycheckbox>
|
||||||
|
<fancycheckbox v-model="activeColumns.dueDate">
|
||||||
|
{{ $t('task.attributes.dueDate') }}
|
||||||
|
</fancycheckbox>
|
||||||
|
<fancycheckbox v-model="activeColumns.startDate">
|
||||||
|
{{ $t('task.attributes.startDate') }}
|
||||||
|
</fancycheckbox>
|
||||||
|
<fancycheckbox v-model="activeColumns.endDate">
|
||||||
|
{{ $t('task.attributes.endDate') }}
|
||||||
|
</fancycheckbox>
|
||||||
|
<fancycheckbox v-model="activeColumns.percentDone">
|
||||||
|
{{ $t('task.attributes.percentDone') }}
|
||||||
|
</fancycheckbox>
|
||||||
|
<fancycheckbox v-model="activeColumns.created">
|
||||||
|
{{ $t('task.attributes.created') }}
|
||||||
|
</fancycheckbox>
|
||||||
|
<fancycheckbox v-model="activeColumns.updated">
|
||||||
|
{{ $t('task.attributes.updated') }}
|
||||||
|
</fancycheckbox>
|
||||||
|
<fancycheckbox v-model="activeColumns.createdBy">
|
||||||
|
{{ $t('task.attributes.createdBy') }}
|
||||||
|
</fancycheckbox>
|
||||||
|
</card>
|
||||||
|
</template>
|
||||||
|
</popup>
|
||||||
|
<filter-popup v-model="params" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default>
|
||||||
|
<div :class="{'is-loading': loading}" class="loader-container">
|
||||||
|
<card :padding="false" :has-content="false">
|
||||||
|
<div class="has-horizontal-overflow">
|
||||||
|
<table class="table has-actions is-hoverable is-fullwidth mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-if="activeColumns.id">
|
||||||
|
#
|
||||||
|
<Sort :order="sortBy.id" @click="sort('id')"/>
|
||||||
|
</th>
|
||||||
|
<th v-if="activeColumns.done">
|
||||||
|
{{ $t('task.attributes.done') }}
|
||||||
|
<Sort :order="sortBy.done" @click="sort('done')"/>
|
||||||
|
</th>
|
||||||
|
<th v-if="activeColumns.title">
|
||||||
|
{{ $t('task.attributes.title') }}
|
||||||
|
<Sort :order="sortBy.title" @click="sort('title')"/>
|
||||||
|
</th>
|
||||||
|
<th v-if="activeColumns.priority">
|
||||||
|
{{ $t('task.attributes.priority') }}
|
||||||
|
<Sort :order="sortBy.priority" @click="sort('priority')"/>
|
||||||
|
</th>
|
||||||
|
<th v-if="activeColumns.labels">
|
||||||
|
{{ $t('task.attributes.labels') }}
|
||||||
|
</th>
|
||||||
|
<th v-if="activeColumns.assignees">
|
||||||
|
{{ $t('task.attributes.assignees') }}
|
||||||
|
</th>
|
||||||
|
<th v-if="activeColumns.dueDate">
|
||||||
|
{{ $t('task.attributes.dueDate') }}
|
||||||
|
<Sort :order="sortBy.due_date" @click="sort('due_date')"/>
|
||||||
|
</th>
|
||||||
|
<th v-if="activeColumns.startDate">
|
||||||
|
{{ $t('task.attributes.startDate') }}
|
||||||
|
<Sort :order="sortBy.start_date" @click="sort('start_date')"/>
|
||||||
|
</th>
|
||||||
|
<th v-if="activeColumns.endDate">
|
||||||
|
{{ $t('task.attributes.endDate') }}
|
||||||
|
<Sort :order="sortBy.end_date" @click="sort('end_date')"/>
|
||||||
|
</th>
|
||||||
|
<th v-if="activeColumns.percentDone">
|
||||||
|
{{ $t('task.attributes.percentDone') }}
|
||||||
|
<Sort :order="sortBy.percent_done" @click="sort('percent_done')"/>
|
||||||
|
</th>
|
||||||
|
<th v-if="activeColumns.created">
|
||||||
|
{{ $t('task.attributes.created') }}
|
||||||
|
<Sort :order="sortBy.created" @click="sort('created')"/>
|
||||||
|
</th>
|
||||||
|
<th v-if="activeColumns.updated">
|
||||||
|
{{ $t('task.attributes.updated') }}
|
||||||
|
<Sort :order="sortBy.updated" @click="sort('updated')"/>
|
||||||
|
</th>
|
||||||
|
<th v-if="activeColumns.createdBy">
|
||||||
|
{{ $t('task.attributes.createdBy') }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr :key="t.id" v-for="t in tasks">
|
||||||
|
<td v-if="activeColumns.id">
|
||||||
|
<router-link :to="taskDetailRoutes[t.id]">
|
||||||
|
<template v-if="t.identifier === ''">
|
||||||
|
#{{ t.index }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ t.identifier }}
|
||||||
|
</template>
|
||||||
|
</router-link>
|
||||||
|
</td>
|
||||||
|
<td v-if="activeColumns.done">
|
||||||
|
<Done :is-done="t.done" variant="small" />
|
||||||
|
</td>
|
||||||
|
<td v-if="activeColumns.title">
|
||||||
|
<router-link :to="taskDetailRoutes[t.id]">{{ t.title }}</router-link>
|
||||||
|
</td>
|
||||||
|
<td v-if="activeColumns.priority">
|
||||||
|
<priority-label :priority="t.priority" :done="t.done" :show-all="true"/>
|
||||||
|
</td>
|
||||||
|
<td v-if="activeColumns.labels">
|
||||||
|
<labels :labels="t.labels"/>
|
||||||
|
</td>
|
||||||
|
<td v-if="activeColumns.assignees">
|
||||||
|
<user
|
||||||
|
:avatar-size="27"
|
||||||
|
:is-inline="true"
|
||||||
|
:key="t.id + 'assignee' + a.id + i"
|
||||||
|
:show-username="false"
|
||||||
|
:user="a"
|
||||||
|
v-for="(a, i) in t.assignees"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<date-table-cell :date="t.dueDate" v-if="activeColumns.dueDate"/>
|
||||||
|
<date-table-cell :date="t.startDate" v-if="activeColumns.startDate"/>
|
||||||
|
<date-table-cell :date="t.endDate" v-if="activeColumns.endDate"/>
|
||||||
|
<td v-if="activeColumns.percentDone">{{ t.percentDone * 100 }}%</td>
|
||||||
|
<date-table-cell :date="t.created" v-if="activeColumns.created"/>
|
||||||
|
<date-table-cell :date="t.updated" v-if="activeColumns.updated"/>
|
||||||
|
<td v-if="activeColumns.createdBy">
|
||||||
|
<user
|
||||||
|
:avatar-size="27"
|
||||||
|
:show-username="false"
|
||||||
|
:user="t.createdBy"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
:total-pages="totalPages"
|
||||||
|
:current-page="currentPage"
|
||||||
|
/>
|
||||||
|
</card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListWrapper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { toRef, computed, Ref } from 'vue'
|
||||||
|
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
|
||||||
|
import ListWrapper from './ListWrapper.vue'
|
||||||
|
import Done from '@/components/misc/Done.vue'
|
||||||
|
import User from '@/components/misc/user.vue'
|
||||||
|
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
|
||||||
|
import Labels from '@/components/tasks/partials/labels.vue'
|
||||||
|
import DateTableCell from '@/components/tasks/partials/date-table-cell.vue'
|
||||||
|
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||||
|
import Sort from '@/components/tasks/partials/sort.vue'
|
||||||
|
import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
||||||
|
import Pagination from '@/components/misc/pagination.vue'
|
||||||
|
import Popup from '@/components/misc/popup.vue'
|
||||||
|
|
||||||
|
import { useTaskList } from '@/composables/taskList'
|
||||||
|
import TaskModel from '@/models/task'
|
||||||
|
|
||||||
|
const ACTIVE_COLUMNS_DEFAULT = {
|
||||||
|
id: true,
|
||||||
|
done: true,
|
||||||
|
title: true,
|
||||||
|
priority: false,
|
||||||
|
labels: true,
|
||||||
|
assignees: true,
|
||||||
|
dueDate: true,
|
||||||
|
startDate: false,
|
||||||
|
endDate: false,
|
||||||
|
percentDone: false,
|
||||||
|
created: false,
|
||||||
|
updated: false,
|
||||||
|
createdBy: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
listId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type Order = 'asc' | 'desc' | 'none'
|
||||||
|
|
||||||
|
interface SortBy {
|
||||||
|
id : Order
|
||||||
|
done? : Order
|
||||||
|
title? : Order
|
||||||
|
priority? : Order
|
||||||
|
due_date? : Order
|
||||||
|
start_date? : Order
|
||||||
|
end_date? : Order
|
||||||
|
percent_done? : Order
|
||||||
|
created? : Order
|
||||||
|
updated? : Order
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORT_BY_DEFAULT : SortBy = {
|
||||||
|
id: 'desc',
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeColumns = useStorage('tableViewColumns', { ...ACTIVE_COLUMNS_DEFAULT })
|
||||||
|
const sortBy = useStorage<SortBy>('tableViewSortBy', { ...SORT_BY_DEFAULT })
|
||||||
|
|
||||||
|
const taskList = useTaskList(toRef(props, 'listId'))
|
||||||
|
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
params,
|
||||||
|
totalPages,
|
||||||
|
currentPage,
|
||||||
|
} = taskList
|
||||||
|
const tasks : Ref<TaskModel[]> = taskList.tasks
|
||||||
|
|
||||||
|
Object.assign(params.value, {
|
||||||
|
filter_by: [],
|
||||||
|
filter_value: [],
|
||||||
|
filter_comparator: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
// FIXME: by doing this we can have multiple sort orders
|
||||||
|
function sort(property : keyof SortBy) {
|
||||||
|
const order = sortBy.value[property]
|
||||||
|
if (typeof order === 'undefined' || order === 'none') {
|
||||||
|
sortBy.value[property] = 'desc'
|
||||||
|
} else if (order === 'desc') {
|
||||||
|
sortBy.value[property] = 'asc'
|
||||||
|
} else {
|
||||||
|
delete sortBy.value[property]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: re-enable opening task detail in modal
|
||||||
|
// const router = useRouter()
|
||||||
|
const taskDetailRoutes = computed(() => Object.fromEntries(
|
||||||
|
tasks.value.map(({id}) => ([
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
name: 'task.detail',
|
||||||
|
params: { id },
|
||||||
|
// state: { backdropView: router.currentRoute.value.fullPath },
|
||||||
|
},
|
||||||
|
])),
|
||||||
|
))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.table {
|
||||||
|
background: transparent;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
|
||||||
|
th {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns-filter {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
&.is-open {
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
186
src/views/list/ListWrapper.vue
Normal file
186
src/views/list/ListWrapper.vue
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="{ 'is-loading': listService.loading, 'is-archived': currentList.isArchived}"
|
||||||
|
class="loader-container"
|
||||||
|
>
|
||||||
|
<div class="switch-view-container">
|
||||||
|
<div class="switch-view">
|
||||||
|
<router-link
|
||||||
|
v-shortcut="'g l'"
|
||||||
|
:title="$t('keyboardShortcuts.list.switchToListView')"
|
||||||
|
:class="{'is-active': viewName === 'list'}"
|
||||||
|
:to="{ name: 'list.list', params: { listId } }">
|
||||||
|
{{ $t('list.list.title') }}
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
v-shortcut="'g g'"
|
||||||
|
:title="$t('keyboardShortcuts.list.switchToGanttView')"
|
||||||
|
:class="{'is-active': viewName === 'gantt'}"
|
||||||
|
:to="{ name: 'list.gantt', params: { listId } }">
|
||||||
|
{{ $t('list.gantt.title') }}
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
v-shortcut="'g t'"
|
||||||
|
:title="$t('keyboardShortcuts.list.switchToTableView')"
|
||||||
|
:class="{'is-active': viewName === 'table'}"
|
||||||
|
:to="{ name: 'list.table', params: { listId } }">
|
||||||
|
{{ $t('list.table.title') }}
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
v-shortcut="'g k'"
|
||||||
|
:title="$t('keyboardShortcuts.list.switchToKanbanView')"
|
||||||
|
:class="{'is-active': viewName === 'kanban'}"
|
||||||
|
:to="{ name: 'list.kanban', params: { listId } }">
|
||||||
|
{{ $t('list.kanban.title') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<slot name="header" />
|
||||||
|
</div>
|
||||||
|
<transition name="fade">
|
||||||
|
<Message variant="warning" v-if="currentList.isArchived" class="mb-4">
|
||||||
|
{{ $t('list.archived') }}
|
||||||
|
</Message>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref, shallowRef, computed, watchEffect} from 'vue'
|
||||||
|
import {useRoute} from 'vue-router'
|
||||||
|
|
||||||
|
import Message from '@/components/misc/message.vue'
|
||||||
|
|
||||||
|
import ListModel from '@/models/list'
|
||||||
|
import ListService from '@/services/list'
|
||||||
|
|
||||||
|
import {store} from '@/store'
|
||||||
|
import {CURRENT_LIST} from '@/store/mutation-types'
|
||||||
|
|
||||||
|
import {getListTitle} from '@/helpers/getListTitle'
|
||||||
|
import {saveListToHistory} from '@/modules/listHistory'
|
||||||
|
import { useTitle } from '@/composables/useTitle'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
listId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
viewName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const listService = shallowRef(new ListService())
|
||||||
|
const loadedListId = ref(0)
|
||||||
|
|
||||||
|
const currentList = computed(() => {
|
||||||
|
return typeof store.state.currentList === 'undefined' ? {
|
||||||
|
id: 0,
|
||||||
|
title: '',
|
||||||
|
isArchived: false,
|
||||||
|
maxRight: null,
|
||||||
|
} : store.state.currentList
|
||||||
|
})
|
||||||
|
|
||||||
|
// call again the method if the listId changes
|
||||||
|
watchEffect(() => loadList(props.listId))
|
||||||
|
|
||||||
|
useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '')
|
||||||
|
|
||||||
|
async function loadList(listIdToLoad: number) {
|
||||||
|
const listData = {id: listIdToLoad}
|
||||||
|
saveListToHistory(listData)
|
||||||
|
|
||||||
|
// This invalidates the loaded list at the kanban board which lets it reload its content when
|
||||||
|
// switched to it. This ensures updates done to tasks in the gantt or list views are consistently
|
||||||
|
// shown in all views while preventing reloads when closing a task popup.
|
||||||
|
// We don't do this for the table view because that does not change tasks.
|
||||||
|
// FIXME: remove this
|
||||||
|
if (
|
||||||
|
props.viewName === 'list.list' ||
|
||||||
|
props.viewName === 'list.gantt'
|
||||||
|
) {
|
||||||
|
store.commit('kanban/setListId', 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
|
||||||
|
// the currently loaded list has the right set.
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
listIdToLoad === loadedListId.value ||
|
||||||
|
typeof listIdToLoad === 'undefined' ||
|
||||||
|
listIdToLoad === currentList.value.id
|
||||||
|
)
|
||||||
|
&& typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`Loading list, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value)
|
||||||
|
|
||||||
|
// We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux.
|
||||||
|
const list = new ListModel(listData)
|
||||||
|
try {
|
||||||
|
const loadedList = await listService.value.get(list)
|
||||||
|
await store.dispatch(CURRENT_LIST, loadedList)
|
||||||
|
} finally {
|
||||||
|
loadedListId.value = props.listId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.switch-view-container {
|
||||||
|
@media screen and (max-width: $tablet) {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-view {
|
||||||
|
background: var(--white);
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: $radius;
|
||||||
|
font-size: .75rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
height: $switch-view-height;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: .5rem;
|
||||||
|
|
||||||
|
a {
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
display: block;
|
||||||
|
border-radius: $radius;
|
||||||
|
|
||||||
|
transition: all 100ms;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-right: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active,
|
||||||
|
&:hover {
|
||||||
|
color: var(--switch-view-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: var(--primary);
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-archived .notification.is-warning {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -61,7 +61,7 @@ export default {
|
||||||
}
|
}
|
||||||
this.showError = false
|
this.showError = false
|
||||||
|
|
||||||
this.list.namespaceId = parseInt(this.$route.params.id)
|
this.list.namespaceId = parseInt(this.$route.params.namespaceId)
|
||||||
const list = await this.$store.dispatch('lists/createList', this.list)
|
const list = await this.$store.dispatch('lists/createList', this.list)
|
||||||
this.$message.success({message: this.$t('list.create.createdSuccess') })
|
this.$message.success({message: this.$t('list.create.createdSuccess') })
|
||||||
this.$router.push({
|
this.$router.push({
|
||||||
|
|
|
@ -1,211 +0,0 @@
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
:class="{ 'is-loading': listService.loading, 'is-archived': currentList.isArchived}"
|
|
||||||
class="loader-container"
|
|
||||||
>
|
|
||||||
<div class="switch-view-container">
|
|
||||||
<div class="switch-view">
|
|
||||||
<router-link
|
|
||||||
v-shortcut="'g l'"
|
|
||||||
:title="$t('keyboardShortcuts.list.switchToListView')"
|
|
||||||
:class="{'is-active': $route.name.includes('list.list')}"
|
|
||||||
:to="{ name: 'list.list', params: { listId: listId } }">
|
|
||||||
{{ $t('list.list.title') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
v-shortcut="'g g'"
|
|
||||||
:title="$t('keyboardShortcuts.list.switchToGanttView')"
|
|
||||||
:class="{'is-active': $route.name.includes('list.gantt')}"
|
|
||||||
:to="{ name: 'list.gantt', params: { listId: listId } }">
|
|
||||||
{{ $t('list.gantt.title') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
v-shortcut="'g t'"
|
|
||||||
:title="$t('keyboardShortcuts.list.switchToTableView')"
|
|
||||||
:class="{'is-active': $route.name.includes('list.table')}"
|
|
||||||
:to="{ name: 'list.table', params: { listId: listId } }">
|
|
||||||
{{ $t('list.table.title') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
v-shortcut="'g k'"
|
|
||||||
:title="$t('keyboardShortcuts.list.switchToKanbanView')"
|
|
||||||
:class="{'is-active': $route.name.includes('list.kanban')}"
|
|
||||||
:to="{ name: 'list.kanban', params: { listId: listId } }">
|
|
||||||
{{ $t('list.kanban.title') }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<transition name="fade">
|
|
||||||
<message variant="warning" v-if="currentList.isArchived" class="mb-4">
|
|
||||||
{{ $t('list.archived') }}
|
|
||||||
</message>
|
|
||||||
</transition>
|
|
||||||
|
|
||||||
<router-view/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import Message from '@/components/misc/message'
|
|
||||||
import ListModel from '../../models/list'
|
|
||||||
import ListService from '../../services/list'
|
|
||||||
import {CURRENT_LIST} from '../../store/mutation-types'
|
|
||||||
import {getListView} from '../../helpers/saveListView'
|
|
||||||
import {saveListToHistory} from '../../modules/listHistory'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {Message},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
listService: new ListService(),
|
|
||||||
listLoaded: 0,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
// call again the method if the route changes
|
|
||||||
'$route.path': {
|
|
||||||
handler: 'loadList',
|
|
||||||
immediate: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
// Computed property to let "listId" always have a value
|
|
||||||
listId() {
|
|
||||||
return typeof this.$route.params.listId === 'undefined' ? 0 : this.$route.params.listId
|
|
||||||
},
|
|
||||||
background() {
|
|
||||||
return this.$store.state.background
|
|
||||||
},
|
|
||||||
currentList() {
|
|
||||||
return typeof this.$store.state.currentList === 'undefined' ? {
|
|
||||||
id: 0,
|
|
||||||
title: '',
|
|
||||||
isArchived: false,
|
|
||||||
} : this.$store.state.currentList
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
replaceListView() {
|
|
||||||
const savedListView = getListView(this.$route.params.listId)
|
|
||||||
this.$router.replace({name: savedListView, params: {id: this.$route.params.listId}})
|
|
||||||
console.debug('Replaced list view with', savedListView)
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadList() {
|
|
||||||
if (this.$route.name.includes('.settings.')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const listData = {id: parseInt(this.$route.params.listId)}
|
|
||||||
|
|
||||||
saveListToHistory(listData)
|
|
||||||
|
|
||||||
this.setTitle(this.currentList.id ? this.getListTitle(this.currentList) : '')
|
|
||||||
|
|
||||||
// This invalidates the loaded list at the kanban board which lets it reload its content when
|
|
||||||
// switched to it. This ensures updates done to tasks in the gantt or list views are consistently
|
|
||||||
// shown in all views while preventing reloads when closing a task popup.
|
|
||||||
// We don't do this for the table view because that does not change tasks.
|
|
||||||
if (
|
|
||||||
this.$route.name === 'list.list' ||
|
|
||||||
this.$route.name === 'list.gantt'
|
|
||||||
) {
|
|
||||||
this.$store.commit('kanban/setListId', 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// When clicking again on a list in the menu, there would be no list view selected which means no list
|
|
||||||
// at all. Users will then have to click on the list view menu again which is quite confusing.
|
|
||||||
if (this.$route.name === 'list.index') {
|
|
||||||
return this.replaceListView()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
|
|
||||||
// the currently loaded list has the right set.
|
|
||||||
if (
|
|
||||||
(
|
|
||||||
this.$route.params.listId === this.listLoaded ||
|
|
||||||
typeof this.$route.params.listId === 'undefined' ||
|
|
||||||
this.$route.params.listId === this.currentList.id ||
|
|
||||||
parseInt(this.$route.params.listId) === this.currentList.id
|
|
||||||
)
|
|
||||||
&& typeof this.currentList !== 'undefined' && this.currentList.maxRight !== null
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect the user to list view by default
|
|
||||||
if (
|
|
||||||
this.$route.name !== 'list.list' &&
|
|
||||||
this.$route.name !== 'list.gantt' &&
|
|
||||||
this.$route.name !== 'list.table' &&
|
|
||||||
this.$route.name !== 'list.kanban'
|
|
||||||
) {
|
|
||||||
return this.replaceListView()
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug(`Loading list, $route.name = ${this.$route.name}, $route.params =`, this.$route.params, `, listLoaded = ${this.listLoaded}, currentList = `, this.currentList)
|
|
||||||
|
|
||||||
// We create an extra list object instead of creating it in this.list because that would trigger a ui update which would result in bad ux.
|
|
||||||
const list = new ListModel(listData)
|
|
||||||
try {
|
|
||||||
const loadedList = await this.listService.get(list)
|
|
||||||
await this.$store.dispatch(CURRENT_LIST, loadedList)
|
|
||||||
this.setTitle(this.getListTitle(loadedList))
|
|
||||||
} finally {
|
|
||||||
this.listLoaded = this.$route.params.listId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.switch-view-container {
|
|
||||||
@media screen and (max-width: $tablet) {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch-view {
|
|
||||||
background: var(--white);
|
|
||||||
display: inline-flex;
|
|
||||||
border-radius: $radius;
|
|
||||||
font-size: .75rem;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
height: $switch-view-height;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding: .5rem;
|
|
||||||
|
|
||||||
a {
|
|
||||||
padding: .25rem .5rem;
|
|
||||||
display: block;
|
|
||||||
border-radius: $radius;
|
|
||||||
|
|
||||||
transition: all 100ms;
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-active,
|
|
||||||
&:hover {
|
|
||||||
color: var(--switch-view-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-active {
|
|
||||||
background: var(--primary);
|
|
||||||
font-weight: bold;
|
|
||||||
box-shadow: var(--shadow-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-archived .notification.is-warning {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -3,24 +3,38 @@
|
||||||
:title="$t('list.share.header')"
|
:title="$t('list.share.header')"
|
||||||
primary-label=""
|
primary-label=""
|
||||||
>
|
>
|
||||||
<component
|
<template v-if="list">
|
||||||
:id="list.id"
|
<userTeam
|
||||||
:is="manageUsersComponent"
|
:id="list.id"
|
||||||
:userIsAdmin="userIsAdmin"
|
:userIsAdmin="userIsAdmin"
|
||||||
shareType="user"
|
shareType="user"
|
||||||
type="list"/>
|
type="list"
|
||||||
<component
|
/>
|
||||||
:id="list.id"
|
<userTeam
|
||||||
:is="manageTeamsComponent"
|
:id="list.id"
|
||||||
:userIsAdmin="userIsAdmin"
|
:userIsAdmin="userIsAdmin"
|
||||||
shareType="team"
|
shareType="team"
|
||||||
type="list"/>
|
type="list"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<link-sharing :list-id="$route.params.listId" v-if="linkSharingEnabled" class="mt-4"/>
|
<link-sharing :list-id="listId" v-if="linkSharingEnabled" class="mt-4"/>
|
||||||
</create-edit>
|
</create-edit>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
name: 'list-setting-share',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {ref, computed, watchEffect} from 'vue'
|
||||||
|
import {useStore} from 'vuex'
|
||||||
|
import {useRoute} from 'vue-router'
|
||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
|
import {useTitle} from '@vueuse/core'
|
||||||
|
|
||||||
import ListService from '@/services/list'
|
import ListService from '@/services/list'
|
||||||
import ListModel from '@/models/list'
|
import ListModel from '@/models/list'
|
||||||
import {CURRENT_LIST} from '@/store/mutation-types'
|
import {CURRENT_LIST} from '@/store/mutation-types'
|
||||||
|
@ -29,43 +43,30 @@ import CreateEdit from '@/components/misc/create-edit.vue'
|
||||||
import LinkSharing from '@/components/sharing/linkSharing.vue'
|
import LinkSharing from '@/components/sharing/linkSharing.vue'
|
||||||
import userTeam from '@/components/sharing/userTeam.vue'
|
import userTeam from '@/components/sharing/userTeam.vue'
|
||||||
|
|
||||||
export default {
|
const {t} = useI18n()
|
||||||
name: 'list-setting-share',
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
list: ListModel,
|
|
||||||
listService: new ListService(),
|
|
||||||
manageUsersComponent: '',
|
|
||||||
manageTeamsComponent: '',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
CreateEdit,
|
|
||||||
LinkSharing,
|
|
||||||
userTeam,
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
linkSharingEnabled() {
|
|
||||||
return this.$store.state.config.linkSharingEnabled
|
|
||||||
},
|
|
||||||
userIsAdmin() {
|
|
||||||
return this.list.owner && this.list.owner.id === this.$store.state.auth.info.id
|
|
||||||
},
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.loadList()
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async loadList() {
|
|
||||||
const list = new ListModel({id: this.$route.params.listId})
|
|
||||||
|
|
||||||
this.list = await this.listService.get(list)
|
const list = ref()
|
||||||
await this.$store.dispatch(CURRENT_LIST, this.list)
|
const title = computed(() => list.value?.title
|
||||||
// This will trigger the dynamic loading of components once we actually have all the data to pass to them
|
? t('list.share.title', {list: list.value.title})
|
||||||
this.manageTeamsComponent = 'userTeam'
|
: '',
|
||||||
this.manageUsersComponent = 'userTeam'
|
)
|
||||||
this.setTitle(this.$t('list.share.title', {list: this.list.title}))
|
useTitle(title)
|
||||||
},
|
|
||||||
},
|
const store = useStore()
|
||||||
|
const linkSharingEnabled = computed(() => store.state.config.linkSharingEnabled)
|
||||||
|
const userIsAdmin = computed(() => 'owner' in list.value && list.value.owner.id === store.state.auth.info.id)
|
||||||
|
|
||||||
|
async function loadList(listId: number) {
|
||||||
|
const listService = new ListService()
|
||||||
|
const newList = await listService.get(new ListModel({id: listId}))
|
||||||
|
await store.dispatch(CURRENT_LIST, newList)
|
||||||
|
list.value = newList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const listId = computed(() => route.params.listId !== undefined
|
||||||
|
? parseInt(route.params.listId as string)
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
watchEffect(() => listId.value !== undefined && loadList(listId.value))
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,331 +0,0 @@
|
||||||
<template>
|
|
||||||
<div :class="{'is-loading': taskCollectionService.loading}" class="table-view loader-container">
|
|
||||||
<div class="filter-container">
|
|
||||||
<div class="items">
|
|
||||||
<popup>
|
|
||||||
<template #trigger="{toggle}">
|
|
||||||
<x-button
|
|
||||||
@click.prevent.stop="toggle()"
|
|
||||||
icon="th"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
{{ $t('list.table.columns') }}
|
|
||||||
</x-button>
|
|
||||||
</template>
|
|
||||||
<template #content="{isOpen}">
|
|
||||||
<card class="columns-filter" :class="{'is-open': isOpen}">
|
|
||||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.id">#</fancycheckbox>
|
|
||||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.done">
|
|
||||||
{{ $t('task.attributes.done') }}
|
|
||||||
</fancycheckbox>
|
|
||||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.title">
|
|
||||||
{{ $t('task.attributes.title') }}
|
|
||||||
</fancycheckbox>
|
|
||||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.priority">
|
|
||||||
{{ $t('task.attributes.priority') }}
|
|
||||||
</fancycheckbox>
|
|
||||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.labels">
|
|
||||||
{{ $t('task.attributes.labels') }}
|
|
||||||
</fancycheckbox>
|
|
||||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.assignees">
|
|
||||||
{{ $t('task.attributes.assignees') }}
|
|
||||||
</fancycheckbox>
|
|
||||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.dueDate">
|
|
||||||
{{ $t('task.attributes.dueDate') }}
|
|
||||||
</fancycheckbox>
|
|
||||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.startDate">
|
|
||||||
{{ $t('task.attributes.startDate') }}
|
|
||||||
</fancycheckbox>
|
|
||||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.endDate">
|
|
||||||
{{ $t('task.attributes.endDate') }}
|
|
||||||
</fancycheckbox>
|
|
||||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.percentDone">
|
|
||||||
{{ $t('task.attributes.percentDone') }}
|
|
||||||
</fancycheckbox>
|
|
||||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.created">
|
|
||||||
{{ $t('task.attributes.created') }}
|
|
||||||
</fancycheckbox>
|
|
||||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.updated">
|
|
||||||
{{ $t('task.attributes.updated') }}
|
|
||||||
</fancycheckbox>
|
|
||||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.createdBy">
|
|
||||||
{{ $t('task.attributes.createdBy') }}
|
|
||||||
</fancycheckbox>
|
|
||||||
</card>
|
|
||||||
</template>
|
|
||||||
</popup>
|
|
||||||
<filter-popup
|
|
||||||
v-model="params"
|
|
||||||
@update:modelValue="loadTasks()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<card :padding="false" :has-content="false">
|
|
||||||
<div class="has-horizontal-overflow">
|
|
||||||
<table class="table has-actions is-hoverable is-fullwidth mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th v-if="activeColumns.id">
|
|
||||||
#
|
|
||||||
<sort :order="sortBy.id" @click="sort('id')"/>
|
|
||||||
</th>
|
|
||||||
<th v-if="activeColumns.done">
|
|
||||||
{{ $t('task.attributes.done') }}
|
|
||||||
<sort :order="sortBy.done" @click="sort('done')"/>
|
|
||||||
</th>
|
|
||||||
<th v-if="activeColumns.title">
|
|
||||||
{{ $t('task.attributes.title') }}
|
|
||||||
<sort :order="sortBy.title" @click="sort('title')"/>
|
|
||||||
</th>
|
|
||||||
<th v-if="activeColumns.priority">
|
|
||||||
{{ $t('task.attributes.priority') }}
|
|
||||||
<sort :order="sortBy.priority" @click="sort('priority')"/>
|
|
||||||
</th>
|
|
||||||
<th v-if="activeColumns.labels">
|
|
||||||
{{ $t('task.attributes.labels') }}
|
|
||||||
</th>
|
|
||||||
<th v-if="activeColumns.assignees">
|
|
||||||
{{ $t('task.attributes.assignees') }}
|
|
||||||
</th>
|
|
||||||
<th v-if="activeColumns.dueDate">
|
|
||||||
{{ $t('task.attributes.dueDate') }}
|
|
||||||
<sort :order="sortBy.due_date" @click="sort('due_date')"/>
|
|
||||||
</th>
|
|
||||||
<th v-if="activeColumns.startDate">
|
|
||||||
{{ $t('task.attributes.startDate') }}
|
|
||||||
<sort :order="sortBy.start_date" @click="sort('start_date')"/>
|
|
||||||
</th>
|
|
||||||
<th v-if="activeColumns.endDate">
|
|
||||||
{{ $t('task.attributes.endDate') }}
|
|
||||||
<sort :order="sortBy.end_date" @click="sort('end_date')"/>
|
|
||||||
</th>
|
|
||||||
<th v-if="activeColumns.percentDone">
|
|
||||||
{{ $t('task.attributes.percentDone') }}
|
|
||||||
<sort :order="sortBy.percent_done" @click="sort('percent_done')"/>
|
|
||||||
</th>
|
|
||||||
<th v-if="activeColumns.created">
|
|
||||||
{{ $t('task.attributes.created') }}
|
|
||||||
<sort :order="sortBy.created" @click="sort('created')"/>
|
|
||||||
</th>
|
|
||||||
<th v-if="activeColumns.updated">
|
|
||||||
{{ $t('task.attributes.updated') }}
|
|
||||||
<sort :order="sortBy.updated" @click="sort('updated')"/>
|
|
||||||
</th>
|
|
||||||
<th v-if="activeColumns.createdBy">
|
|
||||||
{{ $t('task.attributes.createdBy') }}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr :key="t.id" v-for="t in tasks">
|
|
||||||
<td v-if="activeColumns.id">
|
|
||||||
<router-link :to="{name: 'task.detail', params: { id: t.id }}">
|
|
||||||
<template v-if="t.identifier === ''">
|
|
||||||
#{{ t.index }}
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ t.identifier }}
|
|
||||||
</template>
|
|
||||||
</router-link>
|
|
||||||
</td>
|
|
||||||
<td v-if="activeColumns.done">
|
|
||||||
<Done :is-done="t.done" variant="small" />
|
|
||||||
</td>
|
|
||||||
<td v-if="activeColumns.title">
|
|
||||||
<router-link :to="{name: 'task.detail', params: { id: t.id }}">{{ t.title }}</router-link>
|
|
||||||
</td>
|
|
||||||
<td v-if="activeColumns.priority">
|
|
||||||
<priority-label :priority="t.priority" :done="t.done" :show-all="true"/>
|
|
||||||
</td>
|
|
||||||
<td v-if="activeColumns.labels">
|
|
||||||
<labels :labels="t.labels"/>
|
|
||||||
</td>
|
|
||||||
<td v-if="activeColumns.assignees">
|
|
||||||
<user
|
|
||||||
:avatar-size="27"
|
|
||||||
:is-inline="true"
|
|
||||||
:key="t.id + 'assignee' + a.id + i"
|
|
||||||
:show-username="false"
|
|
||||||
:user="a"
|
|
||||||
v-for="(a, i) in t.assignees"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<date-table-cell :date="t.dueDate" v-if="activeColumns.dueDate"/>
|
|
||||||
<date-table-cell :date="t.startDate" v-if="activeColumns.startDate"/>
|
|
||||||
<date-table-cell :date="t.endDate" v-if="activeColumns.endDate"/>
|
|
||||||
<td v-if="activeColumns.percentDone">{{ t.percentDone * 100 }}%</td>
|
|
||||||
<date-table-cell :date="t.created" v-if="activeColumns.created"/>
|
|
||||||
<date-table-cell :date="t.updated" v-if="activeColumns.updated"/>
|
|
||||||
<td v-if="activeColumns.createdBy">
|
|
||||||
<user
|
|
||||||
:avatar-size="27"
|
|
||||||
:show-username="false"
|
|
||||||
:user="t.createdBy"/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Pagination
|
|
||||||
:total-pages="taskCollectionService.totalPages"
|
|
||||||
:current-page="currentPage"
|
|
||||||
/>
|
|
||||||
</card>
|
|
||||||
|
|
||||||
<!-- This router view is used to show the task popup while keeping the table view itself -->
|
|
||||||
<router-view v-slot="{ Component }">
|
|
||||||
<transition name="modal">
|
|
||||||
<component :is="Component" />
|
|
||||||
</transition>
|
|
||||||
</router-view>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import taskList from '@/components/tasks/mixins/taskList'
|
|
||||||
import Done from '@/components/misc/Done.vue'
|
|
||||||
import User from '@/components/misc/user'
|
|
||||||
import PriorityLabel from '@/components/tasks/partials/priorityLabel'
|
|
||||||
import Labels from '@/components/tasks/partials/labels'
|
|
||||||
import DateTableCell from '@/components/tasks/partials/date-table-cell'
|
|
||||||
import Fancycheckbox from '@/components/input/fancycheckbox'
|
|
||||||
import Sort from '@/components/tasks/partials/sort'
|
|
||||||
import {saveListView} from '@/helpers/saveListView'
|
|
||||||
import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
|
||||||
import Pagination from '@/components/misc/pagination.vue'
|
|
||||||
import Popup from '@/components/misc/popup'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'Table',
|
|
||||||
components: {
|
|
||||||
Popup,
|
|
||||||
Done,
|
|
||||||
FilterPopup,
|
|
||||||
Sort,
|
|
||||||
Fancycheckbox,
|
|
||||||
DateTableCell,
|
|
||||||
Labels,
|
|
||||||
PriorityLabel,
|
|
||||||
User,
|
|
||||||
Pagination,
|
|
||||||
},
|
|
||||||
mixins: [
|
|
||||||
taskList,
|
|
||||||
],
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
activeColumns: {
|
|
||||||
id: true,
|
|
||||||
done: true,
|
|
||||||
title: true,
|
|
||||||
priority: false,
|
|
||||||
labels: true,
|
|
||||||
assignees: true,
|
|
||||||
dueDate: true,
|
|
||||||
startDate: false,
|
|
||||||
endDate: false,
|
|
||||||
percentDone: false,
|
|
||||||
created: false,
|
|
||||||
updated: false,
|
|
||||||
createdBy: false,
|
|
||||||
},
|
|
||||||
sortBy: {
|
|
||||||
id: 'desc',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
const savedShowColumns = localStorage.getItem('tableViewColumns')
|
|
||||||
if (savedShowColumns !== null) {
|
|
||||||
this.activeColumns = JSON.parse(savedShowColumns)
|
|
||||||
}
|
|
||||||
const savedSortBy = localStorage.getItem('tableViewSortBy')
|
|
||||||
if (savedSortBy !== null) {
|
|
||||||
this.sortBy = JSON.parse(savedSortBy)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.params.filter_by = []
|
|
||||||
this.params.filter_value = []
|
|
||||||
this.params.filter_comparator = []
|
|
||||||
|
|
||||||
this.initTasks(1)
|
|
||||||
|
|
||||||
// Save the current list view to local storage
|
|
||||||
// We use local storage and not vuex here to make it persistent across reloads.
|
|
||||||
saveListView(this.$route.params.listId, this.$route.name)
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
initTasks(page, search = '') {
|
|
||||||
// This makes sure an id sort order is always sorted last.
|
|
||||||
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
|
|
||||||
// precedence over everything else, making any other sort columns pretty useless.
|
|
||||||
const sortKeys = Object.keys(this.sortBy)
|
|
||||||
let hasIdFilter = false
|
|
||||||
for (const s of sortKeys) {
|
|
||||||
if (s === 'id') {
|
|
||||||
sortKeys.splice(s, 1)
|
|
||||||
hasIdFilter = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasIdFilter) {
|
|
||||||
sortKeys.push('id')
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = this.params
|
|
||||||
params.sort_by = []
|
|
||||||
params.order_by = []
|
|
||||||
sortKeys.map(s => {
|
|
||||||
params.sort_by.push(s)
|
|
||||||
params.order_by.push(this.sortBy[s])
|
|
||||||
})
|
|
||||||
this.loadTasks(page, search, params)
|
|
||||||
},
|
|
||||||
sort(property) {
|
|
||||||
const order = this.sortBy[property]
|
|
||||||
if (typeof order === 'undefined' || order === 'none') {
|
|
||||||
this.sortBy[property] = 'desc'
|
|
||||||
} else if (order === 'desc') {
|
|
||||||
this.sortBy[property] = 'asc'
|
|
||||||
} else {
|
|
||||||
delete this.sortBy[property]
|
|
||||||
}
|
|
||||||
this.initTasks(this.currentPage, this.searchTerm)
|
|
||||||
// Save the order to be able to retrieve them later
|
|
||||||
localStorage.setItem('tableViewSortBy', JSON.stringify(this.sortBy))
|
|
||||||
},
|
|
||||||
saveTaskColumns() {
|
|
||||||
localStorage.setItem('tableViewColumns', JSON.stringify(this.activeColumns))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.table-view {
|
|
||||||
.table {
|
|
||||||
background: transparent;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
|
|
||||||
th {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.columns-filter {
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
&.is-open {
|
|
||||||
margin: 2rem 0 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -24,7 +24,7 @@
|
||||||
|
|
||||||
<section :key="`n${n.id}`" class="namespace" v-for="n in namespaces">
|
<section :key="`n${n.id}`" class="namespace" v-for="n in namespaces">
|
||||||
<x-button
|
<x-button
|
||||||
:to="{name: 'list.create', params: {id: n.id}}"
|
:to="{name: 'list.create', params: {namespaceId: n.id}}"
|
||||||
class="is-pulled-right"
|
class="is-pulled-right"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
v-if="n.id > 0 && n.lists.length > 0"
|
v-if="n.id > 0 && n.lists.length > 0"
|
||||||
|
@ -51,7 +51,7 @@
|
||||||
|
|
||||||
<p class="has-text-centered has-text-grey mt-4 is-italic" v-if="n.lists.length === 0">
|
<p class="has-text-centered has-text-grey mt-4 is-italic" v-if="n.lists.length === 0">
|
||||||
{{ $t('namespace.noLists') }}
|
{{ $t('namespace.noLists') }}
|
||||||
<router-link :to="{name: 'list.create', params: {id: n.id}}">
|
<router-link :to="{name: 'list.create', params: {namespaceId: n.id}}">
|
||||||
{{ $t('namespace.createList') }}
|
{{ $t('namespace.createList') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -4,9 +4,11 @@
|
||||||
@submit="archiveNamespace()"
|
@submit="archiveNamespace()"
|
||||||
>
|
>
|
||||||
<template #header><span>{{ title }}</span></template>
|
<template #header><span>{{ title }}</span></template>
|
||||||
|
|
||||||
<template #text>
|
<template #text>
|
||||||
<p>{{ list.isArchived ? $t('namespace.archive.unarchiveText') : $t('namespace.archive.archiveText') }}</p>
|
<p>
|
||||||
|
{{ namespace.isArchived ? $t('namespace.archive.unarchiveText') : $t('namespace.archive.archiveText')}}
|
||||||
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</modal>
|
</modal>
|
||||||
</template>
|
</template>
|
||||||
|
@ -27,17 +29,18 @@ export default {
|
||||||
created() {
|
created() {
|
||||||
this.namespace = this.$store.getters['namespaces/getNamespaceById'](this.$route.params.id)
|
this.namespace = this.$store.getters['namespaces/getNamespaceById'](this.$route.params.id)
|
||||||
this.title = this.namespace.isArchived ?
|
this.title = this.namespace.isArchived ?
|
||||||
this.$t('namespace.archive.titleUnarchive', { namespace: this.namespace.title }) :
|
this.$t('namespace.archive.titleUnarchive', {namespace: this.namespace.title}) :
|
||||||
this.$t('namespace.archive.titleArchive', { namespace: this.namespace.title })
|
this.$t('namespace.archive.titleArchive', {namespace: this.namespace.title})
|
||||||
this.setTitle(this.title)
|
this.setTitle(this.title)
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
async archiveNamespace() {
|
async archiveNamespace() {
|
||||||
this.namespace.isArchived = !this.namespace.isArchived
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const namespace = await this.namespaceService.update(this.namespace)
|
const namespace = await this.namespaceService.update({
|
||||||
|
...this.namespace,
|
||||||
|
isArchived: !this.namespace.isArchived,
|
||||||
|
})
|
||||||
this.$store.commit('namespaces/setNamespaceById', namespace)
|
this.$store.commit('namespaces/setNamespaceById', namespace)
|
||||||
this.$message.success({message: this.$t('namespace.archive.success')})
|
this.$message.success({message: this.$t('namespace.archive.success')})
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
@ -3,69 +3,67 @@
|
||||||
:title="title"
|
:title="title"
|
||||||
primary-label=""
|
primary-label=""
|
||||||
>
|
>
|
||||||
<component
|
<template v-if="namespace">
|
||||||
:id="namespace.id"
|
<manageSharing
|
||||||
:is="manageUsersComponent"
|
:id="namespace.id"
|
||||||
:userIsAdmin="userIsAdmin"
|
:userIsAdmin="userIsAdmin"
|
||||||
shareType="user"
|
shareType="user"
|
||||||
type="namespace"/>
|
type="namespace"
|
||||||
<component
|
/>
|
||||||
:id="namespace.id"
|
<manageSharing
|
||||||
:is="manageTeamsComponent"
|
:id="namespace.id"
|
||||||
:userIsAdmin="userIsAdmin"
|
:userIsAdmin="userIsAdmin"
|
||||||
shareType="team"
|
shareType="team"
|
||||||
type="namespace"/>
|
type="namespace"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</create-edit>
|
</create-edit>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import manageSharing from '@/components/sharing/userTeam.vue'
|
export default {
|
||||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
name: 'namespace-setting-share',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {ref, computed, watchEffect} from 'vue'
|
||||||
|
import {useStore} from 'vuex'
|
||||||
|
import {useRoute} from 'vue-router'
|
||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
|
import {useTitle} from '@vueuse/core'
|
||||||
|
|
||||||
import NamespaceService from '@/services/namespace'
|
import NamespaceService from '@/services/namespace'
|
||||||
import NamespaceModel from '@/models/namespace'
|
import NamespaceModel from '@/models/namespace'
|
||||||
|
|
||||||
export default {
|
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||||
name: 'namespace-setting-share',
|
import manageSharing from '@/components/sharing/userTeam.vue'
|
||||||
data() {
|
|
||||||
return {
|
const {t} = useI18n()
|
||||||
namespaceService: new NamespaceService(),
|
|
||||||
namespace: new NamespaceModel(),
|
const namespace = ref()
|
||||||
manageUsersComponent: '',
|
|
||||||
manageTeamsComponent: '',
|
const title = computed(() => namespace.value?.title
|
||||||
title: '',
|
? t('namespace.share.title', { namespace: namespace.value.title })
|
||||||
}
|
: '',
|
||||||
},
|
)
|
||||||
components: {
|
useTitle(title)
|
||||||
CreateEdit,
|
|
||||||
manageSharing,
|
const store = useStore()
|
||||||
},
|
const userIsAdmin = computed(() => 'owner' in namespace.value && namespace.value.owner.id === store.state.auth.info.id)
|
||||||
beforeMount() {
|
|
||||||
this.namespace.id = this.$route.params.id
|
async function loadNamespace(namespaceId: number) {
|
||||||
},
|
if (!namespaceId) return
|
||||||
watch: {
|
const namespaceService = new NamespaceService()
|
||||||
// call again the method if the route changes
|
namespace.value = await namespaceService.get(new NamespaceModel({id: namespaceId}))
|
||||||
'$route': {
|
|
||||||
handler: 'loadNamespace',
|
// TODO: set namespace in store
|
||||||
deep: true,
|
|
||||||
immediate: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
userIsAdmin() {
|
|
||||||
return this.namespace.owner && this.namespace.owner.id === this.$store.state.auth.info.id
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async loadNamespace() {
|
|
||||||
const namespace = new NamespaceModel({id: this.$route.params.id})
|
|
||||||
this.namespace = await this.namespaceService.get(namespace)
|
|
||||||
// This will trigger the dynamic loading of components once we actually have all the data to pass to them
|
|
||||||
this.manageTeamsComponent = 'manageSharing'
|
|
||||||
this.manageUsersComponent = 'manageSharing'
|
|
||||||
this.title = this.$t('namespace.share.title', { namespace: this.namespace.title })
|
|
||||||
this.setTitle(this.title)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const namespaceId = computed(() => route.params.namespaceId !== undefined
|
||||||
|
? parseInt(route.params.namespaceId as string)
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
watchEffect(() => namespaceId.value !== undefined && loadNamespace(namespaceId.value))
|
||||||
</script>
|
</script>
|
|
@ -231,23 +231,25 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
setDatesToNextWeek() {
|
setDatesToNextWeek() {
|
||||||
this.cStartDate = new Date()
|
const now = new Date()
|
||||||
this.cEndDate = new Date((new Date()).getTime() + 7 * 24 * 60 * 60 * 1000)
|
this.cStartDate = now
|
||||||
|
this.cEndDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||||
this.showOverdue = false
|
this.showOverdue = false
|
||||||
this.setDate()
|
this.setDate()
|
||||||
},
|
},
|
||||||
|
|
||||||
setDatesToNextMonth() {
|
setDatesToNextMonth() {
|
||||||
this.cStartDate = new Date()
|
const now = new Date()
|
||||||
this.cEndDate = new Date((new Date()).setMonth((new Date()).getMonth() + 1))
|
this.cStartDate = now
|
||||||
|
this.cEndDate = new Date((new Date()).setMonth(now.getMonth() + 1))
|
||||||
this.showOverdue = false
|
this.showOverdue = false
|
||||||
this.setDate()
|
this.setDate()
|
||||||
},
|
},
|
||||||
|
|
||||||
showTodaysTasks() {
|
showTodaysTasks() {
|
||||||
const d = new Date()
|
const now = new Date()
|
||||||
this.cStartDate = new Date()
|
this.cStartDate = now
|
||||||
this.cEndDate = new Date(d.setDate(d.getDate() + 1))
|
this.cEndDate = new Date((new Date()).setDate(now.getDate() + 1))
|
||||||
this.showOverdue = true
|
this.showOverdue = true
|
||||||
this.setDate()
|
this.setDate()
|
||||||
},
|
},
|
||||||
|
|
|
@ -263,6 +263,7 @@
|
||||||
{{ task.done ? $t('task.detail.undone') : $t('task.detail.done') }}
|
{{ task.done ? $t('task.detail.undone') : $t('task.detail.done') }}
|
||||||
</x-button>
|
</x-button>
|
||||||
<task-subscription
|
<task-subscription
|
||||||
|
v-if="task.subscription"
|
||||||
entity="task"
|
entity="task"
|
||||||
:entity-id="task.id"
|
:entity-id="task.id"
|
||||||
:subscription="task.subscription"
|
:subscription="task.subscription"
|
||||||
|
@ -459,8 +460,10 @@ import {CURRENT_LIST} from '@/store/mutation-types'
|
||||||
import {uploadFile} from '@/helpers/attachments'
|
import {uploadFile} from '@/helpers/attachments'
|
||||||
import ChecklistSummary from '../../components/tasks/partials/checklist-summary'
|
import ChecklistSummary from '../../components/tasks/partials/checklist-summary'
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'TaskDetailView',
|
name: 'TaskDetailView',
|
||||||
|
compatConfig: { ATTR_FALSE_VALUE: false },
|
||||||
components: {
|
components: {
|
||||||
ChecklistSummary,
|
ChecklistSummary,
|
||||||
TaskSubscription,
|
TaskSubscription,
|
||||||
|
@ -479,6 +482,14 @@ export default {
|
||||||
description,
|
description,
|
||||||
heading,
|
heading,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
taskId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
taskService: new TaskService(),
|
taskService: new TaskService(),
|
||||||
|
@ -529,10 +540,6 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
taskId() {
|
|
||||||
const {id} = this.$route.params
|
|
||||||
return id === undefined ? id : Number(id)
|
|
||||||
},
|
|
||||||
currentList() {
|
currentList() {
|
||||||
return this.$store.state[CURRENT_LIST]
|
return this.$store.state[CURRENT_LIST]
|
||||||
},
|
},
|
||||||
|
@ -948,4 +955,6 @@ $flash-background-duration: 750ms;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include modal-transition();
|
||||||
</style>
|
</style>
|
|
@ -1,71 +0,0 @@
|
||||||
<template>
|
|
||||||
<modal
|
|
||||||
@close="close()"
|
|
||||||
variant="scrolling"
|
|
||||||
class="task-detail-view-modal"
|
|
||||||
>
|
|
||||||
<a @click="close()" class="close">
|
|
||||||
<icon icon="times"/>
|
|
||||||
</a>
|
|
||||||
<task-detail-view/>
|
|
||||||
</modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import TaskDetailView from './TaskDetailView'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'TaskDetailViewModal',
|
|
||||||
components: {
|
|
||||||
TaskDetailView,
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
lastRoute: null,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
beforeRouteEnter(to, from, next) {
|
|
||||||
next(vm => {
|
|
||||||
vm.lastRoute = from
|
|
||||||
})
|
|
||||||
},
|
|
||||||
beforeRouteLeave(to, from, next) {
|
|
||||||
if (from.name === 'task.kanban.detail' && to.name === 'task.detail') {
|
|
||||||
this.$router.replace({name: 'task.kanban.detail', params: to.params})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next()
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
close() {
|
|
||||||
if (this.lastRoute === null) {
|
|
||||||
this.$router.back()
|
|
||||||
} else {
|
|
||||||
this.$router.push(this.lastRoute)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.close {
|
|
||||||
position: fixed;
|
|
||||||
top: 5px;
|
|
||||||
right: 26px;
|
|
||||||
color: var(--white);
|
|
||||||
font-size: 2rem;
|
|
||||||
|
|
||||||
@media screen and (max-width: $desktop) {
|
|
||||||
color: var(--dark);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
// Close icon SVG uses currentColor, change the color to keep it visible
|
|
||||||
.dark .task-detail-view-modal .close {
|
|
||||||
color: var(--grey-900);
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -308,4 +308,6 @@ export default {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include modal-transition();
|
||||||
</style>
|
</style>
|
Loading…
Reference in a new issue