Merge branch 'main' into fix/upcoming

# Conflicts:
#	src/views/tasks/ShowTasks.vue
This commit is contained in:
kolaente 2022-02-05 20:02:30 +01:00
commit d272eb2a7a
No known key found for this signature in database
GPG key ID: F40E70337AB24C9B
123 changed files with 3637 additions and 3309 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

@ -1,4 +1,4 @@
import faker from 'faker' import faker from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns' import {formatISO} from 'date-fns'

View file

@ -1,4 +1,4 @@
import faker from 'faker' import faker from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns' import {formatISO} from 'date-fns'

View file

@ -1,6 +1,6 @@
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from "date-fns" import {formatISO} from "date-fns"
import faker from 'faker' import faker from '@faker-js/faker'
export class LinkShareFactory extends Factory { export class LinkShareFactory extends Factory {
static table = 'link_shares' static table = 'link_shares'

View file

@ -1,6 +1,6 @@
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from "date-fns" import {formatISO} from "date-fns"
import faker from 'faker' import faker from '@faker-js/faker'
export class ListFactory extends Factory { export class ListFactory extends Factory {
static table = 'lists' static table = 'lists'

View file

@ -1,4 +1,4 @@
import faker from 'faker' import faker from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns' import {formatISO} from 'date-fns'

View file

@ -1,4 +1,4 @@
import faker from 'faker' import faker from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns' import {formatISO} from 'date-fns'

View file

@ -1,4 +1,4 @@
import faker from 'faker' import faker from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from "date-fns" import {formatISO} from "date-fns"

View file

@ -1,4 +1,4 @@
import faker from 'faker' import faker from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns' import {formatISO} from 'date-fns'

View file

@ -1,4 +1,4 @@
import faker from 'faker' import faker from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from "date-fns" import {formatISO} from "date-fns"

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

@ -25,7 +25,6 @@ context('Registration', () => {
cy.get('#username').type(fixture.username) cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email) cy.get('#email').type(fixture.email)
cy.get('#password').type(fixture.password) cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click() cy.get('#register-submit').click()
cy.url().should('include', '/') cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00 cy.clock(1625656161057) // 13:00
@ -43,7 +42,6 @@ context('Registration', () => {
cy.get('#username').type(fixture.username) cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email) cy.get('#email').type(fixture.email)
cy.get('#password').type(fixture.password) cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click() cy.get('#register-submit').click()
cy.get('div.message.danger').contains('A user with this username already exists.') cy.get('div.message.danger').contains('A user with this username already exists.')
}) })

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,37 +18,37 @@
"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.26", "@vue/compat": "3.2.29",
"@vueuse/core": "7.5.2", "@vueuse/core": "7.5.5",
"@vueuse/router": "7.5.3", "@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.0", "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.15.0", "easymde": "2.16.1",
"flatpickr": "4.6.9", "flatpickr": "4.6.9",
"flexsearch": "0.7.21", "flexsearch": "0.7.21",
"highlight.js": "11.4.0", "highlight.js": "11.4.0",
"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.9", "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.13", "v-tooltip": "4.0.0-beta.17",
"vue": "3.2.26", "vue": "3.2.29",
"vue-advanced-cropper": "2.7.1", "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.26", "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,41 +56,40 @@
}, },
"devDependencies": { "devDependencies": {
"@4tw/cypress-drag-drop": "2.1.0", "@4tw/cypress-drag-drop": "2.1.0",
"@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.9.0", "@typescript-eslint/eslint-plugin": "5.10.2",
"@typescript-eslint/parser": "5.9.0", "@typescript-eslint/parser": "5.10.2",
"@vitejs/plugin-legacy": "1.6.4", "@vitejs/plugin-legacy": "1.6.4",
"@vitejs/plugin-vue": "2.0.1", "@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.24.0", "axios": "0.25.0",
"browserslist": "4.19.1", "browserslist": "4.19.1",
"caniuse-lite": "1.0.30001298", "caniuse-lite": "1.0.30001307",
"cypress": "9.2.0", "cypress": "9.4.1",
"cypress-file-upload": "5.0.8", "esbuild": "0.14.18",
"esbuild": "0.14.11", "eslint": "8.8.0",
"eslint": "8.6.0", "eslint-plugin-vue": "8.4.1",
"eslint-plugin-vue": "8.2.0",
"express": "4.17.2", "express": "4.17.2",
"faker": "5.5.3", "happy-dom": "2.31.1",
"netlify-cli": "8.6.15", "netlify-cli": "8.15.0",
"happy-dom": "2.25.1", "postcss": "8.4.6",
"postcss": "8.4.5", "postcss-preset-env": "7.3.1",
"postcss-preset-env": "7.2.0", "rollup": "2.67.0",
"rollup": "2.63.0", "rollup-plugin-visualizer": "5.5.4",
"rollup-plugin-visualizer": "5.5.2", "sass": "1.49.7",
"sass": "1.47.0",
"slugify": "1.6.5", "slugify": "1.6.5",
"typescript": "4.5.4", "typescript": "4.5.5",
"vite": "2.7.10", "vite": "2.7.13",
"vite-plugin-pwa": "0.11.12", "vite-plugin-pwa": "0.11.13",
"vite-svg-loader": "3.1.1", "vite-svg-loader": "3.1.2",
"vitest": "0.0.139", "vitest": "0.2.7",
"vue-tsc": "0.30.2", "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

@ -3,5 +3,11 @@
"labels": ["dependencies"], "labels": ["dependencies"],
"extends": [ "extends": [
"config:base" "config:base"
],
"packageRules": [
{
"matchPackageNames": ["netlify-cli"],
"extends": ["schedule:weekly"]
}
] ]
} }

View file

