Merge branch 'main' into feature/login-improvements

This commit is contained in:
Dominik Pschenitschni 2022-02-05 18:04:33 +01:00
commit a8ac2fc2dd
No known key found for this signature in database
GPG key ID: B257AC0149F43A77
72 changed files with 2486 additions and 2407 deletions

View file

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

View file

@ -7,5 +7,6 @@
"video": false, "video": false,
"retries": { "retries": {
"runMode": 2 "runMode": 2
} },
"projectId": "181c7x"
} }

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

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

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

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

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

View file

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

View 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()
})
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 { </style>
opacity: 0;
} <style lang="scss">
// Close icon SVG uses currentColor, change the color to keep it visible
.modal-enter .modal-container, .dark .task-detail-view-modal .close {
.modal-leave-active .modal-container { color: var(--grey-900);
transform: scale(0.9);
} }
</style> </style>

View file

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

View file

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

View file

@ -365,3 +365,7 @@ export default {
}, },
} }
</script> </script>
<style lang="scss" scoped>
@include modal-transition();
</style>

View file

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

View file

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

View file

@ -400,4 +400,6 @@ export default {
transform: translate3d(0, -4px, 0); transform: translate3d(0, -4px, 0);
} }
} }
@include modal-transition();
</style> </style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,16 +302,16 @@ 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) date.setMonth(date.getMonth() + 1)
} }
if (date < now) { if (date.getDate() !== day) {
date.setMonth(date.getMonth() + 1) date.setDate(day)
} }
return { return {

View file

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

View file

@ -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')
}) })
@ -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', () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
declare module 'vue-flatpickr-component';

View file

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

View file

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

View file

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

View file

@ -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">
@ -119,39 +122,32 @@
</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>

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,9 @@
<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 {

View file

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

View file

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

View file

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

View file

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

View file

@ -308,4 +308,6 @@ export default {
padding: 0; padding: 0;
} }
} }
@include modal-transition();
</style> </style>

978
yarn.lock

File diff suppressed because it is too large Load diff