@ -42,7 +42,7 @@ import {useBodyClass} from '@/composables/useBodyClass'
const store = useStore() const store = useStore()
const router = useRouter() const router = useRouter()
useBodyClass('is-touch', isTouchDevice) useBodyClass('is-touch', isTouchDevice())
const keyboardShortcutsActive = computed(() => store.state.keyboardShortcutsActive) const keyboardShortcutsActive = computed(() => store.state.keyboardShortcutsActive)
const authUser = computed(() => store.getters['auth/authUser']) const authUser = computed(() => store.getters['auth/authUser'])

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -1,33 +1,51 @@
<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})`}"
class="app-container" class="app-container"
> >
<navigation/> <navigation/>
<div <main
:class="[ :class="[
{ 'is-menu-enabled': menuActive }, { 'is-menu-enabled': menuActive },
$route.name, $route.name,
]" ]"
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()"
@ -35,13 +53,13 @@
> >
<icon icon="keyboard"/> <icon icon="keyboard"/>
</a> </a>
</div> </main>
</div> </div>
</div> </div>
</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

@ -1,8 +1,8 @@
<template> <template>
<div :class="{'is-active': menuActive}" class="namespace-container"> <aside :class="{'is-active': menuActive}" class="namespace-container">
<div class="menu top-menu"> <nav class="menu top-menu">
<router-link :to="{name: 'home'}" class="logo"> <router-link :to="{name: 'home'}" class="logo">
<Logo width="164" height="48" /> <Logo width="164" height="48"/>
</router-link> </router-link>
<ul class="menu-list"> <ul class="menu-list">
<li> <li>
@ -46,31 +46,35 @@
</router-link> </router-link>
</li> </li>
</ul> </ul>
</div> </nav>
<aside class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}"> <nav class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
<template v-for="(n, nk) in namespaces" :key="n.id" > <template v-for="(n, nk) in namespaces" :key="n.id">
<div class="namespace-title" :class="{'has-menu': n.id > 0}"> <div class="namespace-title" :class="{'has-menu': n.id > 0}">
<span <span
@click="toggleLists(n.id)" @click="toggleLists(n.id)"
class="menu-label" class="menu-label"
v-tooltip="namespaceTitles[nk]"> v-tooltip="namespaceTitles[nk]"
>
<span
v-if="n.hexColor !== ''"
:style="{ backgroundColor: n.hexColor }"
class="color-bubble"
/>
<span class="name"> <span class="name">
<span
:style="{ backgroundColor: n.hexColor }"
class="color-bubble"
v-if="n.hexColor !== ''">
</span>
{{ namespaceTitles[nk] }} {{ namespaceTitles[nk] }}
</span> </span>
<a
class="icon is-small toggle-lists-icon pl-2"
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
@click="toggleLists(n.id)"
>
<icon icon="chevron-down"/>
</a>
<span class="count" :class="{'ml-2 mr-0': n.id > 0}">
({{ namespaceListsCount[nk] }})
</span>
</span> </span>
<a
class="icon is-small toggle-lists-icon"
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
@click="toggleLists(n.id)"
>
<icon icon="chevron-down"/>
</a>
<namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/> <namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
</div> </div>
<div <div
@ -81,18 +85,20 @@
<!-- <!--
NOTE: a v-model / computed setter is not possible, since the updateActiveLists function NOTE: a v-model / computed setter is not possible, since the updateActiveLists function
triggered by the change needs to have access to the current namespace triggered by the change needs to have access to the current namespace
--> -->
<draggable <draggable
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',
@ -134,7 +140,7 @@
:class="{'is-favorite': l.isFavorite}" :class="{'is-favorite': l.isFavorite}"
@click.prevent.stop="toggleFavoriteList(l)" @click.prevent.stop="toggleFavoriteList(l)"
class="favorite"> class="favorite">
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']" /> <icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
</span> </span>
</a> </a>
</router-link> </router-link>
@ -145,9 +151,9 @@
</draggable> </draggable>
</div> </div>
</template> </template>
</aside> </nav>
<PoweredByLink /> <PoweredByLink/>
</div> </aside>
</template> </template>
<script> <script>
@ -194,13 +200,13 @@ 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, index) => { return this.namespaces.map((namespace) => this.getNamespaceTitle(namespace))
const title = this.getNamespaceTitle(namespace) },
return `${title} (${this.activeLists[index]?.length ?? 0})` namespaceListsCount() {
}) return this.namespaces.map((_, index) => this.activeLists[index]?.length ?? 0)
}, },
}, },
beforeCreate() { beforeCreate() {
@ -237,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,
@ -255,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
@ -269,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
@ -365,8 +375,9 @@ $vikunja-nav-selected-width: 0.4rem;
.menu-label { .menu-label {
.color-bubble { .color-bubble {
width: 14px !important; width: 14px;
height: 14px !important; height: 14px;
flex-basis: auto;
} }
.is-archived { .is-archived {
@ -387,6 +398,12 @@ $vikunja-nav-selected-width: 0.4rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
margin-right: auto;
}
.count {
color: var(--grey-500);
margin-right: .5rem;
} }
} }
@ -482,7 +499,7 @@ $vikunja-nav-selected-width: 0.4rem;
height: 1rem; height: 1rem;
vertical-align: middle; vertical-align: middle;
padding-right: 0.5rem; padding-right: 0.5rem;
&.handle { &.handle {
opacity: 0; opacity: 0;
transition: opacity $transition; transition: opacity $transition;
@ -490,7 +507,7 @@ $vikunja-nav-selected-width: 0.4rem;
cursor: grab; cursor: grab;
} }
} }
&:hover .icon.handle { &:hover .icon.handle {
opacity: 1; opacity: 1;
} }
@ -542,7 +559,7 @@ $vikunja-nav-selected-width: 0.4rem;
span.list-menu-link, li > a { span.list-menu-link, li > a {
padding-left: 2rem; padding-left: 2rem;
display: inline-block; display: inline-block;
.icon { .icon {
padding-bottom: .25rem; padding-bottom: .25rem;
} }

View file

@ -1,9 +1,8 @@
<template> <template>
<nav <header
:class="{'has-background': background}" :class="{'has-background': background}"
aria-label="main navigation" aria-label="main navigation"
class="navbar main-theme is-fixed-top" class="navbar main-theme is-fixed-top"
role="navigation"
> >
<router-link :to="{name: 'home'}" class="logo-link"> <router-link :to="{name: 'home'}" class="logo-link">
<Logo width="164" height="48"/> <Logo width="164" height="48"/>
@ -77,7 +76,7 @@
</dropdown> </dropdown>
</div> </div>
</div> </div>
</nav> </header>
</template> </template>
<script> <script>

View file

@ -85,4 +85,8 @@ export default {
margin-left: .5rem; margin-left: .5rem;
} }
} }
.dark .update-notification {
color: var(--grey-200);
}
</style> </style>

View file

@ -0,0 +1,85 @@
<template>
<div class="password-field">
<input
class="input"
id="password"
name="password"
:placeholder="$t('user.auth.passwordPlaceholder')"
required
:type="passwordFieldType"
autocomplete="current-password"
@keyup.enter="e => $emit('submit', e)"
:tabindex="props.tabindex"
@focusout="validate"
@input="handleInput"
/>
<a
@click="togglePasswordFieldType"
class="password-field-type-toggle"
aria-label="passwordFieldType === 'password' ? $t('user.auth.showPassword') : $t('user.auth.hidePassword')"
v-tooltip="passwordFieldType === 'password' ? $t('user.auth.showPassword') : $t('user.auth.hidePassword')">
<icon :icon="passwordFieldType === 'password' ? 'eye' : 'eye-slash'"/>
</a>
</div>
<p class="help is-danger" v-if="!isValid">
{{ $t('user.auth.passwordRequired') }}
</p>
</template>
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {useDebounceFn} from '@vueuse/core'
const props = defineProps({
tabindex: String,
modelValue: String,
// This prop is a workaround to trigger validation from the outside when the user never had focus in the input.
validateInitially: Boolean,
})
const emit = defineEmits(['submit', 'update:modelValue'])
const passwordFieldType = ref<String>('password')
const password = ref<String>('')
const isValid = ref<Boolean>(!props.validateInitially)
watch(
() => props.validateInitially,
(doValidate: Boolean) => {
if (doValidate) {
validate()
}
},
)
function validate() {
useDebounceFn(() => {
isValid.value = password.value !== ''
}, 100)()
}
function togglePasswordFieldType() {
passwordFieldType.value = passwordFieldType.value === 'password'
? 'text'
: 'password'
}
function handleInput(e) {
password.value = e.target.value
emit('update:modelValue', e.target.value)
}
</script>
<style scoped>
.password-field {
position: relative;
}
.password-field-type-toggle {
position: absolute;
color: var(--grey-400);
top: 50%;
right: 1rem;
transform: translateY(-50%);
}
</style>

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

@ -28,19 +28,20 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {ref, watch} from 'vue' import {PropType, ref, watch} from 'vue'
import {useStore} from 'vuex' import {useStore} from 'vuex'
import ListService from '@/services/list' import ListService from '@/services/list'
import {colorIsDark} from '@/helpers/color/colorIsDark' import {colorIsDark} from '@/helpers/color/colorIsDark'
import ListModel from '@/models/list'
const background = ref<string | null>(null) const background = ref<string | null>(null)
const backgroundLoading = ref(false) const backgroundLoading = ref(false)
const props = defineProps({ const props = defineProps({
list: { list: {
type: Object, type: Object as PropType<ListModel>,
required: true, required: true,
}, },
showArchived: { showArchived: {
@ -68,7 +69,7 @@ async function loadBackground() {
const store = useStore() const store = useStore()
function toggleFavoriteList(list) { function toggleFavoriteList(list: ListModel) {
// The favorites pseudo list is always favorite // The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite // Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) { if (list.id === -1 || list.isArchived) {

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

@ -1,18 +1,35 @@
<template> <template>
<div class="message-wrapper"> <div class="message-wrapper">
<div class="message" :class="variant"> <div class="message" :class="[variant, textAlignClass]">
<slot/> <slot/>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
defineProps({ import {computed, PropType} from 'vue'
const TEXT_ALIGN_MAP = Object.freeze({
left: '',
center: 'has-text-centered',
right: 'has-text-right',
})
type textAlignVariants = keyof typeof TEXT_ALIGN_MAP
const props = defineProps({
variant: { variant: {
type: String, type: String,
default: 'info', default: 'info',
}, },
textAlign: {
type: String as PropType<textAlignVariants>,
default: 'left',
},
}) })
const textAlignClass = computed(() => TEXT_ALIGN_MAP[props.textAlign])
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -14,6 +14,9 @@
<div> <div>
<h2 class="title" v-if="title">{{ title }}</h2> <h2 class="title" v-if="title">{{ title }}</h2>
<api-config/> <api-config/>
<Message v-if="motd !== ''" class="is-hidden-tablet mb-4">
{{ motd }}
</Message>
<slot/> <slot/>
</div> </div>
<legal/> <legal/>
@ -38,8 +41,8 @@ const store = useStore()
const {t} = useI18n() const {t} = useI18n()
const motd = computed(() => store.state.config.motd) const motd = computed(() => store.state.config.motd)
// @ts-ignore
const title = computed(() => t(route.meta.title ?? '')) const title = computed(() => t(route.meta?.title as string || ''))
useTitle(() => title.value) useTitle(() => title.value)
</script> </script>

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,53 +1,51 @@
<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,
}, const props = withDefaults(defineProps<Props>(), {
entityId: { isButton: true,
required: true,
},
isButton: {
type: Boolean,
default: true,
},
}) })
const subscriptionEntity = computed<string>(() => props.subscription.entity)
const emit = defineEmits(['change']) const emit = defineEmits(['change'])
const subscriptionService = shallowRef(new SubscriptionService()) const subscriptionService = shallowRef(new SubscriptionService())
@ -57,7 +55,7 @@ const tooltipText = computed(() => {
if (disabled.value) { if (disabled.value) {
return t('task.subscription.subscribedThroughParent', { return t('task.subscription.subscribedThroughParent', {
entity: props.entity, entity: props.entity,
parent: props.subscription.entity, parent: subscriptionEntity.value,
}) })
} }
@ -67,13 +65,13 @@ 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
} }
return props.subscription.entity !== props.entity return subscriptionEntity.value !== props.entity
}) })
function changeSubscription() { function changeSubscription() {

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

View file

@ -13,6 +13,7 @@
import {ref, computed} from 'vue' import {ref, computed} from 'vue'
import {useStore} from 'vuex' import {useStore} from 'vuex'
import Multiselect from '@/components/input/multiselect.vue' import Multiselect from '@/components/input/multiselect.vue'
import NamespaceModel from '@/models/namespace'
const emit = defineEmits(['selected']) const emit = defineEmits(['selected'])
@ -25,7 +26,7 @@ function findNamespaces(newQuery: string) {
query.value = newQuery query.value = newQuery
} }
function select(namespace) { function select(namespace: NamespaceModel) {
emit('selected', namespace) emit('selected', namespace)
} }
</script> </script>

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

@ -34,7 +34,7 @@
> >
<div class="filename">{{ a.file.name }}</div> <div class="filename">{{ a.file.name }}</div>
<div class="info"> <div class="info">
<p class="collapses"> <p class="attachment-info-meta">
<i18n-t keypath="task.attachment.createdBy"> <i18n-t keypath="task.attachment.createdBy">
<span v-tooltip="formatDate(a.created)"> <span v-tooltip="formatDate(a.created)">
{{ formatDateSince(a.created) }} {{ formatDateSince(a.created) }}
@ -289,21 +289,6 @@ export default {
content: '·'; content: '·';
padding: 0 .25rem; padding: 0 .25rem;
} }
@media screen and (max-width: $mobile) {
&.collapses {
flex-direction: column;
> span:not(:last-child):after,
> a:not(:last-child):after {
display: none;
}
.user .username {
display: none;
}
}
}
} }
} }
} }
@ -341,6 +326,10 @@ export default {
height: auto; height: auto;
text-shadow: var(--shadow-md); text-shadow: var(--shadow-md);
animation: bounce 2s infinite; animation: bounce 2s infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
} }
.hint { .hint {
@ -357,6 +346,35 @@ export default {
} }
} }
.attachment-info-meta {
display: flex;
align-items: center;
:deep(.user) {
display: flex !important;
align-items: center;
margin: 0 .5rem;
}
@media screen and (max-width: $mobile) {
flex-direction: column;
align-items: flex-start;
:deep(.user) {
margin: .5rem 0;
}
> span:not(:last-child):after,
> a:not(:last-child):after {
display: none;
}
.user .username {
display: none;
}
}
}
@keyframes bounce { @keyframes bounce {
from, from,
20%, 20%,
@ -382,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

@ -1,6 +1,8 @@
<template> <template>
<td v-tooltip="+date === 0 ? '' : formatDate(date)"> <td v-tooltip="+date === 0 ? '' : formatDate(date)">
{{ +date === 0 ? '-' : formatDateSince(date) }} <time :datetime="date ? formatISO(date) : null">
{{ +date === 0 ? '-' : formatDateSince(date) }}
</time>
</td> </td>
</template> </template>

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,19 +19,19 @@
: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">
<span <span
v-if="typeof props.option === 'string'" v-if="typeof props.option === 'string'"
class="tag"> class="tag search-result">
<span>{{ props.option }}</span> <span>{{ props.option }}</span>
</span> </span>
<span <span
v-else v-else
:style="{'background': props.option.hexColor, 'color': props.option.textColor}" :style="{'background': props.option.hexColor, 'color': props.option.textColor}"
class="tag"> class="tag search-result">
<span>{{ props.option.title }}</span> <span>{{ props.option.title }}</span>
</span> </span>
</template> </template>
@ -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')})
}, },
@ -152,6 +146,18 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.tag { .tag {
margin: .5rem 0 0 .5rem; margin: .25rem !important;
}
.tag.search-result {
margin: 0 !important;
}
:deep(.input-wrapper) {
padding: .25rem !important;
}
:deep(input.input) {
padding: 0 .5rem;
} }
</style> </style>

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">
@ -28,9 +28,9 @@
<span class="icon"> <span class="icon">
<icon :icon="['far', 'calendar-alt']"/> <icon :icon="['far', 'calendar-alt']"/>
</span> </span>
<span> <time :datetime="formatISO(task.dueDate)">
{{ formatDateSince(task.dueDate) }} {{ formatDateSince(task.dueDate) }}
</span> </time>
</span> </span>
<h3>{{ task.title }}</h3> <h3>{{ task.title }}</h3>
<progress <progress
@ -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>
@ -39,14 +39,17 @@
:user="a" :user="a"
v-for="(a, i) in task.assignees" v-for="(a, i) in task.assignees"
/> />
<i <time
:datetime="formatISO(task.dueDate)"
:class="{'overdue': task.dueDate <= new Date() && !task.done}" :class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="is-italic"
@click.prevent.stop="showDefer = !showDefer" @click.prevent.stop="showDefer = !showDefer"
v-if="+new Date(task.dueDate) > 0" v-if="+new Date(task.dueDate) > 0"
v-tooltip="formatDate(task.dueDate)" v-tooltip="formatDate(task.dueDate)"
:aria-expanded="showDefer ? 'true' : 'false'"
> >
- {{ $t('task.detail.due', {at: formatDateSince(task.dueDate)}) }} - {{ $t('task.detail.due', {at: formatDateSince(task.dueDate)}) }}
</i> </time>
<transition name="fade"> <transition name="fade">
<defer-task v-if="+new Date(task.dueDate) > 0 && showDefer" v-model="task" ref="deferDueDate"/> <defer-task v-if="+new Date(task.dueDate) > 0 && showDefer" v-model="task" ref="deferDueDate"/>
</transition> </transition>
@ -126,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,
@ -167,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, watch, readonly} from 'vue' import {computed, watch, readonly} from 'vue'
import {useStorage, createSharedComposable, ColorSchema, usePreferredColorScheme, tryOnMounted} from '@vueuse/core' import {useStorage, createSharedComposable, BasicColorSchema, usePreferredColorScheme, tryOnMounted} from '@vueuse/core'
const STORAGE_KEY = 'color-scheme' const STORAGE_KEY = 'color-scheme'
const DEFAULT_COLOR_SCHEME_SETTING: ColorSchema = 'light' const DEFAULT_COLOR_SCHEME_SETTING: BasicColorSchema = 'light'
const CLASS_DARK = 'dark' const CLASS_DARK = 'dark'
const CLASS_LIGHT = 'light' const CLASS_LIGHT = 'light'
@ -16,7 +16,7 @@ const CLASS_LIGHT = 'light'
// - value is synced via `createSharedComposable` // - value is synced via `createSharedComposable`
// https://github.com/vueuse/vueuse/blob/main/packages/core/useDark/index.ts // https://github.com/vueuse/vueuse/blob/main/packages/core/useDark/index.ts
export const useColorScheme = createSharedComposable(() => { export const useColorScheme = createSharedComposable(() => {
const store = useStorage<ColorSchema>(STORAGE_KEY, DEFAULT_COLOR_SCHEME_SETTING) const store = useStorage<BasicColorSchema>(STORAGE_KEY, DEFAULT_COLOR_SCHEME_SETTING)
const preferredColorScheme = usePreferredColorScheme() const preferredColorScheme = usePreferredColorScheme()

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

6
src/helpers/isEmail.ts Normal file
View file

@ -0,0 +1,6 @@
export function isEmail(email: string): Boolean {
const format = /^.+@.+$/
const match = email.match(format)
return match === null ? false : match.length > 0
}

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

@ -1,5 +1,5 @@
import {createDateFromString} from '@/helpers/time/createDateFromString' import {createDateFromString} from '@/helpers/time/createDateFromString'
import {format, formatDistanceToNow} from 'date-fns' import {format, formatDistanceToNow, formatISO as formatISOfns} from 'date-fns'
import {enGB, de, fr, ru} from 'date-fns/locale' import {enGB, de, fr, ru} from 'date-fns/locale'
import {i18n} from '@/i18n' import {i18n} from '@/i18n'
@ -44,3 +44,7 @@ export const formatDateSince = (date) => {
addSuffix: true, addSuffix: true,
}) })
} }
export function formatISO(date) {
return date ? formatISOfns(date) : ''
}

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,17 +302,17 @@ const getDayFromText = (text: string) => {
const day = parseInt(results[0]) const day = parseInt(results[0])
date.setDate(day) date.setDate(day)
// If the parsed day is the 31st but the next month only has 30 days, setting the day to 31 will "overflow" the // If the parsed day is the 31st (or 29+ and the next month is february) but the next month only has 30 days,
// date to the next month, but the first. // setting the day to 31 will "overflow" the date to the next month, but the first.
// This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after // This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after
// setting it for the first time and set it again if it isn't - that would mean the month overflowed. // setting it for the first time and set it again if it isn't - that would mean the month overflowed.
if (day === 31 && date.getDate() !== day) { while (date < now) {
date.setDate(day)
}
if (date < now) {
date.setMonth(date.getMonth() + 1) date.setMonth(date.getMonth() + 1)
} }
if (date.getDate() !== day) {
date.setDate(day)
}
return { return {
foundText: results[0], foundText: results[0],

View file

@ -899,7 +899,7 @@
"4015": "Komentář k úkolu neexistuje.", "4015": "Komentář k úkolu neexistuje.",
"4016": "Neplatné pole úkolu.", "4016": "Neplatné pole úkolu.",
"4017": "Neplatný komparátor filtru úkolů.", "4017": "Neplatný komparátor filtru úkolů.",
"4018": "Neplatný koncatinátor filtru úkolů.", "4018": "Invalid task filter concatenator.",
"4019": "Neplatná hodnota filtru úkolů.", "4019": "Neplatná hodnota filtru úkolů.",
"5001": "Prostor neexistuje.", "5001": "Prostor neexistuje.",
"5003": "Nemáte přístup ke zvolenému prostoru.", "5003": "Nemáte přístup ke zvolenému prostoru.",

View file

@ -7,7 +7,7 @@
"lastViewed": "Zuletzt angesehen", "lastViewed": "Zuletzt angesehen",
"list": { "list": {
"newText": "Du kannst eine neue Liste für deine neuen Aufgaben erstellen:", "newText": "Du kannst eine neue Liste für deine neuen Aufgaben erstellen:",
"new": "New list", "new": "Neue Liste",
"importText": "Oder importiere deine Listen und Aufgaben aus anderen Diensten in Vikunja:", "importText": "Oder importiere deine Listen und Aufgaben aus anderen Diensten in Vikunja:",
"import": "Deine Daten in Vikunja importieren" "import": "Deine Daten in Vikunja importieren"
} }
@ -157,7 +157,7 @@
"searchSelect": "Klicke auf oder drücke die Eingabetaste, um diese Liste auszuwählen", "searchSelect": "Klicke auf oder drücke die Eingabetaste, um diese Liste auszuwählen",
"shared": "Geteilte Listen", "shared": "Geteilte Listen",
"create": { "create": {
"header": "New list", "header": "Neue Liste",
"titlePlaceholder": "Der Titel der Liste steht hier…", "titlePlaceholder": "Der Titel der Liste steht hier…",
"addTitleRequired": "Bitte gebe einen Namen an.", "addTitleRequired": "Bitte gebe einen Namen an.",
"createdSuccess": "Die Liste wurde erfolgreich erstellt.", "createdSuccess": "Die Liste wurde erfolgreich erstellt.",
@ -315,7 +315,7 @@
"namespaces": "Namespaces", "namespaces": "Namespaces",
"search": "Beginne zu schreiben, um einen Namespace zu suchen…", "search": "Beginne zu schreiben, um einen Namespace zu suchen…",
"create": { "create": {
"title": "New namespace", "title": "Neuer Namespace",
"titleRequired": "Bitte gebe einen Titel an.", "titleRequired": "Bitte gebe einen Titel an.",
"explanation": "Ein Namespace ist eine Sammlung von Listen, die du teilen und zur Organisation verwenden kannst. Jede Liste zu einem Namespace.", "explanation": "Ein Namespace ist eine Sammlung von Listen, die du teilen und zur Organisation verwenden kannst. Jede Liste zu einem Namespace.",
"tooltip": "Was ist ein Namespace?", "tooltip": "Was ist ein Namespace?",
@ -383,7 +383,7 @@
"reminderRange": "Erinnerungs-Datumsbereich" "reminderRange": "Erinnerungs-Datumsbereich"
}, },
"create": { "create": {
"title": "New Saved Filter", "title": "Neuer gespeicherter Filter",
"description": "Ein gespeicherter Filter ist eine virtuelle Liste, die bei jedem Zugriff aus einem Satz von Filtern errechnet wird. Einmal erstellt, erscheint diese in einem speziellen Namespace.", "description": "Ein gespeicherter Filter ist eine virtuelle Liste, die bei jedem Zugriff aus einem Satz von Filtern errechnet wird. Einmal erstellt, erscheint diese in einem speziellen Namespace.",
"action": "Neuen gespeicherten Filter erstellen" "action": "Neuen gespeicherten Filter erstellen"
}, },
@ -545,7 +545,7 @@
"chooseStartDate": "Klicke hier, um ein Startdatum zu setzen", "chooseStartDate": "Klicke hier, um ein Startdatum zu setzen",
"chooseEndDate": "Klicke hier, um ein Enddatum zu setzen", "chooseEndDate": "Klicke hier, um ein Enddatum zu setzen",
"move": "Aufgabe in eine andere Liste verschieben", "move": "Aufgabe in eine andere Liste verschieben",
"done": "Mark task done!", "done": "Als erledigt markieren!",
"undone": "Als nicht erledigt markieren", "undone": "Als nicht erledigt markieren",
"created": "Erstellt {0} von {1}", "created": "Erstellt {0} von {1}",
"updated": "Aktualisiert {0}", "updated": "Aktualisiert {0}",
@ -781,7 +781,7 @@
"then": "dann", "then": "dann",
"task": { "task": {
"title": "Aufgabenseite", "title": "Aufgabenseite",
"done": "Done", "done": "Fertig",
"assign": "Benutzer:in zuweisen", "assign": "Benutzer:in zuweisen",
"labels": "Dieser Aufgabe ein Label hinzufügen", "labels": "Dieser Aufgabe ein Label hinzufügen",
"dueDate": "Ändere das Fälligkeitsdatum dieser Aufgabe", "dueDate": "Ändere das Fälligkeitsdatum dieser Aufgabe",
@ -899,7 +899,7 @@
"4015": "Dieser Aufgabenkommentar existiert nicht.", "4015": "Dieser Aufgabenkommentar existiert nicht.",
"4016": "Ungültiges Aufgabenfeld.", "4016": "Ungültiges Aufgabenfeld.",
"4017": "Ungültiger Aufgabenfilter (Vergleichskriterium).", "4017": "Ungültiger Aufgabenfilter (Vergleichskriterium).",
"4018": "Ungültiger Aufgabenfilter (Kombination).", "4018": "Ungültige Verkettung von Aufgabenfiltern.",
"4019": "Ungültiger Aufgabenfilter (Wert).", "4019": "Ungültiger Aufgabenfilter (Wert).",
"5001": "Dieser Namespace existiert nicht.", "5001": "Dieser Namespace existiert nicht.",
"5003": "Du hast keinen Zugriff auf den Namespace.", "5003": "Du hast keinen Zugriff auf den Namespace.",

View file

@ -7,7 +7,7 @@
"lastViewed": "Zletscht ahglueget", "lastViewed": "Zletscht ahglueget",
"list": { "list": {
"newText": "Du chasch e Liste für dini neue Uufgabe erstelle:", "newText": "Du chasch e Liste für dini neue Uufgabe erstelle:",
"new": "New list", "new": "Neue Liste",
"importText": "Oder importier dini Liste und Uufgabe us anderne Dienst nach Vikunja:", "importText": "Oder importier dini Liste und Uufgabe us anderne Dienst nach Vikunja:",
"import": "Dini Date in Vikunja importiere" "import": "Dini Date in Vikunja importiere"
} }
@ -157,7 +157,7 @@
"searchSelect": "Druck uf Enter um die Liste uuszwähle", "searchSelect": "Druck uf Enter um die Liste uuszwähle",
"shared": "Teilti Liste", "shared": "Teilti Liste",
"create": { "create": {
"header": "New list", "header": "Neue Liste",
"titlePlaceholder": "Listetitl da ahgeh…", "titlePlaceholder": "Listetitl da ahgeh…",
"addTitleRequired": "Bitte gib en Titl ah.", "addTitleRequired": "Bitte gib en Titl ah.",
"createdSuccess": "Liste erfolgriich erstellt.", "createdSuccess": "Liste erfolgriich erstellt.",
@ -315,7 +315,7 @@
"namespaces": "Namensrüüm", "namespaces": "Namensrüüm",
"search": "Schriib, um nachemne Namensruum z'sueche…", "search": "Schriib, um nachemne Namensruum z'sueche…",
"create": { "create": {
"title": "New namespace", "title": "Neuer Namespace",
"titleRequired": "Bitte gib en Titl ah.", "titleRequired": "Bitte gib en Titl ah.",
"explanation": "En Namensruum isch e Gruppe vo Liste, wo du chasch zur Organisation benutze. Tatsächlich sind alli Listene emne Namensruum zuegwise.", "explanation": "En Namensruum isch e Gruppe vo Liste, wo du chasch zur Organisation benutze. Tatsächlich sind alli Listene emne Namensruum zuegwise.",
"tooltip": "Was isch en Namensruum?", "tooltip": "Was isch en Namensruum?",
@ -383,7 +383,7 @@
"reminderRange": "Errinnerigs Datumbereich" "reminderRange": "Errinnerigs Datumbereich"
}, },
"create": { "create": {
"title": "New Saved Filter", "title": "Neuer gespeicherter Filter",
"description": "En gspeicherete Filter isch e virtuelli Liste, welche vomene Satz a Filter zemmegsetzt wird, sobald me uf sie zuegriift. Wenn sie mal erstellt worde isch, erhaltet si ihren eigene Namensruum.", "description": "En gspeicherete Filter isch e virtuelli Liste, welche vomene Satz a Filter zemmegsetzt wird, sobald me uf sie zuegriift. Wenn sie mal erstellt worde isch, erhaltet si ihren eigene Namensruum.",
"action": "Neue gspeicherete Filter erstelle" "action": "Neue gspeicherete Filter erstelle"
}, },
@ -545,7 +545,7 @@
"chooseStartDate": "Druck dah, um es Startdatum z'setze", "chooseStartDate": "Druck dah, um es Startdatum z'setze",
"chooseEndDate": "Druck da, um es Enddatum z'setze", "chooseEndDate": "Druck da, um es Enddatum z'setze",
"move": "Schieb die Uufgab in e anderi Liste", "move": "Schieb die Uufgab in e anderi Liste",
"done": "Mark task done!", "done": "Als erledigt markieren!",
"undone": "Als unerledigt markierä", "undone": "Als unerledigt markierä",
"created": "Erstellt am {0} vo {1}", "created": "Erstellt am {0} vo {1}",
"updated": "{0} g'updatet", "updated": "{0} g'updatet",
@ -781,7 +781,7 @@
"then": "dann", "then": "dann",
"task": { "task": {
"title": "Uufgabesiite", "title": "Uufgabesiite",
"done": "Done", "done": "Fertig",
"assign": "Benutzer:in zuweisen", "assign": "Benutzer:in zuweisen",
"labels": "Labels ennere Uufgab hinzuefüege", "labels": "Labels ennere Uufgab hinzuefüege",
"dueDate": "S'Fälligkeitsdatum für die Uufgab ändere", "dueDate": "S'Fälligkeitsdatum für die Uufgab ändere",
@ -899,7 +899,7 @@
"4015": "De Uufgabe Kommentar giz nid.", "4015": "De Uufgabe Kommentar giz nid.",
"4016": "Ungültigs Uufgabefeld.", "4016": "Ungültigs Uufgabefeld.",
"4017": "Ungültige Uufgabefilter vergliich.", "4017": "Ungültige Uufgabefilter vergliich.",
"4018": "Ungültige Uufgabefilter Zemmezug.", "4018": "Ungültige Verkettung von Aufgabenfiltern.",
"4019": "Ungültigi Uufgabe Filter Wert.", "4019": "Ungültigi Uufgabe Filter Wert.",
"5001": "De Namensruum existiert nid.", "5001": "De Namensruum existiert nid.",
"5003": "Du hesch kei Zuegriff zu dem Namensruum.", "5003": "Du hesch kei Zuegriff zu dem Namensruum.",

View file

@ -31,10 +31,9 @@
"username": "Username", "username": "Username",
"usernameEmail": "Username Or Email Address", "usernameEmail": "Username Or Email Address",
"usernamePlaceholder": "e.g. frederick", "usernamePlaceholder": "e.g. frederick",
"email": "E-mail address", "email": "Email address",
"emailPlaceholder": "e.g. frederic{'@'}vikunja.io", "emailPlaceholder": "e.g. frederic{'@'}vikunja.io",
"password": "Password", "password": "Password",
"passwordRepeat": "Retype your password",
"passwordPlaceholder": "e.g. •••••••••••", "passwordPlaceholder": "e.g. •••••••••••",
"forgotPassword": "Forgot your password?", "forgotPassword": "Forgot your password?",
"resetPassword": "Reset your password", "resetPassword": "Reset your password",
@ -45,12 +44,19 @@
"totpTitle": "Two Factor Authentication Code", "totpTitle": "Two Factor Authentication Code",
"totpPlaceholder": "e.g. 123456", "totpPlaceholder": "e.g. 123456",
"login": "Login", "login": "Login",
"register": "Register", "createAccount": "Create account",
"loginWith": "Log in with {provider}", "loginWith": "Log in with {provider}",
"authenticating": "Authenticating…", "authenticating": "Authenticating…",
"openIdStateError": "State does not match, refusing to continue!", "openIdStateError": "State does not match, refusing to continue!",
"openIdGeneralError": "An error occured while authenticating against the third party.", "openIdGeneralError": "An error occured while authenticating against the third party.",
"logout": "Logout" "logout": "Logout",
"emailInvalid": "Please enter a valid email address.",
"usernameRequired": "Please provide a username.",
"passwordRequired": "Please provide a password.",
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?"
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
@ -61,7 +67,7 @@
"currentPasswordPlaceholder": "Your current password", "currentPasswordPlaceholder": "Your current password",
"passwordsDontMatch": "The new password and its confirmation don't match.", "passwordsDontMatch": "The new password and its confirmation don't match.",
"passwordUpdateSuccess": "The password was successfully updated.", "passwordUpdateSuccess": "The password was successfully updated.",
"updateEmailTitle": "Update Your E-Mail Address", "updateEmailTitle": "Update Your Email Address",
"updateEmailNew": "New Email Address", "updateEmailNew": "New Email Address",
"updateEmailSuccess": "Your email address was successfully updated. We've sent you a link to confirm it.", "updateEmailSuccess": "Your email address was successfully updated. We've sent you a link to confirm it.",
"general": { "general": {
@ -904,7 +910,7 @@
"4015": "The task comment does not exist.", "4015": "The task comment does not exist.",
"4016": "Invalid task field.", "4016": "Invalid task field.",
"4017": "Invalid task filter comparator.", "4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.", "4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.", "4019": "Invalid task filter value.",
"5001": "The namespace does not exist.", "5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.", "5003": "You do not have access to the specified namespace.",

View file

@ -899,7 +899,7 @@
"4015": "The task comment does not exist.", "4015": "The task comment does not exist.",
"4016": "Invalid task field.", "4016": "Invalid task field.",
"4017": "Invalid task filter comparator.", "4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.", "4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.", "4019": "Invalid task filter value.",
"5001": "The namespace does not exist.", "5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.", "5003": "You do not have access to the specified namespace.",

View file

@ -116,12 +116,12 @@
"vikunja": "Vikunja" "vikunja": "Vikunja"
}, },
"appearance": { "appearance": {
"title": "Color Scheme", "title": "Jeu de couleurs",
"setSuccess": "Saved change of color scheme to {colorScheme}", "setSuccess": "Changement du jeu de couleurs enregistré vers {colorScheme}",
"colorScheme": { "colorScheme": {
"light": "Light", "light": "Clair",
"system": "System", "system": "Système",
"dark": "Dark" "dark": "Sombre"
} }
} }
}, },
@ -475,7 +475,7 @@
"download": "Télécharger", "download": "Télécharger",
"showMenu": "Afficher le menu", "showMenu": "Afficher le menu",
"hideMenu": "Masquer le menu", "hideMenu": "Masquer le menu",
"forExample": "For example:", "forExample": "Par exemple :",
"welcomeBack": "Welcome Back!" "welcomeBack": "Welcome Back!"
}, },
"input": { "input": {
@ -561,7 +561,7 @@
"text2": "Ceci supprimera également toutes les pièces jointes, les rappels et les relations associés à cette tâche et ne pourra pas être annulé !" "text2": "Ceci supprimera également toutes les pièces jointes, les rappels et les relations associés à cette tâche et ne pourra pas être annulé !"
}, },
"actions": { "actions": {
"assign": "Assign to a user", "assign": "Attribuer à un utilisateur",
"label": "Ajouter des étiquettes", "label": "Ajouter des étiquettes",
"priority": "Définir la priorité", "priority": "Définir la priorité",
"dueDate": "Définir léchéance", "dueDate": "Définir léchéance",
@ -726,8 +726,8 @@
"dateCurrentYear": "utilisera lannée en cours", "dateCurrentYear": "utilisera lannée en cours",
"dateNth": "utilisera le {day}e du mois en cours", "dateNth": "utilisera le {day}e du mois en cours",
"dateTime": "Combinez nimporte lequel des formats de date avec « {time} » (ou {timePM}) pour définir une heure.", "dateTime": "Combinez nimporte lequel des formats de date avec « {time} » (ou {timePM}) pour définir une heure.",
"repeats": "Repeating tasks", "repeats": "Tâches répétitives",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)." "repeatsDescription": "Pour définir une tâche comme répétitive dans un intervalle, il suffit d'ajouter « {suffix} » au texte de la tâche. Le montant doit être un nombre et peut être omis pour utiliser uniquement le type (voir exemples)."
} }
}, },
"team": { "team": {
@ -782,7 +782,7 @@
"task": { "task": {
"title": "Page de tâche", "title": "Page de tâche",
"done": "Done", "done": "Done",
"assign": "Assign to a user", "assign": "Attribuer à un utilisateur",
"labels": "Ajouter des étiquettes à cette tâche", "labels": "Ajouter des étiquettes à cette tâche",
"dueDate": "Modifier la date déchéance de cette tâche", "dueDate": "Modifier la date déchéance de cette tâche",
"attachment": "Ajouter une pièce jointe à cette tâche", "attachment": "Ajouter une pièce jointe à cette tâche",
@ -899,7 +899,7 @@
"4015": "Le commentaire de la tâche nexiste pas.", "4015": "Le commentaire de la tâche nexiste pas.",
"4016": "Champ de tâche invalide.", "4016": "Champ de tâche invalide.",
"4017": "Comparateur de filtre de tâche invalide.", "4017": "Comparateur de filtre de tâche invalide.",
"4018": "Concaténateur de filtre de tâche invalide.", "4018": "Invalid task filter concatenator.",
"4019": "Valeur de filtre de tâche invalide.", "4019": "Valeur de filtre de tâche invalide.",
"5001": "Lespace de noms nexiste pas.", "5001": "Lespace de noms nexiste pas.",
"5003": "Tu nas pas accès à lespace de noms indiqué.", "5003": "Tu nas pas accès à lespace de noms indiqué.",
@ -908,7 +908,7 @@
"5010": "Cette équipe na pas accès à cet espace de noms.", "5010": "Cette équipe na pas accès à cet espace de noms.",
"5011": "Cet·e utilisateur·rice a déjà accès à cet espace de noms.", "5011": "Cet·e utilisateur·rice a déjà accès à cet espace de noms.",
"5012": "Lespace de noms est archivé et ne peut donc être consulté quen lecture seule.", "5012": "Lespace de noms est archivé et ne peut donc être consulté quen lecture seule.",
"6001": "The team name cannot be empty.", "6001": "Le nom de l'équipe ne peut pas être vide.",
"6002": "Léquipe nexiste pas.", "6002": "Léquipe nexiste pas.",
"6004": "Léquipe a déjà accès à cet espace de noms ou à cette liste.", "6004": "Léquipe a déjà accès à cet espace de noms ou à cette liste.",
"6005": "Lutilisateur·rice est déjà membre de cette équipe.", "6005": "Lutilisateur·rice est déjà membre de cette équipe.",

View file

@ -7,7 +7,7 @@
"lastViewed": "Ultima visualizzazione", "lastViewed": "Ultima visualizzazione",
"list": { "list": {
"newText": "È possibile creare una nuova lista per le nuove attività:", "newText": "È possibile creare una nuova lista per le nuove attività:",
"new": "New list", "new": "Nuova lista",
"importText": "O importare le liste e le attività da altri servizi in Vikunja:", "importText": "O importare le liste e le attività da altri servizi in Vikunja:",
"import": "Importa i tuoi dati in Vikunja" "import": "Importa i tuoi dati in Vikunja"
} }
@ -17,14 +17,14 @@
"text": "La pagina richiesta non esiste." "text": "La pagina richiesta non esiste."
}, },
"ready": { "ready": {
"loading": "Vikunja is loading…", "loading": "Vikunja sta caricando…",
"errorOccured": "An error occured:", "errorOccured": "Si è verificato un errore:",
"checkApiUrl": "Please check if the api url is correct.", "checkApiUrl": "Controlla se l'URL API è corretto.",
"noApiUrlConfigured": "No API url was configured. Please set one below:" "noApiUrlConfigured": "Nessun URL API configurato. Impostane uno qui sotto:"
}, },
"offline": { "offline": {
"title": "You are offline.", "title": "Sei offline.",
"text": "Please check your network connection and try again." "text": "Controlla la connessione di rete e riprova."
}, },
"user": { "user": {
"auth": { "auth": {
@ -36,7 +36,7 @@
"password": "Password", "password": "Password",
"passwordRepeat": "Digita di nuovo la tua password", "passwordRepeat": "Digita di nuovo la tua password",
"passwordPlaceholder": "es. ••••••••••••", "passwordPlaceholder": "es. ••••••••••••",
"forgotPassword": "Forgot your password?", "forgotPassword": "Password dimenticata?",
"resetPassword": "Reimposta la tua password", "resetPassword": "Reimposta la tua password",
"resetPasswordAction": "Inviami il link per reimpostare la password", "resetPasswordAction": "Inviami il link per reimpostare la password",
"resetPasswordSuccess": "Controlla la tua casella di posta! Dovresti avere un'e-mail con le istruzioni su come reimpostare la password.", "resetPasswordSuccess": "Controlla la tua casella di posta! Dovresti avere un'e-mail con le istruzioni su come reimpostare la password.",
@ -48,7 +48,7 @@
"register": "Registrati", "register": "Registrati",
"loginWith": "Accedi con {provider}", "loginWith": "Accedi con {provider}",
"authenticating": "Autenticazione…", "authenticating": "Autenticazione…",
"openIdStateError": "State does not match, refusing to continue!", "openIdStateError": "Stato non corrispondente, impossibile continuare!",
"openIdGeneralError": "Si è verificato un errore durante l'autenticazione con terze parti.", "openIdGeneralError": "Si è verificato un errore durante l'autenticazione con terze parti.",
"logout": "Esci" "logout": "Esci"
}, },
@ -103,31 +103,31 @@
"title": "Avatar", "title": "Avatar",
"initials": "Iniziali", "initials": "Iniziali",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble", "marble": "Marmo",
"upload": "Carica", "upload": "Carica",
"uploadAvatar": "Carica Avatar", "uploadAvatar": "Carica Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!", "statusUpdateSuccess": "Avatar aggiornato!",
"setSuccess": "L'avatar è stato impostato con successo!" "setSuccess": "L'avatar è stato impostato con successo!"
}, },
"quickAddMagic": { "quickAddMagic": {
"title": "Quick Add Magic Mode", "title": "Modalità Aggiunta Rapida Magica",
"disabled": "Disabilitato", "disabled": "Disabilitato",
"todoist": "Todoist", "todoist": "Todoist",
"vikunja": "Vikunja" "vikunja": "Vikunja"
}, },
"appearance": { "appearance": {
"title": "Color Scheme", "title": "Tema",
"setSuccess": "Saved change of color scheme to {colorScheme}", "setSuccess": "Tema cambiato in {colorScheme}",
"colorScheme": { "colorScheme": {
"light": "Light", "light": "Chiaro",
"system": "System", "system": "Sistema",
"dark": "Dark" "dark": "Scuro"
} }
} }
}, },
"deletion": { "deletion": {
"title": "Delete your Vikunja Account", "title": "Elimina il tuo Account Vikunja",
"text1": "The deletion of your account is permanent and cannot be undone. We will delete all your namespaces, lists, tasks and everything associated with it.", "text1": "La cancellazione del tuo account è permanente e non può essere annullata. Elimineremo tutti i tuoi namespace, liste, attività e tutto ciò che è ad esso associato.",
"text2": "Per continuare, inserisci la tua password. Riceverai un'e-mail con ulteriori istruzioni.", "text2": "Per continuare, inserisci la tua password. Riceverai un'e-mail con ulteriori istruzioni.",
"confirm": "Elimina il mio profilo", "confirm": "Elimina il mio profilo",
"requestSuccess": "Richiesta riuscita. Riceverai un'e-mail con ulteriori istruzioni.", "requestSuccess": "Richiesta riuscita. Riceverai un'e-mail con ulteriori istruzioni.",
@ -141,7 +141,7 @@
}, },
"export": { "export": {
"title": "Esporta i tuoi dati Vikunja", "title": "Esporta i tuoi dati Vikunja",
"description": "You can request a copy of all your Vikunja data. This include Namespaces, Lists, Tasks and everything associated to them. You can import this data in any Vikunja instance through the migration function.", "description": "Puoi richiedere una copia di tutti i tuoi dati all'interno di Vikunja. Questo include i Namespace, le Liste, le Attività e tutto ciò che è loro associato. È possibile importare questi dati in qualsiasi istanza Vikunja attraverso la funzione di migrazione.",
"descriptionPasswordRequired": "Inserisci la tua password per procedere:", "descriptionPasswordRequired": "Inserisci la tua password per procedere:",
"request": "Richiedi una copia dei miei dati Vikunja", "request": "Richiedi una copia dei miei dati Vikunja",
"success": "Hai richiesto con successo i tuoi dati Vikunja! Ti invieremo un'e-mail una volta che saranno pronti da scaricare.", "success": "Hai richiesto con successo i tuoi dati Vikunja! Ti invieremo un'e-mail una volta che saranno pronti da scaricare.",
@ -157,7 +157,7 @@
"searchSelect": "Fare clic o premere invio per selezionare questa lista", "searchSelect": "Fare clic o premere invio per selezionare questa lista",
"shared": "Liste Condivise", "shared": "Liste Condivise",
"create": { "create": {
"header": "New list", "header": "Nuova lista",
"titlePlaceholder": "Il titolo della lista va qui…", "titlePlaceholder": "Il titolo della lista va qui…",
"addTitleRequired": "Specifica un titolo.", "addTitleRequired": "Specifica un titolo.",
"createdSuccess": "La lista è stata creata correttamente.", "createdSuccess": "La lista è stata creata correttamente.",
@ -191,7 +191,7 @@
"duplicate": { "duplicate": {
"title": "Duplica questa lista", "title": "Duplica questa lista",
"label": "Duplica", "label": "Duplica",
"text": "Select a namespace which should hold the duplicated list:", "text": "Seleziona un namespace che dovrebbe contenere l'elenco duplicato:",
"success": "Lista duplicata." "success": "Lista duplicata."
}, },
"edit": { "edit": {
@ -279,23 +279,23 @@
"title": "Kanban", "title": "Kanban",
"limit": "Limite: {limit}", "limit": "Limite: {limit}",
"noLimit": "Non Impostato", "noLimit": "Non Impostato",
"doneBucket": "Done bucket", "doneBucket": "Colonna attività completate",
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.", "doneBucketHint": "Tutte le attività spostate in questa colonna verranno automaticamente contrassegnate come completate.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.", "doneBucketHintExtended": "Tutte le attività spostate nella colonna attività completate saranno contrassegnate automaticamente come completate. Tutte le attività contrassegnate come completate altrove verranno anche spostate.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.", "doneBucketSavedSuccess": "Colonna attività completate salvata.",
"deleteLast": "You cannot remove the last bucket.", "deleteLast": "Impossibile eliminare l'ultima colonna.",
"addTaskPlaceholder": "Enter the new task title…", "addTaskPlaceholder": "Inserisci il nuovo titolo dell'attività…",
"addTask": "Aggiungi un'attività", "addTask": "Aggiungi un'attività",
"addAnotherTask": "Aggiungi un'altra attività", "addAnotherTask": "Aggiungi un'altra attività",
"addBucket": "Create a new bucket", "addBucket": "Crea una nuova colonna",
"addBucketPlaceholder": "Enter the new bucket title…", "addBucketPlaceholder": "Inserisci il titolo della nuova colonna…",
"deleteHeaderBucket": "Delete the bucket", "deleteHeaderBucket": "Elimina la colonna",
"deleteBucketText1": "Are you sure you want to delete this bucket?", "deleteBucketText1": "Confermi di voler eliminare questa colonna?",
"deleteBucketText2": "This will not delete any tasks but move them into the default bucket.", "deleteBucketText2": "Questo non eliminerà nessuna attività, ma la sposterà nel bucket predefinito.",
"deleteBucketSuccess": "The bucket has been deleted successfully.", "deleteBucketSuccess": "Colonna eliminata.",
"bucketTitleSavedSuccess": "The bucket title has been saved successfully.", "bucketTitleSavedSuccess": "Titolo della colonna salvato.",
"bucketLimitSavedSuccess": "The bucket limit been saved successfully.", "bucketLimitSavedSuccess": "Limite della colonna salvato.",
"collapse": "Collapse this bucket" "collapse": "Comprimi questa colonna"
}, },
"pseudo": { "pseudo": {
"favorites": { "favorites": {
@ -304,52 +304,52 @@
} }
}, },
"namespace": { "namespace": {
"title": "Namespaces & Lists", "title": "Namespace e Liste",
"namespace": "Namespace", "namespace": "Namespace",
"showArchived": "Show Archived", "showArchived": "Mostra Archiviati",
"noneAvailable": "You don't have any namespaces right now.", "noneAvailable": "Non hai alcun namespace in questo momento.",
"unarchive": "Un-Archive", "unarchive": "De-Archivia",
"archived": "Archived", "archived": "Archiviato",
"noLists": "This namespace does not contain any lists.", "noLists": "Questo namespace non contiene alcuna lista.",
"createList": "Create a new list in this namespace.", "createList": "Crea una nuova lista in questo namespace.",
"namespaces": "Namespaces", "namespaces": "Namespace",
"search": "Type to search for a namespace…", "search": "Digita per cercare un namespace…",
"create": { "create": {
"title": "New namespace", "title": "Nuovo namespace",
"titleRequired": "Please specify a title.", "titleRequired": "Specifica un titolo.",
"explanation": "A namespace is a collection of lists you can share and use to organize your lists with. In fact, every list belongs to a namepace.", "explanation": "Un namespace è una raccolta di liste che puoi condividere e che puoi usare per organizzare le tue liste. Infatti, ogni lista appartiene a un namespace.",
"tooltip": "What's a namespace?", "tooltip": "Che cos'è un namespace?",
"success": "The namespace was successfully created." "success": "Namespace creato."
}, },
"archive": { "archive": {
"titleArchive": "Archivia \"{namespace}\"", "titleArchive": "Archivia \"{namespace}\"",
"titleUnarchive": "Un-Archive \"{namespace}\"", "titleUnarchive": "Disarchivia \"{namespace}\"",
"archiveText": "You won't be able to edit this namespace or create new lists until you un-archive it. This will also archive all lists in this namespace.", "archiveText": "Non sarà possibile modificare questo namespace o creare nuove liste fino a quando non verrà disarchiviato. Questo archivierà anche tutte le liste in questo namespace.",
"unarchiveText": "You will be able to create new lists or edit it.", "unarchiveText": "Potrai creare nuove liste o modificarle.",
"success": "The namespace was successfully archived.", "success": "Namespace creato.",
"description": "If a namespace is archived, you cannot create new lists or edit it." "description": "Se un namespace è archiviato, non è possibile creare nuove liste o modificarlo."
}, },
"delete": { "delete": {
"title": "Delete \"{namespace}\"", "title": "Elimina \"{namespace}\"",
"text1": "Are you sure you want to delete this namespace and all of its contents?", "text1": "Sei sicuro di voler rimuovere questo namespace e tutto il relativo contenuto?",
"text2": "Questo include tutte le liste e le attività e NON PUÒ ESSERE RIPRISTINATO!", "text2": "Questo include tutte le liste e le attività e NON PUÒ ESSERE RIPRISTINATO!",
"success": "The namespace was successfully deleted." "success": "Namespace eliminato."
}, },
"edit": { "edit": {
"title": "Modifica \"{namespace}\"", "title": "Modifica \"{namespace}\"",
"success": "The namespace was successfully updated." "success": "Namespace aggiornato."
}, },
"share": { "share": {
"title": "Condividi \"{namespace}\"" "title": "Condividi \"{namespace}\""
}, },
"attributes": { "attributes": {
"title": "Namespace Title", "title": "Titolo del Namespace",
"titlePlaceholder": "The namespace title goes here…", "titlePlaceholder": "Il titolo del namespace va qui…",
"description": "Descrizione", "description": "Descrizione",
"descriptionPlaceholder": "The namespaces description goes here…", "descriptionPlaceholder": "La descrizione del namespace va qui…",
"color": "Colore", "color": "Colore",
"archived": "Is Archived", "archived": "Archiviato",
"isArchived": "This namespace is archived" "isArchived": "Questo namespace è archiviato"
}, },
"pseudo": { "pseudo": {
"sharedLists": { "sharedLists": {
@ -365,7 +365,7 @@
}, },
"filters": { "filters": {
"title": "Filtri", "title": "Filtri",
"clear": "Clear Filters", "clear": "Pulisci Filtri",
"attributes": { "attributes": {
"title": "Titolo", "title": "Titolo",
"titlePlaceholder": "Il titolo del filtro salvato va qui…", "titlePlaceholder": "Il titolo del filtro salvato va qui…",
@ -374,17 +374,17 @@
"includeNulls": "Includi attività che non hanno un valore impostato", "includeNulls": "Includi attività che non hanno un valore impostato",
"requireAll": "Tutti i filtri devono essere veri affinché l'attività venga mostrata", "requireAll": "Tutti i filtri devono essere veri affinché l'attività venga mostrata",
"showDoneTasks": "Mostra Attività Fatte", "showDoneTasks": "Mostra Attività Fatte",
"sortAlphabetically": "Sort Alphabetically", "sortAlphabetically": "Ordine alfabetico",
"enablePriority": "Abilita Filtro Per Priorità", "enablePriority": "Abilita Filtro Per Priorità",
"enablePercentDone": "Abilitare Filtro Per Percentuale Fatta", "enablePercentDone": "Abilitare Filtro Per Percentuale Fatta",
"dueDateRange": "Intervallo Data Di Scadenza", "dueDateRange": "Intervallo Data Di Scadenza",
"startDateRange": "Intervallo Data Iniziale", "startDateRange": "Intervallo Data Iniziale",
"endDateRange": "Intervallo Data Finale", "endDateRange": "Intervallo Data Finale",
"reminderRange": "Reminder Date Range" "reminderRange": "Intervallo date dei promemoria"
}, },
"create": { "create": {
"title": "New Saved Filter", "title": "Nuovo Filtro Salvato",
"description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.", "description": "Un filtro salvato è una lista virtuale che viene calcolata da un insieme di filtri di volta in volta. Una volta creato, apparirà in un namespace speciale.",
"action": "Crea nuovo filtro salvato" "action": "Crea nuovo filtro salvato"
}, },
"delete": { "delete": {
@ -446,9 +446,9 @@
}, },
"navigation": { "navigation": {
"overview": "Panoramica", "overview": "Panoramica",
"upcoming": "Upcoming", "upcoming": "Prossimamente",
"settings": "Impostazioni", "settings": "Impostazioni",
"imprint": "Imprint", "imprint": "Informazioni legali",
"privacy": "Politica sulla Privacy" "privacy": "Politica sulla Privacy"
}, },
"misc": { "misc": {
@ -464,19 +464,19 @@
"searchPlaceholder": "Digita per cercare…", "searchPlaceholder": "Digita per cercare…",
"previous": "Precedente", "previous": "Precedente",
"next": "Successivo", "next": "Successivo",
"poweredBy": "Powered by Vikunja", "poweredBy": "Creato con Vikunja",
"info": "Info", "info": "Info",
"create": "Create", "create": "Crea",
"doit": "Fallo!", "doit": "Fallo!",
"saving": "Salvataggio…", "saving": "Salvataggio…",
"saved": "Salvato!", "saved": "Salvato!",
"default": "Predefinito", "default": "Predefinito",
"close": "Chiudi", "close": "Chiudi",
"download": "Scarica", "download": "Scarica",
"showMenu": "Show the menu", "showMenu": "Mostra il menu",
"hideMenu": "Hide the menu", "hideMenu": "Nascondi il menù",
"forExample": "For example:", "forExample": "Ad esempio:",
"welcomeBack": "Welcome Back!" "welcomeBack": "Bentornato!"
}, },
"input": { "input": {
"resetColor": "Ripristina Colore", "resetColor": "Ripristina Colore",
@ -485,9 +485,9 @@
"tomorrow": "Domani", "tomorrow": "Domani",
"nextMonday": "Lunedì Prossimo", "nextMonday": "Lunedì Prossimo",
"thisWeekend": "Questo fine settimana", "thisWeekend": "Questo fine settimana",
"laterThisWeek": "Later This Week", "laterThisWeek": "Alla fine di questa settimana",
"nextWeek": "Prossima Settimana", "nextWeek": "Prossima Settimana",
"chooseDate": "Choose a date" "chooseDate": "Seleziona una data"
}, },
"editor": { "editor": {
"edit": "Modifica", "edit": "Modifica",
@ -504,16 +504,16 @@
"quote": "Citazione", "quote": "Citazione",
"unorderedList": "Elenco puntato", "unorderedList": "Elenco puntato",
"orderedList": "Elenco numerato", "orderedList": "Elenco numerato",
"cleanBlock": "Clean Block", "cleanBlock": "Pulisci Blocco",
"link": "Link", "link": "Link",
"image": "Immagine", "image": "Immagine",
"table": "Tabella", "table": "Tabella",
"horizontalRule": "Horizontal Rule", "horizontalRule": "Divisore Orizzontale",
"sideBySide": "Side By Side", "sideBySide": "Affianca",
"guide": "Guide" "guide": "Guida"
}, },
"multiselect": { "multiselect": {
"createPlaceholder": "Create new", "createPlaceholder": "Crea nuovo",
"selectPlaceholder": "Clicca o premere invio per selezionare" "selectPlaceholder": "Clicca o premere invio per selezionare"
} }
}, },
@ -533,19 +533,19 @@
"titleDates": "Attività dal {from} al {to}", "titleDates": "Attività dal {from} al {to}",
"noDates": "Mostra attività senza date", "noDates": "Mostra attività senza date",
"current": "Attività attuali", "current": "Attività attuali",
"from": "Tasks from", "from": "Attività dal",
"until": "until", "until": "fino al",
"today": "Oggi", "today": "Oggi",
"nextWeek": "Settimana Prossima", "nextWeek": "Settimana Prossima",
"nextMonth": "Prossimo Mese", "nextMonth": "Prossimo Mese",
"noTasks": "Nothing to do — Have a nice day!" "noTasks": "Nessuna attività — Buona giornata!"
}, },
"detail": { "detail": {
"chooseDueDate": "Clicca qui per impostare una data di scadenza", "chooseDueDate": "Clicca qui per impostare una data di scadenza",
"chooseStartDate": "Clicca qui per impostare una data di inizio", "chooseStartDate": "Clicca qui per impostare una data di inizio",
"chooseEndDate": "Clicca qui per impostare una data di fine", "chooseEndDate": "Clicca qui per impostare una data di fine",
"move": "Sposta attività in un'altra lista", "move": "Sposta attività in un'altra lista",
"done": "Mark task done!", "done": "Segna attività fatta!",
"undone": "Segna come non completato", "undone": "Segna come non completato",
"created": "Creato {0} da {1}", "created": "Creato {0} da {1}",
"updated": "Aggiornato {0}", "updated": "Aggiornato {0}",
@ -554,21 +554,21 @@
"deleteSuccess": "L'attività è stata eliminata con successo.", "deleteSuccess": "L'attività è stata eliminata con successo.",
"belongsToList": "Questa attività appartiene alla lista '{list}'", "belongsToList": "Questa attività appartiene alla lista '{list}'",
"due": "Scadenza {at}", "due": "Scadenza {at}",
"closePopup": "Close popup", "closePopup": "Chiudi popup",
"delete": { "delete": {
"header": "Elimina questa attività", "header": "Elimina questa attività",
"text1": "Sei sicuro di voler eliminare questa attività?", "text1": "Sei sicuro di voler eliminare questa attività?",
"text2": "Questo rimuoverà anche tutti gli allegati, i promemoria e le relazioni associati a questa attività e non può essere ripristinato!" "text2": "Questo rimuoverà anche tutti gli allegati, i promemoria e le relazioni associati a questa attività e non può essere ripristinato!"
}, },
"actions": { "actions": {
"assign": "Assign to a user", "assign": "Assegna ad un utente",
"label": "Aggiungi etichette", "label": "Aggiungi etichette",
"priority": "Imposta Priorità", "priority": "Imposta Priorità",
"dueDate": "Imposta data di scadenza", "dueDate": "Imposta data di scadenza",
"startDate": "Imposta una data di inizio", "startDate": "Imposta una data di inizio",
"endDate": "Imposta una data di fine", "endDate": "Imposta una data di fine",
"reminders": "Imposta promemoria", "reminders": "Imposta promemoria",
"repeatAfter": "Set a repeating interval", "repeatAfter": "Imposta ricorrenza",
"percentDone": "Imposta Percentuale Completata", "percentDone": "Imposta Percentuale Completata",
"attachments": "Aggiungi allegati", "attachments": "Aggiungi allegati",
"relatedTasks": "Aggiungi attività collegate", "relatedTasks": "Aggiungi attività collegate",
@ -599,13 +599,13 @@
"updated": "Aggiornato" "updated": "Aggiornato"
}, },
"subscription": { "subscription": {
"subscribedThroughParent": "You can't unsubscribe here because you are subscribed to this {entity} through its {parent}.", "subscribedThroughParent": "Non puoi annullare l'iscrizione qui perché sei iscritto a questo {entity} attraverso il suo {parent}.",
"subscribed": "You are currently subscribed to this {entity} and will receive notifications for changes.", "subscribed": "Sei attualmente iscritto a questo {entity} e riceverai notifiche per le modifiche.",
"notSubscribed": "You are not subscribed to this {entity} and won't receive notifications for changes.", "notSubscribed": "Non sei iscritto a questo {entity} e non riceverai notifiche per le modifiche.",
"subscribe": "Subscribe", "subscribe": "Iscriviti",
"unsubscribe": "Unsubscribe", "unsubscribe": "Disiscriviti",
"subscribeSuccess": "You are now subscribed to this {entity}", "subscribeSuccess": "Ti sei iscritto a questo {entity}",
"unsubscribeSuccess": "You are now unsubscribed to this {entity}" "unsubscribeSuccess": "Ti sei disiscritto a questo {entity}"
}, },
"attachment": { "attachment": {
"title": "Allegati", "title": "Allegati",
@ -623,41 +623,41 @@
"comment": { "comment": {
"title": "Commenti", "title": "Commenti",
"loading": "Caricamento commenti…", "loading": "Caricamento commenti…",
"edited": "edited {date}", "edited": "modificato il {date}",
"creating": "Creazione del commento…", "creating": "Creazione del commento…",
"placeholder": "Aggiungi un commento…", "placeholder": "Aggiungi un commento…",
"comment": "Comment", "comment": "Commenta",
"delete": "Elimina questo commento", "delete": "Elimina questo commento",
"deleteText1": "Sei sicuro di voler eliminare questo commento?", "deleteText1": "Sei sicuro di voler eliminare questo commento?",
"deleteText2": "Questa azione non può essere annullata!", "deleteText2": "Questa azione non può essere annullata!",
"addedSuccess": "Il commento è stato aggiunto correttamente." "addedSuccess": "Il commento è stato aggiunto correttamente."
}, },
"deferDueDate": { "deferDueDate": {
"title": "Defer due date", "title": "Rinvia data di scadenza",
"1day": "1 giorno", "1day": "1 giorno",
"3days": "3 giorni", "3days": "3 giorni",
"1week": "1 settimana" "1week": "1 settimana"
}, },
"description": { "description": {
"placeholder": "Click here to enter a description…", "placeholder": "Clicca qui per inserire una descrizione…",
"empty": "No description available yet." "empty": "Nessuna descrizione."
}, },
"assignee": { "assignee": {
"placeholder": "Type to assign a user…", "placeholder": "Digita per assegnare un utente…",
"selectPlaceholder": "Assegna questo utente", "selectPlaceholder": "Assegna questo utente",
"assignSuccess": "The user has been assigned successfully.", "assignSuccess": "Utente assegnato.",
"unassignSuccess": "The user has been unassigned successfully." "unassignSuccess": "Utente disassegnato."
}, },
"label": { "label": {
"placeholder": "Type to add a new label…", "placeholder": "Digita per aggiungere una nuova etichetta…",
"createPlaceholder": "Add this as new label", "createPlaceholder": "Aggiungila come nuova etichetta",
"addSuccess": "Etichetta aggiunta.", "addSuccess": "Etichetta aggiunta.",
"createSuccess": "Etichetta creata.", "createSuccess": "Etichetta creata.",
"removeSuccess": "Etichetta eliminata.", "removeSuccess": "Etichetta eliminata.",
"addCreateSuccess": "Etichetta creata e aggiunta." "addCreateSuccess": "Etichetta creata e aggiunta."
}, },
"priority": { "priority": {
"unset": "Unset", "unset": "Azzera",
"low": "Bassa", "low": "Bassa",
"medium": "Media", "medium": "Media",
"high": "Alta", "high": "Alta",
@ -665,38 +665,38 @@
"doNow": "FARE ORA" "doNow": "FARE ORA"
}, },
"relation": { "relation": {
"add": "Add a New Task Relation", "add": "Aggiungi Attività Collegata",
"new": "New Task Relation", "new": "Nuova Attività Collegata",
"searchPlaceholder": "Type search for a new task to add as related…", "searchPlaceholder": "Digita per cercare un'attività da aggiungere come collegata…",
"createPlaceholder": "Add this as new related task", "createPlaceholder": "Aggiungi come attività collegata",
"differentList": "This task belongs to a different list.", "differentList": "Questa attività è di una lista diversa.",
"differentNamespace": "This task belongs to a different namespace.", "differentNamespace": "Questa attività appartiene ad un namespace diverso.",
"noneYet": "No task relations yet.", "noneYet": "Nessuna attività collegata.",
"delete": "Delete Task Relation", "delete": "Elimina Collegamento Attività",
"deleteText1": "Are you sure you want to delete this task relation?", "deleteText1": "Confermi di voler eliminare questo collegamento attività?",
"deleteText2": "Questa azione non può essere annullata!", "deleteText2": "Questa azione non può essere annullata!",
"select": "Select a relation kind", "select": "Seleziona un tipo di collegamento",
"kinds": { "kinds": {
"subtask": "Subtask | Subtasks", "subtask": "Sotto-attività | Sotto-attività",
"parenttask": "Parent Task | Parent Tasks", "parenttask": "Attività Principale | Attività Principale",
"related": "Related Task | Related Tasks", "related": "Attività Correlata | Attività Correlata",
"duplicateof": "Duplicato Di | Duplicati Di", "duplicateof": "Duplicato Di | Duplicati Di",
"duplicates": "Duplicates | Duplicates", "duplicates": "Duplicato | Duplicati",
"blocking": "Blocking | Blocking", "blocking": "Bloccante | Bloccanti",
"blocked": "Blocked By | Blocked By", "blocked": "Bloccato Da | Bloccati Da",
"precedes": "Precedes | Precedes", "precedes": "Precede | Precede",
"follows": "Follows | Follows", "follows": "Segue | Segue",
"copiedfrom": "Copied From | Copied From", "copiedfrom": "Copiata Da | Copiate Da",
"copiedto": "Copied To | Copied To" "copiedto": "Copiata In | Copiate In"
} }
}, },
"repeat": { "repeat": {
"everyDay": "Ogni Giorno", "everyDay": "Ogni Giorno",
"everyWeek": "Ogni Settimana", "everyWeek": "Ogni Settimana",
"everyMonth": "Ogni Mese", "everyMonth": "Ogni Mese",
"mode": "Repeat mode", "mode": "Modalità Ripetizione",
"monthly": "Mensilmente", "monthly": "Mensilmente",
"fromCurrentDate": "From Current Date", "fromCurrentDate": "Dalla Data Attuale",
"each": "Ogni", "each": "Ogni",
"specifyAmount": "Specifica una quantità…", "specifyAmount": "Specifica una quantità…",
"hours": "Ore", "hours": "Ore",
@ -706,32 +706,32 @@
"years": "Anni" "years": "Anni"
}, },
"quickAddMagic": { "quickAddMagic": {
"hint": "You can use Quick Add Magic", "hint": "Puoi usare l'Aggiunta Rapida Magica",
"what": "Cosa?", "what": "Cosa?",
"title": "Quick Add Magic", "title": "Aggiunta Rapida Magica",
"intro": "When creating a task, you can use special keywords to directly add attributes to the newly created task. This allows to add commonly used attributes to tasks much faster.", "intro": "Quando si crea un'attività, è possibile utilizzare parole chiave speciali per aggiungere direttamente attributi all'attività appena creata. Questo permette di aggiungere gli attributi comuni molto più velocemente.",
"multiple": "Puoi usarlo più volte.", "multiple": "Puoi usarlo più volte.",
"label1": "To add a label, simply prefix the name of the label with {prefix}.", "label1": "Per aggiungere un'etichetta, basta aggiungere il nome dell'etichetta preceduto da {prefix}.",
"label2": "Vikunja will first check if the label already exist and create it if not.", "label2": "Vikunja controllerà prima se l'etichetta esiste già e nel caso la creerà.",
"label3": "To use spaces, simply add a \" around the label name.", "label3": "Per usare gli spazi, basta \" prima e dopo del nome dell'etichetta.",
"label4": "For example: {prefix}\"Label with spaces\".", "label4": "Per esempio: {prefix}\"Etichetta con spazi\".",
"priority1": "To set a task's priority, add a number 1-5, prefixed with a {prefix}.", "priority1": "Per impostare la priorità di un'attività, aggiungi un numero 1-5, preceduto da {prefix}.",
"priority2": "The higher the number, the higher the priority.", "priority2": "Più alto è il numero, più alta è la priorità.",
"assignees": "To directly assign the task to a user, add their username prefixed with {prefix} to the task.", "assignees": "Per assegnare direttamente l'attività a un utente, aggiungere il suo nome utente preceduto da {prefix} all'attività.",
"list1": "To set a list for the task to appear in, enter its name prefixed with {prefix}.", "list1": "Per impostare una lista di appartenenza all'attività, inserisci il suo nome prefisso con {prefix}.",
"list2": "This will return an error if the list does not exist.", "list2": "Ciò restituirà un errore se la lista non esiste.",
"dateAndTime": "Data e ora", "dateAndTime": "Data e ora",
"date": "Any date will be used as the due date of the new task. You can use dates in any of these formats:", "date": "Qualsiasi data verrà utilizzata come data di scadenza della nuova attività. È possibile utilizzare le date in uno qualsiasi di questi formati:",
"dateWeekday": "any weekday, will use the next date with that date", "dateWeekday": "qualsiasi giorno della settimana, userà la data più vicina",
"dateCurrentYear": "will use the current year", "dateCurrentYear": "userà lanno corrente",
"dateNth": "will use the {day}th of the current month", "dateNth": "userà il {day} del mese corrente",
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.", "dateTime": "Combina uno qualsiasi dei formati di data con \"{time}\" (o {timePM}) per impostare un orario.",
"repeats": "Repeating tasks", "repeats": "Attività ricorrenti",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)." "repeatsDescription": "Per impostare un'attività come ricorrente in un intervallo, basta aggiungere '{suffix}' al testo dell'attività. La quantità deve essere un numero e può essere omesso per usare solo il tipo (vedi esempi)."
} }
}, },
"team": { "team": {
"title": "Teams", "title": "Gruppi",
"noTeams": "Non fai parte di nessun gruppo.", "noTeams": "Non fai parte di nessun gruppo.",
"create": { "create": {
"title": "Crea un nuovo gruppo", "title": "Crea un nuovo gruppo",
@ -746,23 +746,23 @@
"makeAdmin": "Rendi Amministratore", "makeAdmin": "Rendi Amministratore",
"success": "Gruppo aggiornato.", "success": "Gruppo aggiornato.",
"userAddedSuccess": "Membro del gruppo aggiunto.", "userAddedSuccess": "Membro del gruppo aggiunto.",
"madeMember": "The team member was successfully made member.", "madeMember": "Membro del gruppo reso membro.",
"madeAdmin": "The team member was successfully made admin.", "madeAdmin": "Membro del gruppo reso amministratore.",
"delete": { "delete": {
"header": "Elimina il gruppo", "header": "Elimina il gruppo",
"text1": "Sei sicuro di voler eliminare questo gruppo e tutti i suoi membri?", "text1": "Sei sicuro di voler eliminare questo gruppo e tutti i suoi membri?",
"text2": "All team members will lose access to lists and namespaces shared with this team. This CANNOT BE UNDONE!", "text2": "Tutti i membri del gruppo perderanno l'accesso alle liste e ai namespace condivisi con questo gruppo. NON PUÒ ESSERE RIPRISTINATO!",
"success": "Gruppo eliminato." "success": "Gruppo eliminato."
}, },
"deleteUser": { "deleteUser": {
"header": "Rimuovi un utente dal gruppo", "header": "Rimuovi un utente dal gruppo",
"text1": "Confermi di voler rimuovere questo utente dal gruppo?", "text1": "Confermi di voler rimuovere questo utente dal gruppo?",
"text2": "They will lose access to all lists and namespaces this team has access to. This CANNOT BE UNDONE!", "text2": "Perderanno l'accesso a tutte le liste e i namespace a cui questo gruppo ha accesso. NON PUÒ ESSERE RIPRISTINATO!",
"success": "Utente rimosso dal gruppo." "success": "Utente rimosso dal gruppo."
} }
}, },
"attributes": { "attributes": {
"name": "Team Name", "name": "Nome Gruppo",
"namePlaceholder": "Il nome del gruppo va qui…", "namePlaceholder": "Il nome del gruppo va qui…",
"nameRequired": "Specifica un nome.", "nameRequired": "Specifica un nome.",
"description": "Descrizione", "description": "Descrizione",
@ -772,32 +772,32 @@
} }
}, },
"keyboardShortcuts": { "keyboardShortcuts": {
"title": "Keyboard Shortcuts", "title": "Tasti Rapidi",
"general": "General", "general": "Generali",
"allPages": "Queste scorciatoie funzionano in tutte le pagine.", "allPages": "Queste scorciatoie funzionano in tutte le pagine.",
"currentPageOnly": "Queste scorciatoie funzionano solo nella pagina attuale.", "currentPageOnly": "Queste scorciatoie funzionano solo nella pagina attuale.",
"toggleMenu": "Attiva/Disattiva Menu", "toggleMenu": "Attiva/Disattiva Menu",
"quickSearch": "Apri la barra di ricerca/azione rapida", "quickSearch": "Apri la barra di ricerca/azione rapida",
"then": "then", "then": "e dopo",
"task": { "task": {
"title": "Task Page", "title": "Pagina Attività",
"done": "Done", "done": "Fatto",
"assign": "Assign to a user", "assign": "Assegna a un utente",
"labels": "Add labels to this task", "labels": "Aggiungi etichette a questa attività",
"dueDate": "Change the due date of this task", "dueDate": "Modifica la data di scadenza di questa attività",
"attachment": "Add an attachment to this task", "attachment": "Aggiungi un allegato a questa attività",
"related": "Modify related tasks of this task" "related": "Modifica le attività collegate a questa"
}, },
"list": { "list": {
"title": "List Views", "title": "Viste Liste",
"switchToListView": "Switch to list view", "switchToListView": "Passa alla vista Lista",
"switchToGanttView": "Switch to gantt view", "switchToGanttView": "Passa alla vista Gantt",
"switchToKanbanView": "Switch to kanban view", "switchToKanbanView": "Passa alla vista Kanban",
"switchToTableView": "Switch to table view" "switchToTableView": "Passa alla vista Tabella"
} }
}, },
"update": { "update": {
"available": "There is an update for Vikunja available!", "available": "È disponibile un aggiornamento per Vikunja!",
"do": "Aggiorna Adesso" "do": "Aggiorna Adesso"
}, },
"menu": { "menu": {
@ -805,136 +805,136 @@
"archive": "Archivia", "archive": "Archivia",
"duplicate": "Duplica", "duplicate": "Duplica",
"delete": "Elimina", "delete": "Elimina",
"unarchive": "Un-Archive", "unarchive": "Disarchivia",
"setBackground": "Set background", "setBackground": "Imposta sfondo",
"share": "Condividi", "share": "Condividi",
"newList": "Nuova lista" "newList": "Nuova lista"
}, },
"apiConfig": { "apiConfig": {
"url": "URL Vikunja", "url": "URL Vikunja",
"urlPlaceholder": "es. http://localhost:8080", "urlPlaceholder": "es. http://localhost:8080",
"change": "change", "change": "modifica",
"use": "Using Vikunja installation at {0}", "use": "Usa l'installazione di Vikunja a {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.", "error": "Impossibile trovare o usare l'installazione di Vikunja su \"{domain}\". Prova per favore con un altro Url.",
"success": "Using Vikunja installation at \"{domain}\".", "success": "Utilizzando l'installazione di Vikunja su \"{domain}\".",
"urlRequired": "A url is required." "urlRequired": "L'URL è obbligatorio."
}, },
"loadingError": { "loadingError": {
"failed": "Loading failed, please {0}. If the error persists, please {1}.", "failed": "Caricamento non riuscito, si prega di {0}. Se l'errore persiste, per favore {1}.",
"tryAgain": "try again", "tryAgain": "riprova",
"contact": "contact us" "contact": "Contattaci"
}, },
"notification": { "notification": {
"title": "Notifications", "title": "Notifiche",
"none": "You don't have any notifications. Have a nice day!", "none": "Nessuna notifica. Buona giornata!",
"explainer": "Notifications will appear here when actions on namespaces, lists or tasks you subscribed to happen." "explainer": "Le notifiche appariranno qui quando le azioni su Namespace, liste o attività a cui hai sottoscritto la sottoscrizione avvengono."
}, },
"quickActions": { "quickActions": {
"commands": "Commands", "commands": "Comandi",
"placeholder": "Type a command or search…", "placeholder": "Digita un comando o cerca…",
"hint": "You can use {list} to limit the search to a list. Combine {list} or {label} (labels) with a search query to search for a task with these labels or on that list. Use {assignee} to only search for teams.", "hint": "Puoi usare {list} per limitare la ricerca a una lista. Unisci {list} o {label} (etichette) alla ricerca per trovare un'attività con quelle etichette o in quella lista. Usa {assignee} per cercare solo i gruppi.",
"tasks": "Tasks", "tasks": "Attivitá",
"lists": "Liste", "lists": "Liste",
"teams": "Teams", "teams": "Gruppi",
"newList": "Enter the title of the new list…", "newList": "Inserisci il titolo della nuova lista…",
"newTask": "Enter the title of the new task…", "newTask": "Inserisci il titolo della nuova attività…",
"newNamespace": "Enter the title of the new namespace…", "newNamespace": "Inserisci il titolo del nuovo namespace…",
"newTeam": "Enter the name of the new team…", "newTeam": "Inserisci il nome del nuovo gruppo…",
"createTask": "Create a task in the current list ({title})", "createTask": "Crea un'attività nella lista attuale ({title})",
"createList": "Create a list in the current namespace ({title})", "createList": "Crea una lista nel namespace attuale ({title})",
"cmds": { "cmds": {
"newTask": "New task", "newTask": "Nuova attività",
"newList": "New list", "newList": "Nuova lista",
"newNamespace": "New namespace", "newNamespace": "Nuovo Namespace",
"newTeam": "New team" "newTeam": "Nuovo gruppo"
} }
}, },
"date": { "date": {
"locale": "en", "locale": "it",
"altFormatLong": "j M Y H:i", "altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y" "altFormatShort": "j M Y"
}, },
"error": { "error": {
"error": "Errore", "error": "Errore",
"success": "Success", "success": "Fatto",
"0001": "Non ti è permesso farlo.", "0001": "Non ti è permesso farlo.",
"1001": "A user with this username already exists.", "1001": "Esiste già un utente con questo nome utente.",
"1002": "Un utente con questo indirizzo e-mail esiste già.", "1002": "Un utente con questo indirizzo e-mail esiste già.",
"1004": "No username and password specified.", "1004": "Nessun nome utente e password specificati.",
"1005": "L'utente non esiste.", "1005": "L'utente non esiste.",
"1006": "Impossibile ottenere l'id utente.", "1006": "Impossibile ottenere l'id utente.",
"1008": "No password reset token provided.", "1008": "Nessun codice di reimpostazione password fornito.",
"1009": "Invalid password reset token.", "1009": "Codice di reimpostazione password non valido.",
"1010": "Token di conferma dell'e-mail non valido.", "1010": "Token di conferma dell'e-mail non valido.",
"1011": "Wrong username or password.", "1011": "Nome utente o password errati.",
"1012": "Indirizzo e-mail dell'utente non confermato.", "1012": "Indirizzo e-mail dell'utente non confermato.",
"1013": "La nuova password è vuota.", "1013": "La nuova password è vuota.",
"1014": "La vecchia password è vuota.", "1014": "La vecchia password è vuota.",
"1015": "Autenticazione TOTP già abilitata per questo utente.", "1015": "Autenticazione TOTP già abilitata per questo utente.",
"1016": "Autenticazione TOTP non abilitata per questo utente.", "1016": "Autenticazione TOTP non abilitata per questo utente.",
"1017": "Codice TOTP non valido.", "1017": "Codice TOTP non valido.",
"1018": "The user avatar type setting is invalid.", "1018": "L'impostazione del tipo di avatar utente non è valida.",
"2001": "L'ID non può essere vuoto o 0.", "2001": "L'ID non può essere vuoto o 0.",
"2002": "Alcuni dati della richiesta non erano validi.", "2002": "Alcuni dati della richiesta non erano validi.",
"3001": "La lista non esiste.", "3001": "La lista non esiste.",
"3004": "You need to have read permissions on that list to perform that action.", "3004": "Devi avere i permessi di lettura su quella lista per eseguire quell'azione.",
"3005": "Il titolo della lista non può essere vuoto.", "3005": "Il titolo della lista non può essere vuoto.",
"3006": "The list share does not exist.", "3006": "La condivisione della lista non esiste.",
"3007": "Esiste già una lista con questo identificatore.", "3007": "Esiste già una lista con questo identificatore.",
"3008": "The list is archived and can therefore only be accessed read only. This is also true for all tasks associated with this list.", "3008": "La lista è archiviata e può quindi essere consultata solo in sola lettura. Questo vale anche per tutte le attività associate a questa lista.",
"4001": "The list task text cannot be empty.", "4001": "Il testo delle attività della lista non può essere vuoto.",
"4002": "The list task does not exist.", "4002": "Lista di attività non esistente.",
"4003": "Tutte le attività di modifica in blocco devono appartenere alla stessa lista.", "4003": "Tutte le attività di modifica in blocco devono appartenere alla stessa lista.",
"4004": "Hai bisogno di almeno un'attività quando si modificano in blocco le attività.", "4004": "Hai bisogno di almeno un'attività quando si modificano in blocco le attività.",
"4005": "Non hai il permesso di vedere l'attività.", "4005": "Non hai il permesso di vedere l'attività.",
"4006": "You can't set a parent task as the task itself.", "4006": "Non è possibile impostare un'attività principale come l'attività stessa.",
"4007": "You can't create a task relation with an invalid kind of relation.", "4007": "Non è possibile creare una relazione di attività con un tipo di relazione non valido.",
"4008": "You can't create a task relation which already exists.", "4008": "Non è possibile creare una relazione di attività già esistente.",
"4009": "The task relation does not exist.", "4009": "La relazione di attività non esiste.",
"4010": "Cannot relate a task with itself.", "4010": "Non è possibile relazionare un'attività con se stessa.",
"4011": "The task attachment does not exist.", "4011": "L'allegato dell'attività non esiste.",
"4012": "The task attachment is too large.", "4012": "L'allegato dell'attività è troppo grande.",
"4013": "The task sort param is invalid.", "4013": "Il parametro di ordinamento dei task non è valido.",
"4014": "The task sort order is invalid.", "4014": "L' ordinamento dei task non è valido.",
"4015": "The task comment does not exist.", "4015": "Il commento all'attività non esiste.",
"4016": "Invalid task field.", "4016": "Campo attività non valido.",
"4017": "Invalid task filter comparator.", "4017": "Comparatore di filtri attività non valido.",
"4018": "Invalid task filter concatinator.", "4018": "Concatenatore filtro attività non valido.",
"4019": "Invalid task filter value.", "4019": "Filtro attività non valido.",
"5001": "The namespace does not exist.", "5001": "Il namespace non esiste.",
"5003": "You do not have access to the specified namespace.", "5003": "Non hai accesso a questo namespace.",
"5006": "The namespace name cannot be empty.", "5006": "Il nome del namespace non può essere vuoto.",
"5009": "You need to have namespace read access to perform that action.", "5009": "Devi avere accesso in lettura al namespace per effettuare questa operazione.",
"5010": "This team does not have access to that namespace.", "5010": "Il tuo gruppo non ha accesso a questo namespace.",
"5011": "This user has already access to that namespace.", "5011": "Questo utente ha già accesso a quel namespace.",
"5012": "The namespace is archived and can therefore only be accessed read only.", "5012": "Il namespace è archiviato e può quindi essere accessibile solo in sola lettura.",
"6001": "The team name cannot be empty.", "6001": "Il nome del gruppo non può essere vuoto.",
"6002": "The team does not exist.", "6002": "Gruppo non esistente.",
"6004": "The team already has access to that namespace or list.", "6004": "Il team ha già accesso a questo namespace o lista.",
"6005": "The user is already a member of that team.", "6005": "L'utente è già membro di quel gruppo.",
"6006": "Cannot delete the last team member.", "6006": "Non è possibile eliminare l'ultimo membro del gruppo.",
"6007": "The team does not have access to the list to perform that action.", "6007": "Il gruppo non ha accesso alla lista per eseguire quell'azione.",
"7002": "The user already has access to that list.", "7002": "L'utente ha già accesso a quella lista.",
"7003": "Non hai accesso a quella lista.", "7003": "Non hai accesso a quella lista.",
"8001": "Questa etichetta esiste già in quell'attività.", "8001": "Questa etichetta esiste già in quell'attività.",
"8002": "L'etichetta non esiste.", "8002": "L'etichetta non esiste.",
"8003": "Non hai accesso a questa etichetta.", "8003": "Non hai accesso a questa etichetta.",
"9001": "The right is invalid.", "9001": "Permesso non valido.",
"10001": "The bucket does not exist.", "10001": "Colonna non esistente.",
"10002": "The bucket does not belong to that list.", "10002": "La colonna non appartiene a quella lista.",
"10003": "You cannot remove the last bucket on a list.", "10003": "Non puoi rimuovere l'ultima colonna di una lista.",
"10004": "You cannot add the task to this bucket as it already exceeded the limit of tasks it can hold.", "10004": "Non puoi aggiungere l'attività a questa colonna perché ha già superato il limite di attività che può contenere.",
"10005": "There can be only one done bucket per list.", "10005": "Ci può essere solo una colonna completati per lista.",
"11001": "The saved filter does not exist.", "11001": "Filtro salvato non esistente.",
"11002": "Saved filters are not available for link shares.", "11002": "I filtri salvati non sono disponibili per i link di condivisione.",
"12001": "The subscription entity type is invalid.", "12001": "Il tipo di entità sottoscritto non è valido.",
"12002": "You are already subscribed to the entity itself or a parent entity.", "12002": "Sei già iscritto all'entità stessa o a un'entità principale.",
"13001": "This link share requires a password for authentication, but none was provided.", "13001": "Questa condivisione di link richiede una password per l'autenticazione, ma non è stato inserita.",
"13002": "The provided link share password was invalid." "13002": "La password inserita per il link di condivisione è valida."
}, },
"about": { "about": {
"title": "About", "title": "Informazioni",
"frontendVersion": "Frontend Version: {version}", "frontendVersion": "Versione Frontend: {version}",
"apiVersion": "API Version: {version}" "apiVersion": "Versione API: {version}"
} }
} }

View file

@ -899,7 +899,7 @@
"4015": "The task comment does not exist.", "4015": "The task comment does not exist.",
"4016": "Invalid task field.", "4016": "Invalid task field.",
"4017": "Invalid task filter comparator.", "4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.", "4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.", "4019": "Invalid task filter value.",
"5001": "The namespace does not exist.", "5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.", "5003": "You do not have access to the specified namespace.",

View file

@ -899,7 +899,7 @@
"4015": "The task comment does not exist.", "4015": "The task comment does not exist.",
"4016": "Invalid task field.", "4016": "Invalid task field.",
"4017": "Invalid task filter comparator.", "4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.", "4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.", "4019": "Invalid task filter value.",
"5001": "The namespace does not exist.", "5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.", "5003": "You do not have access to the specified namespace.",

View file

@ -899,7 +899,7 @@
"4015": "The task comment does not exist.", "4015": "The task comment does not exist.",
"4016": "Invalid task field.", "4016": "Invalid task field.",
"4017": "Invalid task filter comparator.", "4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.", "4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.", "4019": "Invalid task filter value.",
"5001": "The namespace does not exist.", "5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.", "5003": "You do not have access to the specified namespace.",

View file

@ -899,7 +899,7 @@
"4015": "The task comment does not exist.", "4015": "The task comment does not exist.",
"4016": "Invalid task field.", "4016": "Invalid task field.",
"4017": "Invalid task filter comparator.", "4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.", "4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.", "4019": "Invalid task filter value.",
"5001": "The namespace does not exist.", "5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.", "5003": "You do not have access to the specified namespace.",

View file

@ -899,7 +899,7 @@
"4015": "Комментарий не существует.", "4015": "Комментарий не существует.",
"4016": "Неверное поле задачи.", "4016": "Неверное поле задачи.",
"4017": "Неверный сравнитель фильтров задач.", "4017": "Неверный сравнитель фильтров задач.",
"4018": "Неверный соединитель фильтров задач.", "4018": "Invalid task filter concatenator.",
"4019": "Неверное значение фильтра задач.", "4019": "Неверное значение фильтра задач.",
"5001": "Пространство имён не существует.", "5001": "Пространство имён не существует.",
"5003": "Нет доступа к указанному пространству имён.", "5003": "Нет доступа к указанному пространству имён.",

View file

@ -899,7 +899,7 @@
"4015": "The task comment does not exist.", "4015": "The task comment does not exist.",
"4016": "Invalid task field.", "4016": "Invalid task field.",
"4017": "Invalid task filter comparator.", "4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.", "4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.", "4019": "Invalid task filter value.",
"5001": "The namespace does not exist.", "5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.", "5003": "You do not have access to the specified namespace.",

View file

@ -899,7 +899,7 @@
"4015": "The task comment does not exist.", "4015": "The task comment does not exist.",
"4016": "Invalid task field.", "4016": "Invalid task field.",
"4017": "Invalid task filter comparator.", "4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.", "4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.", "4019": "Invalid task filter value.",
"5001": "The namespace does not exist.", "5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.", "5003": "You do not have access to the specified namespace.",

View file

@ -899,7 +899,7 @@
"4015": "Bình luận không tồn tại.", "4015": "Bình luận không tồn tại.",
"4016": "Trường công việc không hợp lệ.", "4016": "Trường công việc không hợp lệ.",
"4017": "Bộ so sánh bộ lọc công việc không hợp lệ.", "4017": "Bộ so sánh bộ lọc công việc không hợp lệ.",
"4018": "Bộ lọc kết hợp không hợp lệ.", "4018": "Invalid task filter concatenator.",
"4019": "Giá trị bộ lọc công việc không hợp lệ.", "4019": "Giá trị bộ lọc công việc không hợp lệ.",
"5001": "Góc làm việc không có nữa.", "5001": "Góc làm việc không có nữa.",
"5003": "Bạn chưa được phép bước vào vào góc làm việc được chỉ định.", "5003": "Bạn chưa được phép bước vào vào góc làm việc được chỉ định.",

View file

@ -16,6 +16,8 @@ import {
faCocktail, faCocktail,
faCoffee, faCoffee,
faCog, faCog,
faEye,
faEyeSlash,
faEllipsisH, faEllipsisH,
faEllipsisV, faEllipsisV,
faExclamation, faExclamation,
@ -87,6 +89,8 @@ library.add(faCocktail)
library.add(faCoffee) library.add(faCoffee)
library.add(faCog) library.add(faCog)
library.add(faComments) library.add(faComments)
library.add(faEye)
library.add(faEyeSlash)
library.add(faEllipsisH) library.add(faEllipsisH)
library.add(faEllipsisV) library.add(faEllipsisV)
library.add(faExclamation) library.add(faExclamation)

View file

@ -18,7 +18,7 @@ declare global {
} }
} }
import {formatDate, formatDateShort, formatDateLong, formatDateSince} from '@/helpers/time/formatDate' import {formatDate, formatDateShort, formatDateLong, formatDateSince, formatISO} from '@/helpers/time/formatDate'
// @ts-ignore // @ts-ignore
import {VERSION} from './version.json' import {VERSION} from './version.json'
@ -52,6 +52,7 @@ app.use(Notifications)
// directives // directives
import focus from '@/directives/focus' import focus from '@/directives/focus'
// @ts-ignore The export does exist, ts just doesn't find it.
import { VTooltip } from 'v-tooltip' import { VTooltip } from 'v-tooltip'
import 'v-tooltip/dist/v-tooltip.css' import 'v-tooltip/dist/v-tooltip.css'
import shortcut from '@/directives/shortcut' import shortcut from '@/directives/shortcut'
@ -84,6 +85,7 @@ app.mixin({
format: formatDate, format: formatDate,
formatDate: formatDateLong, formatDate: formatDateLong,
formatDateShort: formatDateShort, formatDateShort: formatDateShort,
formatISO,
getNamespaceTitle, getNamespaceTitle,
getListTitle, getListTitle,
setTitle, setTitle,

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')
}) })
@ -32,7 +40,7 @@ describe('Parse Task Text', () => {
expect(result.assignees).toHaveLength(1) expect(result.assignees).toHaveLength(1)
expect(result.assignees[0]).toBe('user') expect(result.assignees[0]).toBe('user')
}) })
it('should ignore email addresses', () => { it('should ignore email addresses', () => {
const text = 'Lorem Ipsum email@example.com' const text = 'Lorem Ipsum email@example.com'
const result = parseTaskText(text) const result = parseTaskText(text)
@ -211,17 +219,36 @@ describe('Parse Task Text', () => {
expect(`${result.date.getHours()}:${result.date.getMinutes()}`).toBe('14:0') expect(`${result.date.getHours()}:${result.date.getMinutes()}`).toBe('14:0')
}) })
it('should recognize dates of the month in the past but next month', () => { it('should recognize dates of the month in the past but next month', () => {
const date = new Date() const time = new Date(2022, 0, 15)
date.setDate(date.getDate() - 1) vi.setSystemTime(time)
const result = parseTaskText(`Lorem Ipsum ${date.getDate()}nd`)
const result = parseTaskText(`Lorem Ipsum ${time.getDate() - 1}th`)
expect(result.text).toBe('Lorem Ipsum') expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getDate()).toBe(date.getDate()) expect(result.date.getDate()).toBe(time.getDate() - 1)
expect(result.date.getMonth()).toBe(time.getMonth() + 1)
})
it('should recognize dates of the month in the past but next month when february is the next month', () => {
const jan = new Date(2022, 0, 30)
vi.setSystemTime(jan)
const nextMonthWithDate = result.date.getDate() === 31 const result = parseTaskText(`Lorem Ipsum ${jan.getDate() - 1}th`)
? (date.getMonth() + 2) % 12
: (date.getMonth() + 1) % 12 const expectedDate = new Date(2022, 2, jan.getDate() - 1)
expect(result.date.getMonth()).toBe(nextMonthWithDate) expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getDate()).toBe(expectedDate.getDate())
expect(result.date.getMonth()).toBe(expectedDate.getMonth())
})
it('should recognize dates of the month in the past but next month when the next month has less days than this one', () => {
const mar = new Date(2022, 2, 32)
vi.setSystemTime(mar)
const result = parseTaskText(`Lorem Ipsum 31st`)
const expectedDate = new Date(2022, 4, 31)
expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getDate()).toBe(expectedDate.getDate())
expect(result.date.getMonth()).toBe(expectedDate.getMonth())
}) })
it('should recognize dates of the month in the future', () => { it('should recognize dates of the month in the future', () => {
const nextDay = new Date(+new Date() + 60 * 60 * 24 * 1000) const nextDay = new Date(+new Date() + 60 * 60 * 24 * 1000)
@ -242,6 +269,12 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum github') expect(result.text).toBe('Lorem Ipsum github')
expect(result.date).toBeNull() expect(result.date).toBeNull()
}) })
it('should not recognize date number with no spacing around them', () => {
const result = parseTaskText('Lorem Ispum v1.1.1')
expect(result.text).toBe('Lorem Ispum v1.1.1')
expect(result.date).toBeNull()
})
describe('Parse weekdays', () => { describe('Parse weekdays', () => {

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
@ -132,7 +133,7 @@ const router = createRouter({
name: 'user.register', name: 'user.register',
component: RegisterComponent, component: RegisterComponent,
meta: { meta: {
title: 'user.auth.register', title: 'user.auth.createAccount',
}, },
}, },
{ {
@ -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)
} }
}, },
@ -226,7 +238,7 @@ export default {
commit('info', info) commit('info', info)
commit('lastUserRefresh') commit('lastUserRefresh')
if (typeof info.settings.language !== 'undefined') { if (typeof info.settings.language === 'undefined' || info.settings.language === '') {
// save current language // save current language
await dispatch('saveUserSettings', { await dispatch('saveUserSettings', {
settings: { settings: {

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

@ -179,9 +179,18 @@ export default {
console.debug('Could not add label to task in kanban, task not found', t) console.debug('Could not add label to task in kanban, task not found', t)
return r return r
} }
// FIXME: direct store manipulation (task)
t.task.labels.push(label) const labels = [...t.task.labels]
ctx.commit('kanban/setTaskInBucketByIndex', t, { root: true }) labels.push(label)
ctx.commit('kanban/setTaskInBucketByIndex', {
task: {
labels,
...t.task,
},
...t,
}, { root: true })
return r return r
}, },
@ -200,15 +209,21 @@ export default {
} }
// Remove the label from the list // Remove the label from the list
for (const l in t.task.labels) { const labels = [...t.task.labels]
if (t.task.labels[l].id === label.id) { for (const l in labels) {
// FIXME: direct store manipulation (task) if (labels[l].id === label.id) {
t.task.labels.splice(l, 1) labels.splice(l, 1)
break break
} }
} }
ctx.commit('kanban/setTaskInBucketByIndex', t, {root: true}) ctx.commit('kanban/setTaskInBucketByIndex', {
task: {
labels,
...t.task,
},
...t,
}, {root: true})
return response return response
}, },

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;

View file

@ -2,4 +2,4 @@
@import "labels"; @import "labels";
@import "list"; @import "list";
@import "task"; @import "task";
@import "tasks"; @import "tasks";

View file

@ -60,9 +60,9 @@
--danger: hsla(var(--danger-h), var(--danger-s), var(--danger-l), var(--danger-a)); --danger: hsla(var(--danger-h), var(--danger-s), var(--danger-l), var(--danger-a));
// var(--primary) / $blue is #1973ff // var(--primary) / $blue is #1973ff
--primary-h: 216.5deg; --primary-h: 217deg;
--primary-s: 100%; --primary-s: 98%;
--primary-l: 54.9%; --primary-l: 53%;
--primary-a: 1; --primary-a: 1;
--primary-hsl: var(--primary-h), var(--primary-s), var(--primary-l); --primary-hsl: var(--primary-h), var(--primary-s), var(--primary-l);
--primary: hsla(var(--primary-h), var(--primary-s), var(--primary-l), var(--primary-a)); --primary: hsla(var(--primary-h), var(--primary-s), var(--primary-l), var(--primary-a));
@ -122,5 +122,10 @@
// Custom color variables we need to override // Custom color variables we need to override
--card-border-color: hsla(var(--grey-100-hsl), 0.3); --card-border-color: hsla(var(--grey-100-hsl), 0.3);
--logo-text-color: var(--grey-700); --logo-text-color: var(--grey-700);
// Slightly different primary color to make sure it has a sufficent contrast ratio
--primary-h: 217deg;
--primary-s: 98%;
--primary-l: 58%;
} }
} }

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;

4
src/types/faker.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module '@faker-js/faker' {
import faker from 'faker'
export default faker
}

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

Some files were not shown because too many files have changed in this diff Show more