feat: rename list to project everywhere

This commit is contained in:
kolaente 2022-11-13 22:04:57 +01:00
parent 34edf0dc5f
commit b8829dfaeb
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
160 changed files with 2494 additions and 2494 deletions

View File

@ -9,7 +9,7 @@
This is the web frontend for Vikunja, written in Vue.js.
Take a look at [our roadmap](https://my.vikunja.cloud/share/UrdhKPqumxDXUbYpEGJLSIyNTwAnbBzVlwdDpRbv/auth) (hosted on Vikunja!) for a list of things we're currently working on!
Take a look at [our roadmap](https://my.vikunja.cloud/share/UrdhKPqumxDXUbYpEGJLSIyNTwAnbBzVlwdDpRbv/auth) (hosted on Vikunja!) for a project of things we're currently working on!
## Security Reports

View File

@ -1,40 +1,40 @@
import {ListFactory} from '../../factories/list'
import {ProjectFactory} from '../../factories/project'
import '../../support/authenticateUser'
import {prepareLists} from './prepareLists'
import {prepareProjects} from './prepareProjects'
describe('List History', () => {
prepareLists()
describe('Project History', () => {
prepareProjects()
it('should show a list history on the home page', () => {
it('should show a project history on the home page', () => {
cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces')
cy.intercept(Cypress.env('API_URL') + '/lists/*').as('loadList')
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
const lists = ListFactory.create(6)
const projects = ProjectFactory.create(6)
cy.visit('/')
cy.wait('@loadNamespaces')
cy.get('body')
.should('not.contain', 'Last viewed')
cy.visit(`/lists/${lists[0].id}`)
cy.visit(`/projects/${projects[0].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[1].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[1].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[2].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[2].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[3].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[3].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[4].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[4].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[5].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[5].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.wait('@loadProject')
// cy.visit('/')
// cy.wait('@loadNamespaces')
@ -45,12 +45,12 @@ describe('List History', () => {
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)
cy.get('.project-cards-wrapper-2-rows')
.should('not.contain', projects[0].title)
.should('contain', projects[1].title)
.should('contain', projects[2].title)
.should('contain', projects[3].title)
.should('contain', projects[4].title)
.should('contain', projects[5].title)
})
})

View File

@ -1,15 +1,15 @@
import {formatISO, format} from 'date-fns'
import {TaskFactory} from '../../factories/task'
import {prepareLists} from './prepareLists'
import {prepareProjects} from './prepareProjects'
import '../../support/authenticateUser'
describe('List View Gantt', () => {
prepareLists()
describe('Project View Gantt', () => {
prepareProjects()
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/gantt')
cy.visit('/projects/1/gantt')
cy.get('.g-gantt-rows-container')
.should('not.contain', tasks[0].title)
@ -23,7 +23,7 @@ describe('List View Gantt', () => {
nextMonth.setDate(1)
nextMonth.setMonth(9)
cy.visit('/lists/1/gantt')
cy.visit('/projects/1/gantt')
cy.get('.g-timeunits-container')
.should('contain', format(now, 'MMMM'))
@ -36,7 +36,7 @@ describe('List View Gantt', () => {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
})
cy.visit('/lists/1/gantt')
cy.visit('/projects/1/gantt')
cy.get('.g-gantt-rows-container')
.should('not.be.empty')
@ -48,7 +48,7 @@ describe('List View Gantt', () => {
start_date: null,
end_date: null,
})
cy.visit('/lists/1/gantt')
cy.visit('/projects/1/gantt')
cy.get('.gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set')
@ -68,7 +68,7 @@ describe('List View Gantt', () => {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
})
cy.visit('/lists/1/gantt')
cy.visit('/projects/1/gantt')
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
.first()
@ -82,9 +82,9 @@ describe('List View Gantt', () => {
const now = Date.UTC(2022, 10, 9)
cy.clock(now, ['Date'])
cy.visit('/lists/1/gantt')
cy.visit('/projects/1/gantt')
cy.get('.list-gantt .gantt-options .field .control input.input.form-control')
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
.click()
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
.first()
@ -98,13 +98,13 @@ describe('List View Gantt', () => {
})
it('Should change the date range based on date query parameters', () => {
cy.visit('/lists/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.visit('/projects/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.get('.g-timeunits-container')
.should('contain', 'September 2022')
.should('contain', 'October 2022')
.should('contain', 'November 2022')
cy.get('.list-gantt .gantt-options .field .control input.input.form-control')
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
.should('have.value', '25 Sep 2022 to 5 Nov 2022')
})
@ -114,7 +114,7 @@ describe('List View Gantt', () => {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
})
cy.visit('/lists/1/gantt')
cy.visit('/projects/1/gantt')
cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar')
.dblclick()

View File

@ -1,13 +1,13 @@
import {BucketFactory} from '../../factories/bucket'
import {ListFactory} from '../../factories/list'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {prepareLists} from './prepareLists'
import {prepareProjects} from './prepareProjects'
import '../../support/authenticateUser'
describe('List View Kanban', () => {
describe('Project View Kanban', () => {
let buckets
prepareLists()
prepareProjects()
beforeEach(() => {
buckets = BucketFactory.create(2)
@ -15,10 +15,10 @@ describe('List View Kanban', () => {
it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, {
list_id: 1,
project_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
@ -33,10 +33,10 @@ describe('List View Kanban', () => {
it('Can add a new task to a bucket', () => {
TaskFactory.create(2, {
list_id: 1,
project_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.visit('/projects/1/kanban')
cy.getSettled('.kanban .bucket')
.contains(buckets[0].title)
@ -54,7 +54,7 @@ describe('List View Kanban', () => {
})
it('Can create a new bucket', () => {
cy.visit('/lists/1/kanban')
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket.new-bucket .button')
.click()
@ -68,7 +68,7 @@ describe('List View Kanban', () => {
})
it('Can set a bucket limit', () => {
cy.visit('/lists/1/kanban')
cy.visit('/projects/1/kanban')
cy.getSettled('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
@ -89,7 +89,7 @@ describe('List View Kanban', () => {
})
it('Can rename a bucket', () => {
cy.visit('/lists/1/kanban')
cy.visit('/projects/1/kanban')
cy.getSettled('.kanban .bucket .bucket-header .title')
.first()
@ -100,7 +100,7 @@ describe('List View Kanban', () => {
})
it('Can delete a bucket', () => {
cy.visit('/lists/1/kanban')
cy.visit('/projects/1/kanban')
cy.getSettled('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
@ -124,10 +124,10 @@ describe('List View Kanban', () => {
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
list_id: 1,
project_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.visit('/projects/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
@ -143,10 +143,10 @@ describe('List View Kanban', () => {
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
project_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.visit('/projects/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
@ -157,18 +157,18 @@ describe('List View Kanban', () => {
.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)
it('Should remove a task from the kanban board when moving it to another project', () => {
const projects = ProjectFactory.create(2)
BucketFactory.create(2, {
list_id: '{increment}',
project_id: '{increment}',
})
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
project_id: 1,
bucket_id: 1,
})
const task = tasks[0]
cy.visit('/lists/1/kanban')
cy.visit('/projects/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(task.title)
@ -179,7 +179,7 @@ describe('List View Kanban', () => {
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${lists[1].title}{enter}`)
.type(`${projects[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')
@ -196,26 +196,26 @@ describe('List View Kanban', () => {
it('Shows a button to filter the kanban board', () => {
const data = TaskFactory.create(10, {
list_id: 1,
project_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.visit('/projects/1/kanban')
cy.get('.list-kanban .filter-container .base-button')
cy.get('.project-kanban .filter-container .base-button')
.should('exist')
})
it('Should remove a task from the board when deleting it', () => {
const lists = ListFactory.create(1)
const projects = ProjectFactory.create(1)
const buckets = BucketFactory.create(2, {
list_id: lists[0].id,
project_id: projects[0].id,
})
const tasks = TaskFactory.create(5, {
list_id: 1,
project_id: 1,
bucket_id: buckets[0].id,
})
const task = tasks[0]
cy.visit('/lists/1/kanban')
cy.visit('/projects/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(task.title)

View File

@ -1,31 +1,31 @@
import {UserListFactory} from '../../factories/users_list'
import {UserProjectFactory} from '../../factories/users_project'
import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user'
import {ListFactory} from '../../factories/list'
import {prepareLists} from './prepareLists'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
import '../../support/authenticateUser'
describe('List View List', () => {
prepareLists()
describe('Project View Project', () => {
prepareProjects()
it('Should be an empty list', () => {
cy.visit('/lists/1')
it('Should be an empty project', () => {
cy.visit('/projects/1')
cy.url()
.should('contain', '/lists/1/list')
cy.get('.list-title h1')
.should('contain', 'First List')
cy.get('.list-title .dropdown')
.should('contain', '/projects/1/project')
cy.get('.project-title h1')
.should('contain', 'First Project')
cy.get('.project-title .dropdown')
.should('exist')
cy.get('p')
.contains('This list is currently empty.')
.contains('This project is currently empty.')
.should('exist')
})
it('Should create a new task', () => {
const newTaskTitle = 'New task'
cy.visit('/lists/1')
cy.visit('/projects/1')
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.get('.tasks')
@ -35,9 +35,9 @@ describe('List View List', () => {
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
project_id: 1,
})
cy.visit('/lists/1/list')
cy.visit('/projects/1/project')
cy.get('.tasks .task .tasktext')
.contains(tasks[0].title)
@ -48,35 +48,35 @@ describe('List View List', () => {
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should not see any elements for a list which is shared read only', () => {
it('Should not see any elements for a project which is shared read only', () => {
UserFactory.create(2)
UserListFactory.create(1, {
list_id: 2,
UserProjectFactory.create(1, {
project_id: 2,
user_id: 1,
right: 0,
})
const lists = ListFactory.create(2, {
const projects = ProjectFactory.create(2, {
owner_id: '{increment}',
namespace_id: '{increment}',
})
cy.visit(`/lists/${lists[1].id}/`)
cy.visit(`/projects/${projects[1].id}/`)
cy.get('.list-title .icon')
cy.get('.project-title .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, {
it('Should only show the color of a project in the navigation and not in the project view', () => {
const projects = ProjectFactory.create(1, {
hex_color: '00db60',
})
TaskFactory.create(10, {
list_id: lists[0].id,
project_id: projects[0].id,
})
cy.visit(`/lists/${lists[0].id}/`)
cy.visit(`/projects/${projects[0].id}/`)
cy.get('.menu-list li .list-menu-link .color-bubble')
cy.get('.menu-project li .project-menu-link .color-bubble')
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
cy.get('.tasks-container .tasks .color-bubble')
.should('not.exist')
@ -86,9 +86,9 @@ describe('List View List', () => {
const tasks = TaskFactory.create(100, {
id: '{increment}',
title: i => `task${i}`,
list_id: 1,
project_id: 1,
})
cy.visit('/lists/1/list')
cy.visit('/projects/1/project')
cy.get('.tasks-container .tasks')
.should('contain', tasks[1].title)

View File

@ -2,35 +2,35 @@ import {TaskFactory} from '../../factories/task'
import '../../support/authenticateUser'
describe('List View Table', () => {
describe('Project View Table', () => {
it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/table')
cy.visit('/projects/1/table')
cy.get('.list-table table.table')
cy.get('.project-table table.table')
.should('exist')
cy.get('.list-table table.table')
cy.get('.project-table table.table')
.should('contain', tasks[0].title)
})
it('Should have working column switches', () => {
TaskFactory.create(1)
cy.visit('/lists/1/table')
cy.visit('/projects/1/table')
cy.get('.list-table .filter-container .items .button')
cy.get('.project-table .filter-container .items .button')
.contains('Columns')
.click()
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
cy.get('.project-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')
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Done')
.click()
cy.get('.list-table table.table th')
cy.get('.project-table table.table th')
.contains('Priority')
.should('exist')
cy.get('.list-table table.table th')
cy.get('.project-table table.table th')
.contains('Done')
.should('not.exist')
})
@ -38,11 +38,11 @@ describe('List View Table', () => {
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
project_id: 1,
})
cy.visit('/lists/1/table')
cy.visit('/projects/1/table')
cy.get('.list-table table.table')
cy.get('.project-table table.table')
.contains(tasks[0].title)
.click()

View File

@ -1,88 +1,88 @@
import {TaskFactory} from '../../factories/task'
import {prepareLists} from './prepareLists'
import {prepareProjects} from './prepareProjects'
import '../../support/authenticateUser'
describe('Lists', () => {
let lists
prepareLists((newLists) => (lists = newLists))
describe('Projects', () => {
let projects
prepareProjects((newProjects) => (projects = newProjects))
it('Should create a new list', () => {
it('Should create a new project', () => {
cy.visit('/')
cy.get('.namespace-title .dropdown-trigger')
.click()
cy.get('.namespace-title .dropdown .dropdown-item')
.contains('New list')
.contains('New project')
.click()
cy.url()
.should('contain', '/lists/new/1')
.should('contain', '/projects/new/1')
cy.get('.card-header-title')
.contains('New list')
.contains('New project')
cy.get('input.input')
.type('New List')
.type('New Project')
cy.get('.button')
.contains('Create')
.click()
cy.get('.global-notification', { timeout: 1000 }) // Waiting until the request to create the new list is done
cy.get('.global-notification', { timeout: 1000 }) // Waiting until the request to create the new project is done
.should('contain', 'Success')
cy.url()
.should('contain', '/lists/')
cy.get('.list-title h1')
.should('contain', 'New List')
.should('contain', '/projects/')
cy.get('.project-title h1')
.should('contain', 'New Project')
})
it('Should redirect to a specific list view after visited', () => {
cy.visit('/lists/1/kanban')
it('Should redirect to a specific project view after visited', () => {
cy.visit('/projects/1/kanban')
cy.url()
.should('contain', '/lists/1/kanban')
cy.visit('/lists/1')
.should('contain', '/projects/1/kanban')
cy.visit('/projects/1')
cy.url()
.should('contain', '/lists/1/kanban')
.should('contain', '/projects/1/kanban')
})
it('Should rename the list in all places', () => {
it('Should rename the project in all places', () => {
TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
project_id: 1,
})
const newListName = 'New list name'
const newProjectName = 'New project name'
cy.visit('/lists/1')
cy.get('.list-title h1')
.should('contain', 'First List')
cy.visit('/projects/1')
cy.get('.project-title h1')
.should('contain', 'First Project')
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-trigger')
cy.get('.namespace-container .menu.namespaces-projects .menu-project li:first-child .dropdown .dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
cy.get('.namespace-container .menu.namespaces-projects .menu-project li:first-child .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.get('#title')
.type(`{selectall}${newListName}`)
.type(`{selectall}${newProjectName}`)
cy.get('footer.card-footer .button')
.contains('Save')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.list-title h1')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.get('.project-title h1')
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
cy.get('.namespace-container .menu.namespaces-projects .menu-project li:first-child')
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
cy.visit('/')
cy.get('.card-content')
.should('contain', newListName)
.should('not.contain', lists[0].title)
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
})
it('Should remove a list', () => {
cy.visit(`/lists/${lists[0].id}`)
it('Should remove a project', () => {
cy.visit(`/projects/${projects[0].id}`)
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-trigger')
cy.get('.namespace-container .menu.namespaces-projects .menu-project li:first-child .dropdown .dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
cy.get('.namespace-container .menu.namespaces-projects .menu-project li:first-child .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
@ -93,28 +93,28 @@ describe('Lists', () => {
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
.should('not.contain', lists[0].title)
cy.get('.namespace-container .menu.namespaces-projects .menu-project')
.should('not.contain', projects[0].title)
cy.location('pathname')
.should('equal', '/')
})
it('Should archive a list', () => {
cy.visit(`/lists/${lists[0].id}`)
it('Should archive a project', () => {
cy.visit(`/projects/${projects[0].id}`)
cy.get('.list-title .dropdown')
cy.get('.project-title .dropdown')
.click()
cy.get('.list-title .dropdown .dropdown-menu .dropdown-item')
cy.get('.project-title .dropdown .dropdown-menu .dropdown-item')
.contains('Archive')
.click()
cy.get('.modal-content')
.should('contain.text', 'Archive this list')
.should('contain.text', 'Archive this project')
cy.get('.modal-content [data-cy=modalPrimary]')
.click()
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
.should('not.contain', lists[0].title)
cy.get('.namespace-container .menu.namespaces-projects .menu-project')
.should('not.contain', projects[0].title)
cy.get('main.app-content')
.should('contain.text', 'This list is archived. It is not possible to create new or edit tasks for it.')
.should('contain.text', 'This project is archived. It is not possible to create new or edit tasks for it.')
})
})

View File

@ -1,7 +1,7 @@
import {UserFactory} from '../../factories/user'
import '../../support/authenticateUser'
import {ListFactory} from '../../factories/list'
import {ProjectFactory} from '../../factories/project'
import {NamespaceFactory} from '../../factories/namespace'
describe('Namepaces', () => {
@ -10,7 +10,7 @@ describe('Namepaces', () => {
beforeEach(() => {
UserFactory.create(1)
namespaces = NamespaceFactory.create(1)
ListFactory.create(1)
ProjectFactory.create(1)
})
it('Should be all there', () => {
@ -51,9 +51,9 @@ describe('Namepaces', () => {
cy.visit('/namespaces')
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
cy.get(`.namespace-container .menu.namespaces-projects .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
.click()
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
cy.get('.namespace-container .menu.namespaces-projects .namespace-title .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.url()
@ -69,10 +69,10 @@ describe('Namepaces', () => {
cy.get('.global-notification', { timeout: 1000 })
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists')
cy.get('.namespace-container .menu.namespaces-projects')
.should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title)
cy.get('[data-cy="namespaces-list"]')
cy.get('[data-cy="namespaces-project"]')
.should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title)
})
@ -82,9 +82,9 @@ describe('Namepaces', () => {
cy.visit('/')
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
cy.get(`.namespace-container .menu.namespaces-projects .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
.click()
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
cy.get('.namespace-container .menu.namespaces-projects .namespace-title .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
@ -95,21 +95,21 @@ describe('Namepaces', () => {
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists')
cy.get('.namespace-container .menu.namespaces-projects')
.should('not.contain', newNamespaces[0].title)
})
it('Should not show archived lists & namespaces if the filter is not checked', () => {
it('Should not show archived projects & namespaces if the filter is not checked', () => {
const n = NamespaceFactory.create(1, {
id: 2,
is_archived: true,
}, false)
ListFactory.create(1, {
ProjectFactory.create(1, {
id: 2,
namespace_id: n[0].id,
}, false)
ListFactory.create(1, {
ProjectFactory.create(1, {
id: 3,
is_archived: true,
}, false)

View File

@ -1,21 +1,21 @@
import {ListFactory} from '../../factories/list'
import {ProjectFactory} from '../../factories/project'
import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {TaskFactory} from '../../factories/task'
export function createLists() {
export function createProjects() {
UserFactory.create(1)
NamespaceFactory.create(1)
const lists = ListFactory.create(1, {
title: 'First List'
const projects = ProjectFactory.create(1, {
title: 'First Project'
})
TaskFactory.truncate()
return lists
return projects
}
export function prepareLists(setLists = () => {}) {
export function prepareProjects(setProjects = () => {}) {
beforeEach(() => {
const lists = createLists()
setLists(lists)
const projects = createProjects()
setProjects(projects)
})
}

View File

@ -1,16 +1,16 @@
import {TaskFactory} from '../../factories/task'
import {ListFactory} from '../../factories/list'
import {ProjectFactory} from '../../factories/project'
import {NamespaceFactory} from '../../factories/namespace'
import {UserListFactory} from '../../factories/users_list'
import {UserProjectFactory} from '../../factories/users_project'
import '../../support/authenticateUser'
describe('Editor', () => {
beforeEach(() => {
NamespaceFactory.create(1)
const lists = ListFactory.create(1)
const projects = ProjectFactory.create(1)
TaskFactory.truncate()
UserListFactory.truncate()
UserProjectFactory.truncate()
})
it('Has a preview with checkable checkboxes', () => {

View File

@ -1,22 +1,22 @@
import {LinkShareFactory} from '../../factories/link_sharing'
import {ListFactory} from '../../factories/list'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
describe('Link shares', () => {
it('Can view a link share', () => {
const lists = ListFactory.create(1)
const projects = ProjectFactory.create(1)
const tasks = TaskFactory.create(10, {
list_id: lists[0].id
project_id: projects[0].id
})
const linkShares = LinkShareFactory.create(1, {
list_id: lists[0].id,
project_id: projects[0].id,
right: 0,
})
cy.visit(`/share/${linkShares[0].hash}/auth`)
cy.get('h1.title')
.should('contain', lists[0].title)
.should('contain', projects[0].title)
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
cy.get('.tasks')

View File

@ -1,4 +1,4 @@
import {ListFactory} from '../../factories/list'
import {ProjectFactory} from '../../factories/project'
import {seed} from '../../support/seed'
import {TaskFactory} from '../../factories/task'
import {formatISO} from 'date-fns'
@ -12,9 +12,9 @@ import '../../support/authenticateUser'
function seedTasks(numberOfTasks = 100, startDueDate = new Date()) {
UserFactory.create(1)
NamespaceFactory.create(1)
const list = ListFactory.create()[0]
const project = ProjectFactory.create()[0]
BucketFactory.create(1, {
list_id: list.id,
project_id: project.id,
})
const tasks = []
let dueDate = startDueDate
@ -23,7 +23,7 @@ function seedTasks(numberOfTasks = 100, startDueDate = new Date()) {
dueDate = (new Date(dueDate.valueOf())).setDate((new Date(dueDate.valueOf())).getDate() + 2)
tasks.push({
id: i + 1,
list_id: list.id,
project_id: project.id,
done: false,
created_by_id: 1,
title: 'Test Task ' + i,
@ -34,7 +34,7 @@ function seedTasks(numberOfTasks = 100, startDueDate = new Date()) {
})
}
seed(TaskFactory.table, tasks)
return {tasks, list}
return {tasks, project}
}
describe('Home Page Task Overview', () => {
@ -71,7 +71,7 @@ describe('Home Page Task Overview', () => {
due_date: formatISO(new Date()),
}, false)
cy.visit(`/lists/${tasks[0].list_id}/list`)
cy.visit(`/projects/${tasks[0].project_id}/project`)
cy.get('.tasks .task')
.first()
.should('contain.text', newTaskTitle)
@ -88,7 +88,7 @@ describe('Home Page Task Overview', () => {
cy.visit('/')
cy.visit(`/lists/${tasks[0].list_id}/list`)
cy.visit(`/projects/${tasks[0].project_id}/project`)
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.visit('/')
@ -111,10 +111,10 @@ describe('Home Page Task Overview', () => {
.should('contain.text', newTaskTitle)
})
it('Should show a task without a due date added via default list at the bottom', () => {
const {list} = seedTasks(40)
it('Should show a task without a due date added via default project at the bottom', () => {
const {project} = seedTasks(40)
updateUserSettings({
default_list_id: list.id,
default_project_id: project.id,
overdue_tasks_reminders_time: '9:00',
})
@ -129,23 +129,23 @@ describe('Home Page Task Overview', () => {
.should('contain.text', newTaskTitle)
})
it('Should show the cta buttons for new list when there are no tasks', () => {
it('Should show the cta buttons for new project when there are no tasks', () => {
TaskFactory.truncate()
cy.visit('/')
cy.get('.home.app-content .content')
.should('contain.text', 'You can create a new list for your new tasks:')
.should('contain.text', 'Or import your lists and tasks from other services into Vikunja:')
.should('contain.text', 'You can create a new project for your new tasks:')
.should('contain.text', 'Or import your projects and tasks from other services into Vikunja:')
})
it('Should not show the cta buttons for new list when there are tasks', () => {
it('Should not show the cta buttons for new project when there are tasks', () => {
seedTasks()
cy.visit('/')
cy.get('.home.app-content .content')
.should('not.contain.text', 'You can create a new list for your new tasks:')
.should('not.contain.text', 'Or import your lists and tasks from other services into Vikunja:')
.should('not.contain.text', 'You can create a new project for your new tasks:')
.should('not.contain.text', 'Or import your projects and tasks from other services into Vikunja:')
})
})

View File

@ -1,11 +1,11 @@
import {formatISO} from 'date-fns'
import {TaskFactory} from '../../factories/task'
import {ListFactory} from '../../factories/list'
import {ProjectFactory} from '../../factories/project'
import {TaskCommentFactory} from '../../factories/task_comment'
import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {UserListFactory} from '../../factories/users_list'
import {UserProjectFactory} from '../../factories/users_project'
import {TaskAssigneeFactory} from '../../factories/task_assignee'
import {LabelFactory} from '../../factories/labels'
import {LabelTaskFactory} from '../../factories/label_task'
@ -47,22 +47,22 @@ function uploadAttachmentAndVerify(taskId: number) {
describe('Task', () => {
let namespaces
let lists
let projects
let buckets
beforeEach(() => {
UserFactory.create(1)
namespaces = NamespaceFactory.create(1)
lists = ListFactory.create(1)
projects = ProjectFactory.create(1)
buckets = BucketFactory.create(1, {
list_id: lists[0].id,
project_id: projects[0].id,
})
TaskFactory.truncate()
UserListFactory.truncate()
UserProjectFactory.truncate()
})
it('Should be created new', () => {
cy.visit('/lists/1/list')
cy.visit('/projects/1/project')
cy.get('.input[placeholder="Add a new task…"')
.type('New Task')
cy.get('.button')
@ -73,11 +73,11 @@ describe('Task', () => {
.should('contain', 'New Task')
})
it('Inserts new tasks at the top of the list', () => {
it('Inserts new tasks at the top of the project', () => {
TaskFactory.create(1)
cy.visit('/lists/1/list')
cy.get('.list-is-empty-notice')
cy.visit('/projects/1/project')
cy.get('.project-is-empty-notice')
.should('not.exist')
cy.get('.input[placeholder="Add a new task…"')
.type('New Task')
@ -94,7 +94,7 @@ describe('Task', () => {
it('Marks a task as done', () => {
TaskFactory.create(1)
cy.visit('/lists/1/list')
cy.visit('/projects/1/project')
cy.get('.tasks .task .fancycheckbox label.check')
.first()
.click()
@ -105,11 +105,11 @@ describe('Task', () => {
it('Can add a task to favorites', () => {
TaskFactory.create(1)
cy.visit('/lists/1/list')
cy.visit('/projects/1/project')
cy.get('.tasks .task .favorite')
.first()
.click()
cy.get('.menu.namespaces-lists')
cy.get('.menu.namespaces-projects')
.should('contain', 'Favorites')
})
@ -133,7 +133,7 @@ describe('Task', () => {
.should('contain', '#1')
cy.get('.task-view h6.subtitle')
.should('contain', namespaces[0].title)
.should('contain', lists[0].title)
.should('contain', projects[0].title)
cy.get('.task-view .details.content.description')
.should('contain', tasks[0].description)
cy.get('.task-view .action-buttons p.created')
@ -178,21 +178,21 @@ describe('Task', () => {
.should('contain', 'Mark as undone')
})
it('Shows a task identifier since the list has one', () => {
const lists = ListFactory.create(1, {
it('Shows a task identifier since the project has one', () => {
const projects = ProjectFactory.create(1, {
id: 1,
identifier: 'TEST',
})
const tasks = TaskFactory.create(1, {
id: 1,
list_id: lists[0].id,
project_id: projects[0].id,
index: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view h1.title.task-id')
.should('contain', `${lists[0].identifier}-${tasks[0].index}`)
.should('contain', `${projects[0].identifier}-${tasks[0].index}`)
})
it('Can edit the description', () => {
@ -235,14 +235,14 @@ describe('Task', () => {
.should('contain', 'Success')
})
it('Can move a task to another list', () => {
const lists = ListFactory.create(2)
it('Can move a task to another project', () => {
const projects = ProjectFactory.create(2)
BucketFactory.create(2, {
list_id: '{increment}'
project_id: '{increment}'
})
const tasks = TaskFactory.create(1, {
id: 1,
list_id: lists[0].id,
project_id: projects[0].id,
})
cy.visit(`/tasks/${tasks[0].id}`)
@ -250,7 +250,7 @@ describe('Task', () => {
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${lists[1].title}{enter}`)
.type(`${projects[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')
@ -260,7 +260,7 @@ describe('Task', () => {
cy.get('.task-view h6.subtitle')
.should('contain', namespaces[0].title)
.should('contain', lists[1].title)
.should('contain', projects[1].title)
cy.get('.global-notification')
.should('contain', 'Success')
})
@ -268,7 +268,7 @@ describe('Task', () => {
it('Can delete a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
list_id: 1,
project_id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
@ -285,17 +285,17 @@ describe('Task', () => {
cy.get('.global-notification')
.should('contain', 'Success')
cy.url()
.should('contain', `/lists/${tasks[0].list_id}/`)
.should('contain', `/projects/${tasks[0].project_id}/`)
})
it('Can add an assignee to a task', () => {
const users = UserFactory.create(5)
const tasks = TaskFactory.create(1, {
id: 1,
list_id: 1,
project_id: 1,
})
UserListFactory.create(5, {
list_id: 1,
UserProjectFactory.create(5, {
project_id: 1,
user_id: '{increment}',
})
@ -320,10 +320,10 @@ describe('Task', () => {
const users = UserFactory.create(2)
const tasks = TaskFactory.create(1, {
id: 1,
list_id: 1,
project_id: 1,
})
UserListFactory.create(5, {
list_id: 1,
UserProjectFactory.create(5, {
project_id: 1,
user_id: '{increment}',
})
TaskAssigneeFactory.create(1, {
@ -346,7 +346,7 @@ describe('Task', () => {
it('Can add a new label to a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
list_id: 1,
project_id: 1,
})
LabelFactory.truncate()
const newLabelText = 'some new label'
@ -374,7 +374,7 @@ describe('Task', () => {
it('Can add an existing label to a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
list_id: 1,
project_id: 1,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
@ -387,13 +387,13 @@ describe('Task', () => {
it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
const tasks = TaskFactory.create(1, {
id: 1,
list_id: lists[0].id,
project_id: projects[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
@ -411,7 +411,7 @@ describe('Task', () => {
it('Can remove a label from a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
list_id: 1,
project_id: 1,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.create(1, {
@ -526,13 +526,13 @@ describe('Task', () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
list_id: lists[0].id,
project_id: projects[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)

View File

@ -1,5 +1,5 @@
import '../../support/authenticateUser'
import {createLists} from '../list/prepareLists'
import {createProjects} from '../project/prepareProjects'
function logout() {
cy.get('.navbar .user .username')
@ -24,21 +24,21 @@ describe('Log out', () => {
})
})
it.skip('Should clear the list history after logging the user out', () => {
const lists = createLists()
cy.visit(`/lists/${lists[0].id}`)
it.skip('Should clear the project history after logging the user out', () => {
const projects = createProjects()
cy.visit(`/projects/${projects[0].id}`)
.then(() => {
expect(localStorage.getItem('listHistory')).to.not.eq(null)
expect(localStorage.getItem('projectHistory')).to.not.eq(null)
})
logout()
cy.wait(1000) // This makes re-loading of the list and associated entities (and the resulting error) visible
cy.wait(1000) // This makes re-loading of the project and associated entities (and the resulting error) visible
cy.url()
.should('contain', '/login')
.then(() => {
expect(localStorage.getItem('listHistory')).to.eq(null)
expect(localStorage.getItem('projectHistory')).to.eq(null)
})
})
})

View File

@ -11,7 +11,7 @@ export class BucketFactory extends Factory {
return {
id: '{increment}',
title: faker.lorem.words(3),
list_id: 1,
project_id: 1,
created_by_id: 1,
created: formatISO(now),
updated: formatISO(now)

View File

@ -11,7 +11,7 @@ export class LinkShareFactory extends Factory {
return {
id: '{increment}',
hash: faker.random.word(32),
list_id: 1,
project_id: 1,
right: 0,
sharing_type: 0,
shared_by_id: 1,

View File

@ -2,8 +2,8 @@ import {Factory} from '../support/factory'
import {formatISO} from "date-fns"
import {faker} from '@faker-js/faker'
export class ListFactory extends Factory {
static table = 'lists'
export class ProjectFactory extends Factory {
static table = 'projects'
static factory() {
const now = new Date()

View File

@ -12,7 +12,7 @@ export class TaskFactory extends Factory {
id: '{increment}',
title: faker.lorem.words(3),
done: false,
list_id: 1,
project_id: 1,
created_by_id: 1,
index: '{increment}',
position: '{increment}',

View File

@ -1,15 +1,15 @@
import {Factory} from '../support/factory'
import {formatISO} from "date-fns"
export class UserListFactory extends Factory {
static table = 'users_lists'
export class UserProjectFactory extends Factory {
static table = 'users_projects'
static factory() {
const now = new Date()
return {
id: '{increment}',
list_id: 1,
project_id: 1,
user_id: 1,
right: 0,
created: formatISO(now),

View File

@ -30,21 +30,21 @@ A basic service can look like this:
```javascript
import AbstractService from './abstractService'
import ListModel from '../models/list'
import ProjectModel from '../models/project'
export default class ListService extends AbstractService {
export default class ProjectService extends AbstractService {
constructor() {
super({
getAll: '/lists',
get: '/lists/{id}',
create: '/namespaces/{namespaceID}/lists',
update: '/lists/{id}',
delete: '/lists/{id}',
getAll: '/projects',
get: '/projects/{id}',
create: '/namespaces/{namespaceID}/projects',
update: '/projects/{id}',
delete: '/projects/{id}',
})
}
modelFactory(data) {
return new ListModel(data)
return new ProjectModel(data)
}
}
```
@ -132,7 +132,7 @@ import AbstractModel from './abstractModel'
import TaskModel from './task'
import UserModel from './user'
export default class ListModel extends AbstractModel {
export default class ProjectModel extends AbstractModel {
constructor(data) {
// The constructor of AbstractModel handles all the default parsing.

View File

@ -18,8 +18,8 @@ sed -i "s/'\/api\/v1/'$VIKUNJA_API_URL/g" /usr/share/nginx/html/index.html
sed -i "s/\.SENTRY_ENABLED = false/\.SENTRY_ENABLED = $VIKUNJA_SENTRY_ENABLED/g" /usr/share/nginx/html/index.html
sed -i "s|\.SENTRY_DSN = '.*'|\.SENTRY_DSN = '$VIKUNJA_SENTRY_DSN'|g" /usr/share/nginx/html/index.html
sed -i "s/listen 80/listen $VIKUNJA_HTTP_PORT/g" /etc/nginx/nginx.conf
sed -i "s/listen 443/listen $VIKUNJA_HTTPS_PORT/g" /etc/nginx/nginx.conf
sed -i "s/projecten 80/projecten $VIKUNJA_HTTP_PORT/g" /etc/nginx/nginx.conf
sed -i "s/projecten 443/projecten $VIKUNJA_HTTPS_PORT/g" /etc/nginx/nginx.conf
# Set the uid and gid of the nginx run user
usermod --non-unique --uid ${PUID} nginx

View File

@ -10,7 +10,7 @@ app.use(express.static(p))
app.get('*', (request, response, next) => {
response.sendFile(`${p}/index.html`)
})
app.listen(port, '127.0.0.1', () => {
app.projecten(port, '127.0.0.1', () => {
console.log(`Serving files from ${p}`)
console.log(`Server started on port ${port}`)
})

View File

@ -8,19 +8,19 @@
<Logo width="164" height="48"/>
</router-link>
<MenuButton class="menu-button"/>
<div class="list-title" ref="listTitle" v-show="currentList.id">
<template v-if="currentList.id">
<div class="project-title" ref="projectTitle" v-show="currentProject.id">
<template v-if="currentProject.id">
<h1
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
:style="{ 'opacity': currentProject.title === '' ? '0': '1' }"
class="title">
{{ currentList.title === '' ? $t('misc.loading') : getListTitle(currentList) }}
{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
</h1>
<BaseButton :to="{name: 'list.info', params: {listId: currentList.id}}" class="info-button">
<BaseButton :to="{name: 'project.info', params: {projectId: currentProject.id}}" class="info-button">
<icon icon="circle-info"/>
</BaseButton>
<list-settings-dropdown v-if="canWriteCurrentList && currentList.id !== -1" :list="currentList"/>
<project-settings-dropdown v-if="canWriteCurrentProject && currentProject.id !== -1" :project="currentProject"/>
</template>
</div>
@ -96,7 +96,7 @@ import {ref, computed, onMounted, nextTick} from 'vue'
import {RIGHTS as Rights} from '@/constants/rights'
import Update from '@/components/home/update.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import Notifications from '@/components/notifications/notifications.vue'
@ -104,16 +104,16 @@ import Logo from '@/components/home/Logo.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import MenuButton from '@/components/home/MenuButton.vue'
import {getListTitle} from '@/helpers/getListTitle'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import {useBaseStore} from '@/stores/base'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
const baseStore = useBaseStore()
const currentList = computed(() => baseStore.currentList)
const currentProject = computed(() => baseStore.currentProject)
const background = computed(() => baseStore.background)
const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Rights.READ)
const canWriteCurrentProject = computed(() => baseStore.currentProject.maxRight > Rights.READ)
const menuActive = computed(() => baseStore.menuActive)
const authStore = useAuthStore()
@ -123,15 +123,15 @@ const imprintUrl = computed(() => configStore.legal.imprintUrl)
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
const usernameDropdown = ref()
const listTitle = ref()
const projectTitle = ref()
onMounted(async () => {
await nextTick()
if (typeof usernameDropdown.value === 'undefined' || typeof listTitle.value === 'undefined') {
if (typeof usernameDropdown.value === 'undefined' || typeof projectTitle.value === 'undefined') {
return
}
const usernameWidth = usernameDropdown.value.$el.clientWidth
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
projectTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
function openQuickActions() {
@ -259,7 +259,7 @@ $hamburger-menu-icon-width: 28px;
}
}
.list-title {
.project-title {
display: flex;
align-items: center;
justify-content: center;

View File

@ -33,7 +33,7 @@
<quick-actions/>
<router-view :route="routeWithModal" v-slot="{ Component }">
<keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
<keep-alive :include="['project.project', 'project.gantt', 'project.table', 'project.kanban']">
<component :is="Component"/>
</keep-alive>
</router-view>
@ -90,7 +90,7 @@ const route = useRoute()
watch(() => route.fullPath, () => window.innerWidth < 769 && baseStore.setMenuActive(false))
// FIXME: this is really error prone
// Reset the current list highlight in menu if the current route is not list related.
// Reset the current project highlight in menu if the current route is not project related.
watch(() => route.name as string, (routeName) => {
if (
routeName &&
@ -109,7 +109,7 @@ watch(() => route.name as string, (routeName) => {
routeName.startsWith('user.settings')
)
) {
baseStore.handleSetCurrentList({list: null})
baseStore.handleSetCurrentProject({project: null})
}
})

View File

@ -9,9 +9,9 @@
<Logo class="logo" v-if="logoVisible"/>
<h1
:class="{'m-0': !logoVisible}"
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
:style="{ 'opacity': currentProject.title === '' ? '0': '1' }"
class="title">
{{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
{{ currentProject.title === '' ? $t('misc.loading') : currentProject.title }}
</h1>
<div class="box has-text-left view">
<router-view/>
@ -31,7 +31,7 @@ import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue'
const baseStore = useBaseStore()
const currentList = computed(() => baseStore.currentList)
const currentProject = computed(() => baseStore.currentProject)
const background = computed(() => baseStore.background)
const logoVisible = computed(() => baseStore.logoVisible)
</script>

View File

@ -4,7 +4,7 @@
<router-link :to="{name: 'home'}" class="logo">
<Logo width="164" height="48"/>
</router-link>
<ul class="menu-list">
<ul class="menu-project">
<li>
<router-link :to="{ name: 'home'}" v-shortcut="'g o'">
<span class="icon">
@ -48,11 +48,11 @@
</ul>
</nav>
<nav class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
<nav class="menu namespaces-projects loader-container is-loading-small" :class="{'is-loading': loading}">
<template v-for="(n, nk) in namespaces" :key="n.id">
<div class="namespace-title" :class="{'has-menu': n.id > 0}">
<BaseButton
@click="toggleLists(n.id)"
@click="toggleProjects(n.id)"
class="menu-label"
v-tooltip="namespaceTitles[nk]"
>
@ -63,29 +63,29 @@
/>
<span class="name">{{ namespaceTitles[nk] }}</span>
<div
class="icon is-small toggle-lists-icon pl-2"
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
class="icon is-small toggle-projects-icon pl-2"
:class="{'active': typeof projectsVisible[n.id] !== 'undefined' ? projectsVisible[n.id] : true}"
>
<icon icon="chevron-down"/>
</div>
<span class="count" :class="{'ml-2 mr-0': n.id > 0}">
({{ namespaceListsCount[nk] }})
({{ namespaceProjectsCount[nk] }})
</span>
</BaseButton>
<namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
</div>
<!--
NOTE: a v-model / computed setter is not possible, since the updateActiveLists function
NOTE: a v-model / computed setter is not possible, since the updateActiveProjects function
triggered by the change needs to have access to the current namespace
-->
<draggable
v-if="listsVisible[n.id] ?? true"
v-if="projectsVisible[n.id] ?? true"
v-bind="dragOptions"
:modelValue="activeLists[nk]"
@update:modelValue="(lists) => updateActiveLists(n, lists)"
group="namespace-lists"
:modelValue="activeProjects[nk]"
@update:modelValue="(projects) => updateActiveProjects(n, projects)"
group="namespace-projects"
@start="() => drag = true"
@end="saveListPosition"
@end="saveProjectPosition"
handle=".handle"
:disabled="n.id < 0 || undefined"
tag="ul"
@ -94,22 +94,22 @@
:data-namespace-index="nk"
:component-data="{
type: 'transition-group',
name: !drag ? 'flip-list' : null,
name: !drag ? 'flip-project' : null,
class: [
'menu-list can-be-hidden',
'menu-project can-be-hidden',
{ 'dragging-disabled': n.id < 0 }
]
}"
>
<template #item="{element: l}">
<li
class="list-menu loader-container is-loading-small"
:class="{'is-loading': listUpdating[l.id]}"
class="project-menu loader-container is-loading-small"
:class="{'is-loading': projectUpdating[l.id]}"
>
<BaseButton
:to="{ name: 'list.index', params: { listId: l.id} }"
class="list-menu-link"
:class="{'router-link-exact-active': currentList.id === l.id}"
:to="{ name: 'project.index', params: { projectId: l.id} }"
class="project-menu-link"
:class="{'router-link-exact-active': currentProject.id === l.id}"
>
<span class="icon handle">
<icon icon="grip-lines"/>
@ -119,17 +119,17 @@
:color="l.hexColor"
class="mr-1"
/>
<span class="list-menu-title">{{ getListTitle(l) }}</span>
<span class="project-menu-title">{{ getProjectTitle(l) }}</span>
</BaseButton>
<BaseButton
class="favorite"
:class="{'is-favorite': l.isFavorite}"
@click="listStore.toggleListFavorite(l)"
@click="projectStore.toggleProjectFavorite(l)"
>
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
</BaseButton>
<list-settings-dropdown :list="l" v-if="l.id > 0"/>
<span class="list-setting-spacer" v-else></span>
<project-settings-dropdown :project="l" v-if="l.id > 0"/>
<span class="project-setting-spacer" v-else></span>
</li>
</template>
</draggable>
@ -145,21 +145,21 @@ import draggable from 'zhyswan-vuedraggable'
import type {SortableEvent} from 'sortablejs'
import BaseButton from '@/components/base/BaseButton.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
import PoweredByLink from '@/components/home/PoweredByLink.vue'
import Logo from '@/components/home/Logo.vue'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
import {getListTitle} from '@/helpers/getListTitle'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import {useEventListener} from '@vueuse/core'
import type {IList} from '@/modelTypes/IList'
import type {IProject} from '@/modelTypes/IProject'
import type {INamespace} from '@/modelTypes/INamespace'
import ColorBubble from '@/components/misc/colorBubble.vue'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
import {useProjectStore} from '@/stores/projects'
import {useNamespaceStore} from '@/stores/namespaces'
const drag = ref(false)
@ -170,7 +170,7 @@ const dragOptions = {
const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore()
const currentList = computed(() => baseStore.currentList)
const currentProject = computed(() => baseStore.currentProject)
const menuActive = computed(() => baseStore.menuActive)
const loading = computed(() => namespaceStore.isLoading)
@ -178,9 +178,9 @@ const loading = computed(() => namespaceStore.isLoading)
const namespaces = computed(() => {
return namespaceStore.namespaces.filter(n => !n.isArchived)
})
const activeLists = computed(() => {
return namespaces.value.map(({lists}) => {
return lists?.filter(item => {
const activeProjects = computed(() => {
return namespaces.value.map(({projects}) => {
return projects?.filter(item => {
return typeof item !== 'undefined' && !item.isArchived
})
})
@ -190,86 +190,86 @@ const namespaceTitles = computed(() => {
return namespaces.value.map((namespace) => getNamespaceTitle(namespace))
})
const namespaceListsCount = computed(() => {
return namespaces.value.map((_, index) => activeLists.value[index]?.length ?? 0)
const namespaceProjectsCount = computed(() => {
return namespaces.value.map((_, index) => activeProjects.value[index]?.length ?? 0)
})
useEventListener('resize', resize)
onMounted(() => resize())
const listStore = useListStore()
const projectStore = useProjectStore()
function resize() {
// Hide the menu by default on mobile
baseStore.setMenuActive(window.innerWidth >= 770)
}
function toggleLists(namespaceId: INamespace['id']) {
listsVisible.value[namespaceId] = !listsVisible.value[namespaceId]
function toggleProjects(namespaceId: INamespace['id']) {
projectsVisible.value[namespaceId] = !projectsVisible.value[namespaceId]
}
const listsVisible = ref<{ [id: INamespace['id']]: boolean }>({})
const projectsVisible = ref<{ [id: INamespace['id']]: boolean }>({})
// FIXME: async action will be unfinished when component mounts
onBeforeMount(async () => {
const namespaces = await namespaceStore.loadNamespaces()
namespaces.forEach(n => {
if (typeof listsVisible.value[n.id] === 'undefined') {
listsVisible.value[n.id] = true
if (typeof projectsVisible.value[n.id] === 'undefined') {
projectsVisible.value[n.id] = true
}
})
})
function updateActiveLists(namespace: INamespace, activeLists: IList[]) {
// This is a bit hacky: since we do have to filter out the archived items from the list
function updateActiveProjects(namespace: INamespace, activeProjects: IProject[]) {
// This is a bit hacky: since we do have to filter out the archived items from the project
// for vue draggable updating it is not as simple as replacing it.
// To work around this, we merge the active lists with the archived ones. Doing so breaks the order
// because now all archived lists are sorted after the active ones. This is fine because they are sorted
// To work around this, we merge the active projects with the archived ones. Doing so breaks the order
// because now all archived projects are sorted after the active ones. This is fine because they are sorted
// later when showing them anyway, and it makes the merging happening here a lot easier.
const lists = [
...activeLists,
...namespace.lists.filter(l => l.isArchived),
const projects = [
...activeProjects,
...namespace.projects.filter(l => l.isArchived),
]
namespaceStore.setNamespaceById({
...namespace,
lists,
projects,
})
}
const listUpdating = ref<{ [id: INamespace['id']]: boolean }>({})
const projectUpdating = ref<{ [id: INamespace['id']]: boolean }>({})
async function saveListPosition(e: SortableEvent) {
async function saveProjectPosition(e: SortableEvent) {
if (!e.newIndex && e.newIndex !== 0) return
const namespaceId = parseInt(e.to.dataset.namespaceId as string)
const newNamespaceIndex = parseInt(e.to.dataset.namespaceIndex as string)
const listsActive = activeLists.value[newNamespaceIndex]
// If the list was dragged to the last position, Safari will report e.newIndex as the size of the listsActive
// array instead of using the position. Because the index is wrong in that case, dragging the list will fail.
const projectsActive = activeProjects.value[newNamespaceIndex]
// If the project was dragged to the last position, Safari will report e.newIndex as the size of the projectsActive
// array instead of using the position. Because the index is wrong in that case, dragging the project will fail.
// To work around that we're explicitly checking that case here and decrease the index.
const newIndex = e.newIndex === listsActive.length ? e.newIndex - 1 : e.newIndex
const newIndex = e.newIndex === projectsActive.length ? e.newIndex - 1 : e.newIndex
const list = listsActive[newIndex]
const listBefore = listsActive[newIndex - 1] ?? null
const listAfter = listsActive[newIndex + 1] ?? null
listUpdating.value[list.id] = true
const project = projectsActive[newIndex]
const projectBefore = projectsActive[newIndex - 1] ?? null
const projectAfter = projectsActive[newIndex + 1] ?? null
projectUpdating.value[project.id] = true
const position = calculateItemPosition(
listBefore !== null ? listBefore.position : null,
listAfter !== null ? listAfter.position : null,
projectBefore !== null ? projectBefore.position : null,
projectAfter !== null ? projectAfter.position : null,
)
try {
// create a copy of the list in order to not violate pinia manipulation
await listStore.updateList({
...list,
// create a copy of the project in order to not violate pinia manipulation
await projectStore.updateProject({
...project,
position,
namespaceId,
})
} finally {
listUpdating.value[list.id] = false
projectUpdating.value[project.id] = false
}
}
</script>
@ -320,14 +320,14 @@ $vikunja-nav-selected-width: 0.4rem;
}
.menu-label,
.menu-list .list-menu-link,
.menu-list a {
.menu-project .project-menu-link,
.menu-project a {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
.list-menu-title {
.project-menu-title {
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
@ -351,7 +351,7 @@ $vikunja-nav-selected-width: 0.4rem;
}
.favorite.is-favorite,
.list-menu:hover .favorite {
.project-menu:hover .favorite {
opacity: 1;
}
@ -396,7 +396,7 @@ $vikunja-nav-selected-width: 0.4rem;
cursor: pointer;
}
.toggle-lists-icon {
.toggle-projects-icon {
svg {
transition: all $transition;
transform: rotate(90deg);
@ -409,23 +409,23 @@ $vikunja-nav-selected-width: 0.4rem;
}
}
&:hover .toggle-lists-icon svg {
&:hover .toggle-projects-icon svg {
opacity: 1;
}
&:not(.has-menu) .toggle-lists-icon {
&:not(.has-menu) .toggle-projects-icon {
padding-right: 1rem;
}
}
.menu-label,
.nsettings,
.menu-list .list-menu-link,
.menu-list a {
.menu-project .project-menu-link,
.menu-project a {
color: $vikunja-nav-color;
}
.menu-list {
.menu-project {
li {
height: 44px;
display: flex;
@ -447,7 +447,7 @@ $vikunja-nav-selected-width: 0.4rem;
}
}
.flip-list-move {
.flip-project-move {
transition: transform $transition-duration;
}
@ -463,7 +463,7 @@ $vikunja-nav-selected-width: 0.4rem;
background: transparent;
}
.list-menu-link, li > a {
.project-menu-link, li > a {
padding: 0.75rem .5rem 0.75rem ($navbar-padding * 1.5 - 1.75rem);
transition: all 0.2s ease;
@ -518,7 +518,7 @@ $vikunja-nav-selected-width: 0.4rem;
}
}
&.namespaces-lists {
&.namespaces-projects {
padding-top: math.div($navbar-padding, 2);
}
@ -530,13 +530,13 @@ $vikunja-nav-selected-width: 0.4rem;
.top-menu {
margin-top: math.div($navbar-padding, 2);
.menu-list {
.menu-project {
li {
font-weight: 500;
font-family: $vikunja-font;
}
.list-menu-link, li > a {
.project-menu-link, li > a {
padding-left: 2rem;
display: inline-block;
@ -548,12 +548,12 @@ $vikunja-nav-selected-width: 0.4rem;
}
}
.list-setting-spacer {
.project-setting-spacer {
width: 2.5rem;
flex-shrink: 0;
}
.namespaces-list.loader-container.is-loading {
.namespaces-project.loader-container.is-loading {
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem + 1.5rem});
}
</style>

View File

@ -1,63 +0,0 @@
<template>
<multiselect
v-model="selectedLists"
:search-results="foundLists"
:loading="listService.loading"
:multiple="true"
:placeholder="$t('list.search')"
label="title"
@search="findLists"
/>
</template>
<script setup lang="ts">
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
import Multiselect from '@/components/input/multiselect.vue'
import type {IList} from '@/modelTypes/IList'
import ListService from '@/services/list'
import {includesById} from '@/helpers/utils'
const props = defineProps({
modelValue: {
type: Array as PropType<IList[]>,
default: () => [],
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: IList[]): void
}>()
const lists = ref<IList[]>([])
watchEffect(() => {
lists.value = props.modelValue
})
const selectedLists = computed({
get() {
return lists.value
},
set: (value) => {
lists.value = value
emit('update:modelValue', value)
},
})
const listService = shallowReactive(new ListService())
const foundLists = ref<IList[]>([])
async function findLists(query: string) {
if (query === '') {
foundLists.value = []
return
}
const response = await listService.getAll({}, {s: query}) as IList[]
// Filter selected items from the results
foundLists.value = response.filter(({id}) => !includesById(lists.value, id))
}
</script>

View File

@ -0,0 +1,63 @@
<template>
<multiselect
v-model="selectedProjects"
:search-results="foundProjects"
:loading="projectService.loading"
:multiple="true"
:placeholder="$t('project.search')"
label="title"
@search="findProjects"
/>
</template>
<script setup lang="ts">
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
import Multiselect from '@/components/input/multiselect.vue'
import type {IProject} from '@/modelTypes/IProject'
import ProjectService from '@/services/project'
import {includesById} from '@/helpers/utils'
const props = defineProps({
modelValue: {
type: Array as PropType<IProject[]>,
default: () => [],
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: IProject[]): void
}>()
const projects = ref<IProject[]>([])
watchEffect(() => {
projects.value = props.modelValue
})
const selectedProjects = computed({
get() {
return projects.value
},
set: (value) => {
projects.value = value
emit('update:modelValue', value)
},
})
const projectService = shallowReactive(new ProjectService())
const foundProjects = ref<IProject[]>([])
async function findProjects(query: string) {
if (query === '') {
foundProjects.value = []
return
}
const response = await projectService.getAll({}, {s: query}) as IProject[]
// Filter selected items from the results
foundProjects.value = response.filter(({id}) => !includesById(projects.value, id))
}
</script>

View File

@ -286,11 +286,11 @@ function handleCheckboxClick(e: Event) {
console.debug('no index found')
return
}
const listPrefix = text.value.substring(index, index + 1)
const projectPrefix = text.value.substring(index, index + 1)
console.debug({index, listPrefix, checked, text: text.value})
console.debug({index, projectPrefix, checked, text: text.value})
text.value = replaceAt(text.value, index, `${listPrefix} ${checked ? '[x]' : '[ ]'} `)
text.value = replaceAt(text.value, index, `${projectPrefix} ${checked ? '[x]' : '[ ]'} `)
bubble()
renderPreview()
}

View File

@ -72,15 +72,15 @@ export function createEasyMDEConfig({ placeholder, uploadImage, imageUploadFunct
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M19.373 5.16357H5.62695C4.79102 5.16357 4.11133 5.84326 4.11133 6.6792V16.2095C4.11133 17.0464 4.79102 17.7261 5.62695 17.7261H6.8877V21.1245C6.8877 21.3667 7.0332 21.5854 7.25684 21.6782C7.33203 21.7095 7.41016 21.7241 7.4873 21.7241C7.64258 21.7241 7.7959 21.6636 7.91113 21.5493L11.748 17.7261H19.373C20.209 17.7261 20.8887 17.0464 20.8887 16.2095V6.6792C20.8887 5.84326 20.209 5.16357 19.373 5.16357ZM19.6895 16.2095C19.6895 16.3843 19.5469 16.5269 19.373 16.5269H11.5C11.3408 16.5269 11.1895 16.5894 11.0762 16.7017L8.08691 19.6802V17.1265C8.08691 16.7954 7.81836 16.5269 7.4873 16.5269H5.62695C5.45312 16.5269 5.31055 16.3843 5.31055 16.2095V6.6792C5.31055 6.50537 5.45312 6.36279 5.62695 6.36279H19.373C19.5469 6.36279 19.6895 6.50537 19.6895 6.6792V16.2095ZM10.3431 8.45264C9.46326 8.45264 8.75 9.16589 8.75 10.0458C8.75 10.9257 9.46326 11.639 10.3431 11.639C10.4775 11.639 10.6058 11.6173 10.7305 11.5861V11.6195C10.7305 12.1322 10.3135 12.5492 9.75586 12.5492C9.4248 12.5492 9.17871 12.8177 9.17871 13.1488C9.17871 13.4799 9.46973 13.7484 9.80078 13.7484C10.9746 13.7484 11.9297 12.7933 11.9297 11.6195V10.1176L11.9294 10.1165L11.9292 10.1155C11.9297 10.1049 11.9312 10.0946 11.9326 10.0843L11.9326 10.0843C11.9345 10.0716 11.9363 10.059 11.9363 10.0458C11.9363 9.16589 11.223 8.45264 10.3431 8.45264ZM13.0637 10.0458C13.0637 9.16589 13.7771 8.45264 14.657 8.45264C15.5369 8.45264 16.2501 9.16589 16.2501 10.0458C16.2501 10.0584 16.2484 10.0706 16.2466 10.0828C16.2452 10.0929 16.2437 10.103 16.2433 10.1134C16.2433 10.1149 16.2441 10.1161 16.2441 10.1176V11.6195C16.2441 12.7933 15.2891 13.7484 14.1152 13.7484C13.7842 13.7484 13.4922 13.4799 13.4922 13.1488C13.4922 12.8177 13.7383 12.5492 14.0693 12.5492C14.6279 12.5492 15.0449 12.1322 15.0449 11.6195V11.5858C14.9202 11.6173 14.7915 11.639 14.657 11.639C13.7771 11.639 13.0637 10.9257 13.0637 10.0458Z"/></svg>',
},
{
name: 'unordered-list',
action: EasyMDE.toggleUnorderedList,
title: i18n.global.t('input.editor.unorderedList'),
name: 'unordered-project',
action: EasyMDE.toggleUnorderedProject,
title: i18n.global.t('input.editor.unorderedProject'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M6.5281 3.7002H3.5281C3.1981 3.7002 2.9281 3.9702 2.9281 4.3002V7.3002C2.9281 7.6302 3.1981 7.9002 3.5281 7.9002H6.5281C6.8581 7.9002 7.1281 7.6302 7.1281 7.3002V4.3002C7.1281 3.9702 6.8581 3.7002 6.5281 3.7002ZM5.9281 6.7002H4.1281V4.9002H5.9281V6.7002ZM3.5281 9.90015H6.5281C6.8581 9.90015 7.1281 10.1701 7.1281 10.5001V13.5001C7.1281 13.8301 6.8581 14.1001 6.5281 14.1001H3.5281C3.1981 14.1001 2.9281 13.8301 2.9281 13.5001V10.5001C2.9281 10.1701 3.1981 9.90015 3.5281 9.90015ZM4.1281 12.9001H5.9281V11.1001H4.1281V12.9001ZM3.5281 16.1001H6.5281C6.8581 16.1001 7.1281 16.3701 7.1281 16.7001V19.7001C7.1281 20.0301 6.8581 20.3001 6.5281 20.3001H3.5281C3.1981 20.3001 2.9281 20.0301 2.9281 19.7001V16.7001C2.9281 16.3701 3.1981 16.1001 3.5281 16.1001ZM4.1281 19.1001H5.9281V17.3001H4.1281V19.1001ZM9.72817 6.4002H20.7282C21.0582 6.4002 21.3282 6.1302 21.3282 5.8002C21.3282 5.4702 21.0582 5.2002 20.7282 5.2002H9.72817C9.39817 5.2002 9.12817 5.4702 9.12817 5.8002C9.12817 6.1302 9.39817 6.4002 9.72817 6.4002ZM9.72817 11.4001H20.7282C21.0582 11.4001 21.3282 11.6701 21.3282 12.0001C21.3282 12.3301 21.0582 12.6001 20.7282 12.6001H9.72817C9.39817 12.6001 9.12817 12.3301 9.12817 12.0001C9.12817 11.6701 9.39817 11.4001 9.72817 11.4001ZM9.72817 17.6001H20.7282C21.0582 17.6001 21.3282 17.8701 21.3282 18.2001C21.3282 18.5301 21.0582 18.8001 20.7282 18.8001H9.72817C9.39817 18.8001 9.12817 18.5301 9.12817 18.2001C9.12817 17.8701 9.39817 17.6001 9.72817 17.6001Z"/></svg>',
},
{
name: 'ordered-list',
action: EasyMDE.toggleOrderedList,
title: i18n.global.t('input.editor.orderedList'),
name: 'ordered-project',
action: EasyMDE.toggleOrderedProject,
title: i18n.global.t('input.editor.orderedProject'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M4.19494 8.29994H5.99494C6.26494 8.29994 6.49494 8.07995 6.49494 7.79994C6.49494 7.51995 6.27494 7.29994 5.99494 7.29994H5.59494V3.79994C5.59494 3.62994 5.50494 3.46994 5.36494 3.37994C5.22494 3.28994 5.04494 3.26994 4.89494 3.33994L3.89494 3.76994C3.64494 3.87994 3.52494 4.17994 3.63494 4.42994C3.74494 4.67994 4.03494 4.79994 4.29494 4.68994L4.59494 4.55994V7.29994H4.19494C3.91494 7.29994 3.69494 7.51995 3.69494 7.79994C3.69494 8.07995 3.91494 8.29994 4.19494 8.29994ZM20.195 6.39995H9.19497C8.86497 6.39995 8.59497 6.12995 8.59497 5.79995C8.59497 5.46995 8.86497 5.19995 9.19497 5.19995H20.195C20.525 5.19995 20.795 5.46995 20.795 5.79995C20.795 6.12995 20.525 6.39995 20.195 6.39995ZM3.78486 14.36H6.37486C6.65486 14.36 6.87486 14.14 6.87486 13.86C6.87486 13.58 6.65486 13.36 6.37486 13.36H4.88486C5.00486 13.23 5.12486 13.09 5.23486 12.95C5.26626 12.9151 5.29645 12.8802 5.32626 12.8458L5.32629 12.8457C5.38192 12.7814 5.43627 12.7186 5.49486 12.66C5.73486 12.4 5.98486 12.12 6.17486 11.79C6.47486 11.25 6.41486 10.63 6.01486 10.17C5.57486 9.66 4.86486 9.5 4.24486 9.74C3.74486 9.95 3.39486 10.35 3.22486 10.91C3.14486 11.18 3.29486 11.46 3.56486 11.54C3.82486 11.61 4.10486 11.46 4.18486 11.2C4.29486 10.85 4.48486 10.73 4.62486 10.67C4.88486 10.57 5.13486 10.68 5.26486 10.82C5.38486 10.96 5.40486 11.12 5.30486 11.29C5.17595 11.5202 4.99618 11.7165 4.80458 11.9257L4.75486 11.98C4.67298 12.0801 4.58283 12.1801 4.49946 12.2727L4.49945 12.2727L4.47486 12.3C4.12486 12.72 3.76486 13.13 3.40486 13.53C3.27486 13.68 3.23486 13.9 3.32486 14.07C3.41486 14.24 3.58486 14.36 3.78486 14.36ZM3.68486 20.3699C4.04486 20.5899 4.46486 20.6999 4.87486 20.6999C5.13486 20.6999 5.38486 20.6499 5.61486 20.5499C6.31486 20.2799 6.73486 19.5599 6.60486 18.8799C6.53486 18.5499 6.35486 18.2899 6.12486 18.0899C6.32486 17.8999 6.45486 17.6499 6.50486 17.3799C6.57486 17.0099 6.49486 16.6299 6.27486 16.3099C5.85486 15.6899 5.07486 15.5199 4.10486 15.8299C3.83486 15.9199 3.69486 16.1999 3.77486 16.4599C3.86486 16.7299 4.14486 16.8699 4.40486 16.7899C4.70486 16.6999 5.24486 16.5799 5.45486 16.8899C5.51486 16.9899 5.54486 17.0999 5.52486 17.1999C5.51486 17.2699 5.47486 17.3599 5.36486 17.4299C5.26486 17.4999 5.12486 17.5399 4.95486 17.5799L4.77486 17.6299C4.54486 17.6999 4.40486 17.9099 4.41486 18.1499C4.42486 18.3899 4.61486 18.5799 4.84486 18.6099C5.20486 18.6599 5.58486 18.8299 5.63486 19.0799C5.67486 19.2999 5.46486 19.5499 5.25486 19.6299C4.94486 19.7599 4.52486 19.7099 4.21486 19.5199C3.97486 19.3699 3.67486 19.4399 3.52486 19.6799C3.37486 19.9199 3.44486 20.2299 3.68486 20.3699ZM20.195 18.7999H9.19497C8.86497 18.7999 8.59497 18.5299 8.59497 18.1999C8.59497 17.8699 8.86497 17.5999 9.19497 17.5999H20.195C20.525 17.5999 20.795 17.8699 20.795 18.1999C20.795 18.5299 20.525 18.7999 20.195 18.7999ZM9.19497 12.5999H20.195C20.525 12.5999 20.795 12.3299 20.795 11.9999C20.795 11.6699 20.525 11.3999 20.195 11.3999H9.19497C8.86497 11.3999 8.59497 11.6699 8.59497 11.9999C8.59497 12.3299 8.86497 12.5999 9.19497 12.5999Z"/></svg>',
},
'|',

View File

@ -1,199 +0,0 @@
<template>
<div
:class="{ 'is-loading': listService.loading, 'is-archived': currentList.isArchived}"
class="loader-container"
>
<div class="switch-view-container">
<div class="switch-view">
<BaseButton
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.list.switchToListView')"
class="switch-view-button"
:class="{'is-active': viewName === 'list'}"
:to="{ name: 'list.list', params: { listId } }"
>
{{ $t('list.list.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g g'"
:title="$t('keyboardShortcuts.list.switchToGanttView')"
class="switch-view-button"
:class="{'is-active': viewName === 'gantt'}"
:to="{ name: 'list.gantt', params: { listId } }"
>
{{ $t('list.gantt.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g t'"
:title="$t('keyboardShortcuts.list.switchToTableView')"
class="switch-view-button"
:class="{'is-active': viewName === 'table'}"
:to="{ name: 'list.table', params: { listId } }"
>
{{ $t('list.table.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g k'"
:title="$t('keyboardShortcuts.list.switchToKanbanView')"
class="switch-view-button"
:class="{'is-active': viewName === 'kanban'}"
:to="{ name: 'list.kanban', params: { listId } }"
>
{{ $t('list.kanban.title') }}
</BaseButton>
</div>
<slot name="header" />
</div>
<transition name="fade">
<Message variant="warning" v-if="currentList.isArchived" class="mb-4">
{{ $t('list.archived') }}
</Message>
</transition>
<slot v-if="loadedListId"/>
</div>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {useRoute} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
import Message from '@/components/misc/message.vue'
import ListModel from '@/models/list'
import ListService from '@/services/list'
import {getListTitle} from '@/helpers/getListTitle'
import {saveListToHistory} from '@/modules/listHistory'
import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
const props = defineProps({
listId: {
type: Number,
required: true,
},
viewName: {
type: String,
required: true,
},
})
const route = useRoute()
const baseStore = useBaseStore()
const listStore = useListStore()
const listService = ref(new ListService())
const loadedListId = ref(0)
const currentList = computed(() => {
return typeof baseStore.currentList === 'undefined' ? {
id: 0,
title: '',
isArchived: false,
maxRight: null,
} : baseStore.currentList
})
useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '')
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
// This resulted in loading and setting the list multiple times, even when navigating away from it.
// This caused wired bugs where the list background would be set on the home page but only right after setting a new
// list background and then navigating to home. It also highlighted the list in the menu and didn't allow changing any
// of it, most likely due to the rights not being properly populated.
watch(
() => props.listId,
// loadList
async (listIdToLoad: number) => {
const listData = {id: listIdToLoad}
saveListToHistory(listData)
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
// the currently loaded list has the right set.
if (
(
listIdToLoad === loadedListId.value ||
typeof listIdToLoad === 'undefined' ||
listIdToLoad === currentList.value.id
)
&& typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null
) {
loadedListId.value = props.listId
return
}
console.debug(`Loading list, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value)
// Set the current list to the one we're about to load so that the title is already shown at the top
loadedListId.value = 0
const listFromStore = listStore.getListById(listData.id)
if (listFromStore !== null) {
baseStore.setBackground(null)
baseStore.setBlurHash(null)
baseStore.handleSetCurrentList({list: listFromStore})
}
// We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux.
const list = new ListModel(listData)
try {
const loadedList = await listService.value.get(list)
await baseStore.handleSetCurrentList({list: loadedList})
} finally {
loadedListId.value = props.listId
}
},
{immediate: true},
)
</script>
<style lang="scss" scoped>
.switch-view-container {
@media screen and (max-width: $tablet) {
display: flex;
justify-content: center;
flex-direction: column;
}
}
.switch-view {
background: var(--white);
display: inline-flex;
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
height: $switch-view-height;
margin: 0 auto 1rem;
padding: .5rem;
}
.switch-view-button {
padding: .25rem .5rem;
display: block;
border-radius: $radius;
transition: all 100ms;
&:not(:last-child) {
margin-right: .5rem;
}
&:hover {
color: var(--switch-view-color);
background: var(--primary);
}
&.is-active {
color: var(--switch-view-color);
background: var(--primary);
font-weight: bold;
box-shadow: var(--shadow-xs);
}
}
// FIXME: this should be in notification and set via a prop
.is-archived .notification.is-warning {
margin-bottom: 1rem;
}
</style>

View File

@ -16,7 +16,7 @@
}}
</message>
<dl class="shortcut-list">
<dl class="shortcut-project">
<template v-for="(sc, si) in s.shortcuts" :key="si">
<dt class="shortcut-title">{{ $t(sc.title) }}</dt>
<shortcut
@ -58,7 +58,7 @@ function close() {
padding: .75rem;
}
.shortcut-list {
.shortcut-project {
display: grid;
grid-template-columns: 2fr 1fr;
}

View File

@ -61,8 +61,8 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
],
},
{
title: 'list.kanban.title',
available: (route) => route.name === 'list.kanban',
title: 'project.kanban.title',
available: (route) => route.name === 'project.kanban',
shortcuts: [
{
title: 'keyboardShortcuts.task.done',
@ -71,26 +71,26 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
],
},
{
title: 'keyboardShortcuts.list.title',
available: (route) => (route.name as string)?.startsWith('list.'),
title: 'keyboardShortcuts.project.title',
available: (route) => (route.name as string)?.startsWith('project.'),
shortcuts: [
{
title: 'keyboardShortcuts.list.switchToListView',
title: 'keyboardShortcuts.project.switchToProjectView',
keys: ['g', 'l'],
combination: 'then',
},
{
title: 'keyboardShortcuts.list.switchToGanttView',
title: 'keyboardShortcuts.project.switchToGanttView',
keys: ['g', 'g'],
combination: 'then',
},
{
title: 'keyboardShortcuts.list.switchToTableView',
title: 'keyboardShortcuts.project.switchToTableView',
keys: ['g', 't'],
combination: 'then',
},
{
title: 'keyboardShortcuts.list.switchToKanbanView',
title: 'keyboardShortcuts.project.switchToKanbanView',
keys: ['g', 'k'],
combination: 'then',
},

View File

@ -17,7 +17,7 @@
class="pagination-next">
{{ $t('misc.next') }}
</router-link>
<ul class="pagination-list">
<ul class="pagination-project">
<li :key="`page-${i}`" v-for="(p, i) in pages">
<span class="pagination-ellipsis" v-if="p.isEllipsis">&hellip;</span>
<router-link

View File

@ -73,14 +73,14 @@ const {t} = useI18n({useScope: 'global'})
const tooltipText = computed(() => {
if (disabled.value) {
if (props.entity === 'list' && subscriptionEntity.value === 'namespace') {
return t('task.subscription.subscribedListThroughParentNamespace')
if (props.entity === 'project' && subscriptionEntity.value === 'namespace') {
return t('task.subscription.subscribedProjectThroughParentNamespace')
}
if (props.entity === 'task' && subscriptionEntity.value === 'namespace') {
return t('task.subscription.subscribedTaskThroughParentNamespace')
}
if (props.entity === 'task' && subscriptionEntity.value === 'list') {
return t('task.subscription.subscribedTaskThroughParentList')
if (props.entity === 'task' && subscriptionEntity.value === 'project') {
return t('task.subscription.subscribedTaskThroughParentProject')
}
return ''
@ -91,10 +91,10 @@ const tooltipText = computed(() => {
return props.modelValue !== null ?
t('task.subscription.subscribedNamespace') :
t('task.subscription.notSubscribedNamespace')
case 'list':
case 'project':
return props.modelValue !== null ?
t('task.subscription.subscribedList') :
t('task.subscription.notSubscribedList')
t('task.subscription.subscribedProject') :
t('task.subscription.notSubscribedProject')
case 'task':
return props.modelValue !== null ?
t('task.subscription.subscribedTask') :
@ -133,8 +133,8 @@ async function subscribe() {
case 'namespace':
message = t('task.subscription.subscribeSuccessNamespace')
break
case 'list':
message = t('task.subscription.subscribeSuccessList')
case 'project':
message = t('task.subscription.subscribeSuccessProject')
break
case 'task':
message = t('task.subscription.subscribeSuccessTask')
@ -156,8 +156,8 @@ async function unsubscribe() {
case 'namespace':
message = t('task.subscription.unsubscribeSuccessNamespace')
break
case 'list':
message = t('task.subscription.unsubscribeSuccessList')
case 'project':
message = t('task.subscription.unsubscribeSuccessProject')
break
case 'task':
message = t('task.subscription.unsubscribeSuccessTask')

View File

@ -22,10 +22,10 @@
{{ $t('menu.share') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'list.create', params: { namespaceId: namespace.id } }"
:to="{ name: 'project.create', params: { namespaceId: namespace.id } }"
icon="plus"
>
{{ $t('menu.newList') }}
{{ $t('menu.newProject') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"

View File

@ -8,7 +8,7 @@
</div>
<transition name="fade">
<div class="notifications-list" v-if="showNotifications" ref="popup">
<div class="notifications-project" v-if="showNotifications" ref="popup">
<span class="head">{{ $t('notification.title') }}</span>
<div
v-for="(n, index) in notifications"
@ -116,9 +116,9 @@ function to(n, index) {
case names.TASK_DELETED:
// Nothing
break
case names.LIST_CREATED:
case names.PROJECT_CREATED:
to.name = 'task.index'
to.params.listId = n.notification.list.id
to.params.projectId = n.notification.project.id
break
case names.TEAM_MEMBER_ADDED:
to.name = 'teams.edit'
@ -154,7 +154,7 @@ function to(n, index) {
border: 2px solid var(--white);
}
.notifications-list {
.notifications-project {
position: fixed;
right: 1rem;
margin-top: 1rem;

View File

@ -0,0 +1,199 @@
<template>
<div
:class="{ 'is-loading': projectService.loading, 'is-archived': currentProject.isArchived}"
class="loader-container"
>
<div class="switch-view-container">
<div class="switch-view">
<BaseButton
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.project.switchToProjectView')"
class="switch-view-button"
:class="{'is-active': viewName === 'project'}"
:to="{ name: 'project.project', params: { projectId } }"
>
{{ $t('project.project.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g g'"
:title="$t('keyboardShortcuts.project.switchToGanttView')"
class="switch-view-button"
:class="{'is-active': viewName === 'gantt'}"
:to="{ name: 'project.gantt', params: { projectId } }"
>
{{ $t('project.gantt.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g t'"
:title="$t('keyboardShortcuts.project.switchToTableView')"
class="switch-view-button"
:class="{'is-active': viewName === 'table'}"
:to="{ name: 'project.table', params: { projectId } }"
>
{{ $t('project.table.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g k'"
:title="$t('keyboardShortcuts.project.switchToKanbanView')"
class="switch-view-button"
:class="{'is-active': viewName === 'kanban'}"
:to="{ name: 'project.kanban', params: { projectId } }"
>
{{ $t('project.kanban.title') }}
</BaseButton>
</div>
<slot name="header" />
</div>
<transition name="fade">
<Message variant="warning" v-if="currentProject.isArchived" class="mb-4">
{{ $t('project.archived') }}
</Message>
</transition>
<slot v-if="loadedProjectId"/>
</div>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {useRoute} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
import Message from '@/components/misc/message.vue'
import ProjectModel from '@/models/project'
import ProjectService from '@/services/project'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import {saveProjectToHistory} from '@/modules/projectHistory'
import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
const props = defineProps({
projectId: {
type: Number,
required: true,
},
viewName: {
type: String,
required: true,
},
})
const route = useRoute()
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const projectService = ref(new ProjectService())
const loadedProjectId = ref(0)
const currentProject = computed(() => {
return typeof baseStore.currentProject === 'undefined' ? {
id: 0,
title: '',
isArchived: false,
maxRight: null,
} : baseStore.currentProject
})
useTitle(() => currentProject.value.id ? getProjectTitle(currentProject.value) : '')
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
// This resulted in loading and setting the project multiple times, even when navigating away from it.
// This caused wired bugs where the project background would be set on the home page but only right after setting a new
// project background and then navigating to home. It also highlighted the project in the menu and didn't allow changing any
// of it, most likely due to the rights not being properly populated.
watch(
() => props.projectId,
// loadProject
async (projectIdToLoad: number) => {
const projectData = {id: projectIdToLoad}
saveProjectToHistory(projectData)
// Don't load the project if we either already loaded it or aren't dealing with a project at all currently and
// the currently loaded project has the right set.
if (
(
projectIdToLoad === loadedProjectId.value ||
typeof projectIdToLoad === 'undefined' ||
projectIdToLoad === currentProject.value.id
)
&& typeof currentProject.value !== 'undefined' && currentProject.value.maxRight !== null
) {
loadedProjectId.value = props.projectId
return
}
console.debug(`Loading project, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedProjectId = ${loadedProjectId.value}, currentProject = `, currentProject.value)
// Set the current project to the one we're about to load so that the title is already shown at the top
loadedProjectId.value = 0
const projectFromStore = projectStore.getProjectById(projectData.id)
if (projectFromStore !== null) {
baseStore.setBackground(null)
baseStore.setBlurHash(null)
baseStore.handleSetCurrentProject({project: projectFromStore})
}
// We create an extra project object instead of creating it in project.value because that would trigger a ui update which would result in bad ux.
const project = new ProjectModel(projectData)
try {
const loadedProject = await projectService.value.get(project)
await baseStore.handleSetCurrentProject({project: loadedProject})
} finally {
loadedProjectId.value = props.projectId
}
},
{immediate: true},
)
</script>
<style lang="scss" scoped>
.switch-view-container {
@media screen and (max-width: $tablet) {
display: flex;
justify-content: center;
flex-direction: column;
}
}
.switch-view {
background: var(--white);
display: inline-flex;
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
height: $switch-view-height;
margin: 0 auto 1rem;
padding: .5rem;
}
.switch-view-button {
padding: .25rem .5rem;
display: block;
border-radius: $radius;
transition: all 100ms;
&:not(:last-child) {
margin-right: .5rem;
}
&:hover {
color: var(--switch-view-color);
background: var(--primary);
}
&.is-active {
color: var(--switch-view-color);
background: var(--primary);
font-weight: bold;
box-shadow: var(--shadow-xs);
}
}
// FIXME: this should be in notification and set via a prop
.is-archived .notification.is-warning {
margin-bottom: 1rem;
}
</style>

View File

@ -32,7 +32,7 @@
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import Filters from '@/components/list/partials/filters.vue'
import Filters from '@/components/project/partials/filters.vue'
import {getDefaultParams} from '@/composables/useTaskList'

View File

@ -20,7 +20,7 @@
{{ $t('filters.attributes.showDoneTasks') }}
</fancycheckbox>
<fancycheckbox
v-if="!['list.kanban', 'list.table'].includes($route.name as string)"
v-if="!['project.kanban', 'project.table'].includes($route.name as string)"
v-model="sortAlphabetically"
@update:model-value="change()"
>
@ -154,14 +154,14 @@
</div>
<template
v-if="['filters.create', 'list.edit', 'filter.settings.edit'].includes($route.name as string)">
v-if="['filters.create', 'project.edit', 'filter.settings.edit'].includes($route.name as string)">
<div class="field">
<label class="label">{{ $t('list.lists') }}</label>
<label class="label">{{ $t('project.projects') }}</label>
<div class="control">
<SelectList
v-model="entities.lists"
@select="changeMultiselectFilter('lists', 'list_id')"
@remove="changeMultiselectFilter('lists', 'list_id')"
<SelectProject
v-model="entities.projects"
@select="changeMultiselectFilter('projects', 'project_id')"
@remove="changeMultiselectFilter('projects', 'project_id')"
/>
</div>
</div>
@ -190,7 +190,7 @@ import {camelCase} from 'camel-case'
import type {ILabel} from '@/modelTypes/ILabel'
import type {IUser} from '@/modelTypes/IUser'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IList} from '@/modelTypes/IList'
import type {IProject} from '@/modelTypes/IProject'
import {useLabelStore} from '@/stores/labels'
@ -200,7 +200,7 @@ import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue
import EditLabels from '@/components/tasks/partials/editLabels.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import SelectUser from '@/components/input/SelectUser.vue'
import SelectList from '@/components/input/SelectList.vue'
import SelectProject from '@/components/input/SelectProject.vue'
import SelectNamespace from '@/components/input/SelectNamespace.vue'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
@ -208,13 +208,13 @@ import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
import {objectToSnakeCase} from '@/helpers/case'
import UserService from '@/services/user'
import ListService from '@/services/list'
import ProjectService from '@/services/project'
import NamespaceService from '@/services/namespace'
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
import {getDefaultParams} from '@/composables/useTaskList'
// FIXME: merge with DEFAULT_PARAMS in taskList.js
// FIXME: merge with DEFAULT_PARAMS in taskProject.js
const DEFAULT_PARAMS = {
sort_by: [],
order_by: [],
@ -239,7 +239,7 @@ const DEFAULT_FILTERS = {
reminders: '',
assignees: '',
labels: '',
list_id: '',
project_id: '',
namespace: '',
} as const
@ -264,23 +264,23 @@ const filters = ref({...DEFAULT_FILTERS})
const services = {
users: shallowReactive(new UserService()),
lists: shallowReactive(new ListService()),
projects: shallowReactive(new ProjectService()),
namespace: shallowReactive(new NamespaceService()),
}
interface Entities {
users: IUser[]
labels: ILabel[]
lists: IList[]
projects: IProject[]
namespace: INamespace[]
}
type EntityType = 'users' | 'labels' | 'lists' | 'namespace'
type EntityType = 'users' | 'labels' | 'projects' | 'namespace'
const entities: Entities = reactive({
users: [],
labels: [],
lists: [],
projects: [],
namespace: [],
})
@ -327,7 +327,7 @@ function prepareFilters() {
prepareSingleValue('percent_done', 'percentDone', 'usePercentDone', true)
prepareDate('reminders')
prepareRelatedObjectFilter('users', 'assignees')
prepareRelatedObjectFilter('lists', 'list_id')
prepareRelatedObjectFilter('projects', 'project_id')
prepareRelatedObjectFilter('namespace')
prepareSingleValue('labels')

View File

@ -1,36 +1,36 @@
<template>
<router-link
:class="{
'has-light-text': !colorIsDark(list.hexColor) || background !== null,
'has-light-text': !colorIsDark(project.hexColor) || background !== null,
'has-background': blurHashUrl !== '' || background !== null,
}"
:style="{
'background-color': list.hexColor,
'background-color': project.hexColor,
'background-image': blurHashUrl !== null ? `url(${blurHashUrl})` : false,
}"
:to="{ name: 'list.index', params: { listId: list.id} }"
class="list-card"
v-if="list !== null && (showArchived ? true : !list.isArchived)"
:to="{ name: 'project.index', params: { projectId: project.id} }"
class="project-card"
v-if="project !== null && (showArchived ? true : !project.isArchived)"
>
<div
class="list-background background-fade-in"
class="project-background background-fade-in"
:class="{'is-visible': background}"
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
/>
<div class="list-content">
<span class="is-archived" v-if="list.isArchived">
<div class="project-content">
<span class="is-archived" v-if="project.isArchived">
{{ $t('namespace.archived') }}
</span>
<BaseButton
v-else
:class="{'is-favorite': list.isFavorite}"
@click.stop="listStore.toggleListFavorite(list)"
:class="{'is-favorite': project.isFavorite}"
@click.stop="projectStore.toggleProjectFavorite(project)"
class="favorite"
>
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']"/>
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']"/>
</BaseButton>
<div class="title">{{ list.title }}</div>
<div class="title">{{ project.title }}</div>
</div>
</router-link>
</template>
@ -38,22 +38,22 @@
<script lang="ts" setup>
import {type PropType, ref, watch} from 'vue'
import ListService from '@/services/list'
import ProjectService from '@/services/project'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import BaseButton from '@/components/base/BaseButton.vue'
import type {IList} from '@/modelTypes/IList'
import {useListStore} from '@/stores/lists'
import type {IProject} from '@/modelTypes/IProject'
import {useProjectStore} from '@/stores/projects'
const background = ref<string | null>(null)
const backgroundLoading = ref(false)
const blurHashUrl = ref('')
const props = defineProps({
list: {
type: Object as PropType<IList>,
project: {
type: Object as PropType<IProject>,
required: true,
},
showArchived: {
@ -62,38 +62,38 @@ const props = defineProps({
},
})
watch(props.list, loadBackground, {immediate: true})
watch(props.project, loadBackground, {immediate: true})
async function loadBackground() {
if (props.list === null || !props.list.backgroundInformation || backgroundLoading.value) {
if (props.project === null || !props.project.backgroundInformation || backgroundLoading.value) {
return
}
const blurHash = await getBlobFromBlurHash(props.list.backgroundBlurHash)
const blurHash = await getBlobFromBlurHash(props.project.backgroundBlurHash)
if (blurHash) {
blurHashUrl.value = window.URL.createObjectURL(blurHash)
}
backgroundLoading.value = true
const listService = new ListService()
const projectService = new ProjectService()
try {
background.value = await listService.background(props.list)
background.value = await projectService.background(props.project)
} finally {
backgroundLoading.value = false
}
}
const listStore = useListStore()
const projectStore = useProjectStore()
</script>
<style lang="scss" scoped>
.list-card {
.project-card {
cursor: pointer;
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
height: $list-height;
width: calc((100% - #{($projects-per-row - 1) * 1rem}) / #{$projects-per-row});
height: $project-height;
background: var(--white);
margin: 0 $list-spacing $list-spacing 0;
margin: 0 $project-spacing $project-spacing 0;
border-radius: $radius;
box-shadow: var(--shadow-sm);
transition: box-shadow $transition;
@ -105,7 +105,7 @@ const listStore = useListStore()
}
&.has-background,
.list-background {
.project-background {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
@ -116,7 +116,7 @@ const listStore = useListStore()
color: var(--white);
}
.list-background {
.project-background {
position: absolute;
top: 0;
right: 0;
@ -135,42 +135,42 @@ const listStore = useListStore()
}
@media screen and (min-width: $widescreen) {
&:nth-child(#{$lists-per-row}n) {
&:nth-child(#{$projects-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $widescreen) and (min-width: $tablet) {
$lists-per-row: 3;
$projects-per-row: 3;
& {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
width: calc((100% - #{($projects-per-row - 1) * 1rem}) / #{$projects-per-row});
}
&:nth-child(#{$lists-per-row}n) {
&:nth-child(#{$projects-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $tablet) {
$lists-per-row: 2;
$projects-per-row: 2;
& {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
width: calc((100% - #{($projects-per-row - 1) * 1rem}) / #{$projects-per-row});
}
&:nth-child(#{$lists-per-row}n) {
&:nth-child(#{$projects-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $mobile) {
$lists-per-row: 1;
$projects-per-row: 1;
& {
width: 100%;
margin-right: 0;
}
}
.list-content {
.project-content {
display: flex;
align-content: space-between;
flex-wrap: wrap;

View File

@ -1,23 +1,23 @@
<template>
<dropdown>
<template v-if="isSavedFilter(list)">
<template v-if="isSavedFilter(project)">
<dropdown-item
:to="{ name: 'filter.settings.edit', params: { listId: list.id } }"
:to="{ name: 'filter.settings.edit', params: { projectId: project.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'filter.settings.delete', params: { listId: list.id } }"
:to="{ name: 'filter.settings.delete', params: { projectId: project.id } }"
icon="trash-alt"
>
{{ $t('misc.delete') }}
</dropdown-item>
</template>
<template v-else-if="list.isArchived">
<template v-else-if="project.isArchived">
<dropdown-item
:to="{ name: 'list.settings.archive', params: { listId: list.id } }"
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
icon="archive"
>
{{ $t('menu.unarchive') }}
@ -25,32 +25,32 @@
</template>
<template v-else>
<dropdown-item
:to="{ name: 'list.settings.edit', params: { listId: list.id } }"
:to="{ name: 'project.settings.edit', params: { projectId: project.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
v-if="backgroundsEnabled"
:to="{ name: 'list.settings.background', params: { listId: list.id } }"
:to="{ name: 'project.settings.background', params: { projectId: project.id } }"
icon="image"
>
{{ $t('menu.setBackground') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'list.settings.share', params: { listId: list.id } }"
:to="{ name: 'project.settings.share', params: { projectId: project.id } }"
icon="share-alt"
>
{{ $t('menu.share') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'list.settings.duplicate', params: { listId: list.id } }"
:to="{ name: 'project.settings.duplicate', params: { projectId: project.id } }"
icon="paste"
>
{{ $t('menu.duplicate') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'list.settings.archive', params: { listId: list.id } }"
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
icon="archive"
>
{{ $t('menu.archive') }}
@ -58,14 +58,14 @@
<Subscription
class="has-no-shadow"
:is-button="false"
entity="list"
:entity-id="list.id"
:model-value="list.subscription"
entity="project"
:entity-id="project.id"
:model-value="project.subscription"
@update:model-value="setSubscriptionInStore"
type="dropdown"
/>
<dropdown-item
:to="{ name: 'list.settings.delete', params: { listId: list.id } }"
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
icon="trash-alt"
class="has-text-danger"
>
@ -81,26 +81,26 @@ import {ref, computed, watchEffect, type PropType} from 'vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import Subscription from '@/components/misc/subscription.vue'
import type {IList} from '@/modelTypes/IList'
import type {IProject} from '@/modelTypes/IProject'
import type {ISubscription} from '@/modelTypes/ISubscription'
import {isSavedFilter} from '@/services/savedFilter'
import {useConfigStore} from '@/stores/config'
import {useListStore} from '@/stores/lists'
import {useProjectStore} from '@/stores/projects'
import {useNamespaceStore} from '@/stores/namespaces'
const props = defineProps({
list: {
type: Object as PropType<IList>,
project: {
type: Object as PropType<IProject>,
required: true,
},
})
const listStore = useListStore()
const projectStore = useProjectStore()
const namespaceStore = useNamespaceStore()
const subscription = ref<ISubscription | null>(null)
watchEffect(() => {
subscription.value = props.list.subscription ?? null
subscription.value = props.project.subscription ?? null
})
const configStore = useConfigStore()
@ -108,11 +108,11 @@ const backgroundsEnabled = computed(() => configStore.enabledBackgroundProviders
function setSubscriptionInStore(sub: ISubscription) {
subscription.value = sub
const updatedList = {
...props.list,
const updatedProject = {
...props.project,
subscription: sub,
}
listStore.setList(updatedList)
namespaceStore.setListInNamespaceById(updatedList)
projectStore.setProject(updatedProject)
namespaceStore.setProjectInNamespaceById(updatedProject)
}
</script>

View File

@ -63,18 +63,18 @@ import TeamService from '@/services/team'
import NamespaceModel from '@/models/namespace'
import TeamModel from '@/models/team'
import ListModel from '@/models/list'
import ProjectModel from '@/models/project'
import BaseButton from '@/components/base/BaseButton.vue'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
import {useProjectStore} from '@/stores/projects'
import {useNamespaceStore} from '@/stores/namespaces'
import {useLabelStore} from '@/stores/labels'
import {useTaskStore} from '@/stores/tasks'
import {getHistory} from '@/modules/listHistory'
import {getHistory} from '@/modules/projectHistory'
import {parseTaskText, PrefixMode, PREFIXES} from '@/modules/parseTaskText'
import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
import {success} from '@/message'
@ -82,13 +82,13 @@ import {success} from '@/message'
import type {ITeam} from '@/modelTypes/ITeam'
import type {ITask} from '@/modelTypes/ITask'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IList} from '@/modelTypes/IList'
import type {IProject} from '@/modelTypes/IProject'
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
const baseStore = useBaseStore()
const listStore = useListStore()
const projectStore = useProjectStore()
const namespaceStore = useNamespaceStore()
const labelStore = useLabelStore()
const taskStore = useTaskStore()
@ -98,13 +98,13 @@ type DoAction<Type = any> = { type: ACTION_TYPE } & Type
enum ACTION_TYPE {
CMD = 'cmd',
TASK = 'task',
LIST = 'list',
PROJECT = 'project',
TEAM = 'team',
}
enum COMMAND_TYPE {
NEW_TASK = 'newTask',
NEW_LIST = 'newList',
NEW_PROJECT = 'newProject',
NEW_NAMESPACE = 'newNamespace',
NEW_TEAM = 'newTeam',
}
@ -112,7 +112,7 @@ enum COMMAND_TYPE {
enum SEARCH_MODE {
ALL = 'all',
TASKS = 'tasks',
LISTS = 'lists',
PROJECTS = 'projects',
TEAMS = 'teams',
}
@ -137,26 +137,26 @@ function closeQuickActions() {
baseStore.setQuickActionsActive(false)
}
const foundLists = computed(() => {
const { list } = parsedQuery.value
const foundProjects = computed(() => {
const { project } = parsedQuery.value
if (
searchMode.value === SEARCH_MODE.ALL ||
searchMode.value === SEARCH_MODE.LISTS ||
list === null
searchMode.value === SEARCH_MODE.PROJECTS ||
project === null
) {
return []
}
const ncache: { [id: ListModel['id']]: INamespace } = {}
const ncache: { [id: ProjectModel['id']]: INamespace } = {}
const history = getHistory()
const allLists = [
const allProjects = [
...new Set([
...history.map((l) => listStore.getListById(l.id)),
...listStore.searchList(list),
...history.map((l) => projectStore.getProjectById(l.id)),
...projectStore.searchProject(project),
]),
]
return allLists.filter((l) => {
return allProjects.filter((l) => {
if (typeof l === 'undefined' || l === null) {
return false
}
@ -191,9 +191,9 @@ const results = computed<Result[]>(() => {
items: foundTasks.value,
},
{
type: ACTION_TYPE.LIST,
title: t('quickActions.lists'),
items: foundLists.value,
type: ACTION_TYPE.PROJECT,
title: t('quickActions.projects'),
items: foundProjects.value,
},
{
type: ACTION_TYPE.TEAM,
@ -206,7 +206,7 @@ const results = computed<Result[]>(() => {
const loading = computed(() =>
taskService.loading ||
namespaceStore.isLoading ||
listStore.isLoading ||
projectStore.isLoading ||
teamService.loading,
)
@ -224,11 +224,11 @@ const commands = computed<{ [key in COMMAND_TYPE]: Command }>(() => ({
placeholder: t('quickActions.newTask'),
action: newTask,
},
newList: {
type: COMMAND_TYPE.NEW_LIST,
title: t('quickActions.cmds.newList'),
placeholder: t('quickActions.newList'),
action: newList,
newProject: {
type: COMMAND_TYPE.NEW_PROJECT,
title: t('quickActions.cmds.newProject'),
placeholder: t('quickActions.newProject'),
action: newProject,
},
newNamespace: {
type: COMMAND_TYPE.NEW_NAMESPACE,
@ -246,24 +246,24 @@ const commands = computed<{ [key in COMMAND_TYPE]: Command }>(() => ({
const placeholder = computed(() => selectedCmd.value?.placeholder || t('quickActions.placeholder'))
const currentList = computed(() => Object.keys(baseStore.currentList).length === 0
const currentProject = computed(() => Object.keys(baseStore.currentProject).length === 0
? null
: baseStore.currentList,
: baseStore.currentProject,
)
const hintText = computed(() => {
let namespace
if (selectedCmd.value !== null && currentList.value !== null) {
if (selectedCmd.value !== null && currentProject.value !== null) {
switch (selectedCmd.value.type) {
case COMMAND_TYPE.NEW_TASK:
return t('quickActions.createTask', {
title: currentList.value.title,
title: currentProject.value.title,
})
case COMMAND_TYPE.NEW_LIST:
case COMMAND_TYPE.NEW_PROJECT:
namespace = namespaceStore.getNamespaceById(
currentList.value.namespaceId,
currentProject.value.namespaceId,
)
return t('quickActions.createList', {
return t('quickActions.createProject', {
title: namespace?.title,
})
}
@ -275,8 +275,8 @@ const hintText = computed(() => {
const availableCmds = computed(() => {
const cmds = []
if (currentList.value !== null) {
cmds.push(commands.value.newTask, commands.value.newList)
if (currentProject.value !== null) {
cmds.push(commands.value.newTask, commands.value.newProject)
}
cmds.push(commands.value.newNamespace, commands.value.newTeam)
return cmds
@ -288,21 +288,21 @@ const searchMode = computed(() => {
if (query.value === '') {
return SEARCH_MODE.ALL
}
const { text, list, labels, assignees } = parsedQuery.value
const { text, project, labels, assignees } = parsedQuery.value
if (assignees.length === 0 && text !== '') {
return SEARCH_MODE.TASKS
}
if (
assignees.length === 0 &&
list !== null &&
project !== null &&
text === '' &&
labels.length === 0
) {
return SEARCH_MODE.LISTS
return SEARCH_MODE.PROJECTS
}
if (
assignees.length > 0 &&
list === null &&
project === null &&
text === '' &&
labels.length === 0
) {
@ -356,7 +356,7 @@ function searchTasks() {
taskSearchTimeout.value = null
}
const { text, list: listName, labels } = parsedQuery.value
const { text, project: projectName, labels } = parsedQuery.value
const filters: Filter[] = []
@ -373,10 +373,10 @@ function searchTasks() {
})
}
if (listName !== null) {
const list = listStore.findListByExactname(listName)
if (list !== null) {
addFilter('listId', list.id, 'equals')
if (projectName !== null) {
const project = projectStore.findProjectByExactname(projectName)
if (project !== null) {
addFilter('projectId', project.id, 'equals')
}
}
@ -396,9 +396,9 @@ function searchTasks() {
const r = await taskService.getAll({}, params) as DoAction<ITask>[]
foundTasks.value = r.map((t) => {
t.type = ACTION_TYPE.TASK
const list = listStore.getListById(t.listId)
if (list !== null) {
t.title = `${t.title} (${list.title})`
const project = projectStore.getProjectById(t.projectId)
if (project !== null) {
t.title = `${t.title} (${project.title})`
}
return t
})
@ -444,11 +444,11 @@ const searchInput = ref<HTMLElement | null>(null)
async function doAction(type: ACTION_TYPE, item: DoAction) {
switch (type) {
case ACTION_TYPE.LIST:
case ACTION_TYPE.PROJECT:
closeQuickActions()
await router.push({
name: 'list.index',
params: { listId: (item as DoAction<IList>).id },
name: 'project.index',
params: { projectId: (item as DoAction<IProject>).id },
})
break
case ACTION_TYPE.TASK:
@ -482,29 +482,29 @@ async function doCmd() {
}
async function newTask() {
if (currentList.value === null) {
if (currentProject.value === null) {
return
}
const task = await taskStore.createNewTask({
title: query.value,
listId: currentList.value.id,
projectId: currentProject.value.id,
})
success({ message: t('task.createSuccess') })
await router.push({ name: 'task.detail', params: { id: task.id } })
}
async function newList() {
if (currentList.value === null) {
async function newProject() {
if (currentProject.value === null) {
return
}
const newList = await listStore.createList(new ListModel({
const newProject = await projectStore.createProject(new ProjectModel({
title: query.value,
namespaceId: currentList.value.namespaceId,
namespaceId: currentProject.value.namespaceId,
}))
success({ message: t('list.create.createdSuccess')})
success({ message: t('project.create.createdSuccess')})
await router.push({
name: 'list.index',
params: { listId: newList.id },
name: 'project.index',
params: { projectId: newProject.id },
})
}

View File

@ -1,39 +1,39 @@
<template>
<div>
<p class="has-text-weight-bold">
{{ $t('list.share.links.title') }}
{{ $t('project.share.links.title') }}
<span
class="is-size-7 has-text-grey is-italic ml-3"
v-tooltip="$t('list.share.links.explanation')">
{{ $t('list.share.links.what') }}
v-tooltip="$t('project.share.links.explanation')">
{{ $t('project.share.links.what') }}
</span>
</p>
<div class="sharables-list">
<div class="sharables-project">
<x-button
v-if="!(linkShares.length === 0 || showNewForm)"
@click="showNewForm = true"
icon="plus"
class="mb-4">
{{ $t('list.share.links.create') }}
{{ $t('project.share.links.create') }}
</x-button>
<div class="p-4" v-if="linkShares.length === 0 || showNewForm">
<div class="field">
<label class="label" for="linkShareRight">
{{ $t('list.share.right.title') }}
{{ $t('project.share.right.title') }}
</label>
<div class="control">
<div class="select">
<select v-model="selectedRight" id="linkShareRight">
<option :value="RIGHTS.READ">
{{ $t('list.share.right.read') }}
{{ $t('project.share.right.read') }}
</option>
<option :value="RIGHTS.READ_WRITE">
{{ $t('list.share.right.readWrite') }}
{{ $t('project.share.right.readWrite') }}
</option>
<option :value="RIGHTS.ADMIN">
{{ $t('list.share.right.admin') }}
{{ $t('project.share.right.admin') }}
</option>
</select>
</div>
@ -41,21 +41,21 @@
</div>
<div class="field">
<label class="label" for="linkShareName">
{{ $t('list.share.links.name') }}
{{ $t('project.share.links.name') }}
</label>
<div class="control">
<input
id="linkShareName"
class="input"
:placeholder="$t('list.share.links.namePlaceholder')"
v-tooltip="$t('list.share.links.nameExplanation')"
:placeholder="$t('project.share.links.namePlaceholder')"
v-tooltip="$t('project.share.links.nameExplanation')"
v-model="name"
/>
</div>
</div>
<div class="field">
<label class="label" for="linkSharePassword">
{{ $t('list.share.links.password') }}
{{ $t('project.share.links.password') }}
</label>
<div class="control">
<input
@ -63,25 +63,25 @@
type="password"
class="input"
:placeholder="$t('user.auth.passwordPlaceholder')"
v-tooltip="$t('list.share.links.passwordExplanation')"
v-tooltip="$t('project.share.links.passwordExplanation')"
v-model="password"
/>
</div>
</div>
<x-button @click="add(listId)" icon="plus">
{{ $t('list.share.share') }}
<x-button @click="add(projectId)" icon="plus">
{{ $t('project.share.share') }}
</x-button>
</div>
<table
class="table has-actions is-striped is-hoverable is-fullwidth link-share-list"
class="table has-actions is-striped is-hoverable is-fullwidth link-share-project"
v-if="linkShares.length > 0"
>
<thead>
<tr>
<th></th>
<th>{{ $t('list.share.links.view') }}</th>
<th>{{ $t('list.share.attributes.delete') }}</th>
<th>{{ $t('project.share.links.view') }}</th>
<th>{{ $t('project.share.attributes.delete') }}</th>
</tr>
</thead>
<tbody>
@ -92,7 +92,7 @@
</p>
<p class="mb-2">
<i18n-t keypath="list.share.links.sharedBy" scope="global">
<i18n-t keypath="project.share.links.sharedBy" scope="global">
<strong>{{ getDisplayName(s.sharedBy) }}</strong>
</i18n-t>
</p>
@ -102,19 +102,19 @@
<span class="icon is-small">
<icon icon="lock"/>
</span>&nbsp;
{{ $t('list.share.right.admin') }}
{{ $t('project.share.right.admin') }}
</template>
<template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>&nbsp;
{{ $t('list.share.right.readWrite') }}
{{ $t('project.share.right.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>&nbsp;
{{ $t('list.share.right.read') }}
{{ $t('project.share.right.read') }}
</template>
</p>
@ -172,15 +172,15 @@
<transition name="modal">
<modal
@close="showDeleteModal = false"
@submit="remove(listId)"
@submit="remove(projectId)"
v-if="showDeleteModal"
>
<template #header>
<span>{{ $t('list.share.links.remove') }}</span>
<span>{{ $t('project.share.links.remove') }}</span>
</template>
<template #text>
<p>{{ $t('list.share.links.removeText') }}</p>
<p>{{ $t('project.share.links.removeText') }}</p>
</template>
</modal>
</transition>
@ -195,19 +195,19 @@ import {RIGHTS} from '@/constants/rights'
import LinkShareModel from '@/models/linkShare'
import type {ILinkShare} from '@/modelTypes/ILinkShare'
import type {IList} from '@/modelTypes/IList'
import type {IProject} from '@/modelTypes/IProject'
import LinkShareService from '@/services/linkShare'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {success} from '@/message'
import {getDisplayName} from '@/models/user'
import type {ListView} from '@/types/ListView'
import {LIST_VIEWS} from '@/types/ListView'
import type {ProjectView} from '@/types/ProjectView'
import {PROJECT_VIEWS} from '@/types/ProjectView'
import {useConfigStore} from '@/stores/config'
const props = defineProps({
listId: {
projectId: {
default: 0,
required: true,
},
@ -224,20 +224,20 @@ const showDeleteModal = ref(false)
const linkIdToDelete = ref(0)
const showNewForm = ref(false)
type SelectedViewMapper = Record<IList['id'], ListView>
type SelectedViewMapper = Record<IProject['id'], ProjectView>
const selectedView = ref<SelectedViewMapper>({})
const availableViews = computed<Record<ListView, string>>(() => ({
list: t('list.list.title'),
gantt: t('list.gantt.title'),
table: t('list.table.title'),
kanban: t('list.kanban.title'),
const availableViews = computed<Record<ProjectView, string>>(() => ({
project: t('project.project.title'),
gantt: t('project.gantt.title'),
table: t('project.table.title'),
kanban: t('project.kanban.title'),
}))
const copy = useCopyToClipboard()
watch(
() => props.listId,
() => props.projectId,
load,
{immediate: true},
)
@ -245,23 +245,23 @@ watch(
const configStore = useConfigStore()
const frontendUrl = computed(() => configStore.frontendUrl)
async function load(listId: IList['id']) {
// If listId == 0 the list on the calling component wasn't already loaded, so we just bail out here
if (listId === 0) {
async function load(projectId: IProject['id']) {
// If projectId == 0 the project on the calling component wasn't already loaded, so we just bail out here
if (projectId === 0) {
return
}
const links = await linkShareService.getAll({listId})
const links = await linkShareService.getAll({projectId})
links.forEach((l: ILinkShare) => {
selectedView.value[l.id] = 'list'
selectedView.value[l.id] = 'project'
})
linkShares.value = links
}
async function add(listId: IList['id']) {
async function add(projectId: IProject['id']) {
const newLinkShare = new LinkShareModel({
right: selectedRight.value,
listId,
projectId,
name: name.value,
password: password.value,
})
@ -270,31 +270,31 @@ async function add(listId: IList['id']) {
name.value = ''
password.value = ''
showNewForm.value = false
success({message: t('list.share.links.createSuccess')})
await load(listId)
success({message: t('project.share.links.createSuccess')})
await load(projectId)
}
async function remove(listId: IList['id']) {
async function remove(projectId: IProject['id']) {
try {
await linkShareService.delete(new LinkShareModel({
id: linkIdToDelete.value,
listId,
projectId,
}))
success({message: t('list.share.links.deleteSuccess')})
await load(listId)
success({message: t('project.share.links.deleteSuccess')})
await load(projectId)
} finally {
showDeleteModal.value = false
}
}
function getShareLink(hash: string, view: ListView = LIST_VIEWS.LIST) {
function getShareLink(hash: string, view: ProjectView = PROJECT_VIEWS.PROJECT) {
return frontendUrl.value + 'share/' + hash + '/auth?view=' + view
}
</script>
<style lang="scss" scoped>
// FIXME: I think this is not needed
.sharables-list:not(.card-content) {
.sharables-project:not(.card-content) {
overflow-y: auto
}

View File

@ -1,7 +1,7 @@
<template>
<div>
<p class="has-text-weight-bold">
{{ $t('list.share.userTeam.shared', {type: shareTypeNames}) }}
{{ $t('project.share.userTeam.shared', {type: shareTypeNames}) }}
</p>
<div v-if="userIsAdmin">
<div class="field has-addons">
@ -19,7 +19,7 @@
/>
</p>
<p class="control">
<x-button @click="add()">{{ $t('list.share.share') }}</x-button>
<x-button @click="add()">{{ $t('project.share.share') }}</x-button>
</p>
</div>
</div>
@ -31,7 +31,7 @@
<td>{{ getDisplayName(s) }}</td>
<td>
<template v-if="s.id === userInfo.id">
<b class="is-success">{{ $t('list.share.userTeam.you') }}</b>
<b class="is-success">{{ $t('project.share.userTeam.you') }}</b>
</template>
</td>
</template>
@ -52,19 +52,19 @@
<span class="icon is-small">
<icon icon="lock"/>
</span>
{{ $t('list.share.right.admin') }}
{{ $t('project.share.right.admin') }}
</template>
<template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>
{{ $t('list.share.right.readWrite') }}
{{ $t('project.share.right.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>
{{ $t('list.share.right.read') }}
{{ $t('project.share.right.read') }}
</template>
</td>
<td class="actions" v-if="userIsAdmin">
@ -78,19 +78,19 @@
:selected="s.right === RIGHTS.READ"
:value="RIGHTS.READ"
>
{{ $t('list.share.right.read') }}
{{ $t('project.share.right.read') }}
</option>
<option
:selected="s.right === RIGHTS.READ_WRITE"
:value="RIGHTS.READ_WRITE"
>
{{ $t('list.share.right.readWrite') }}
{{ $t('project.share.right.readWrite') }}
</option>
<option
:selected="s.right === RIGHTS.ADMIN"
:value="RIGHTS.ADMIN"
>
{{ $t('list.share.right.admin') }}
{{ $t('project.share.right.admin') }}
</option>
</select>
</div>
@ -110,7 +110,7 @@
</table>
<nothing v-else>
{{ $t('list.share.userTeam.notShared', {type: shareTypeNames}) }}
{{ $t('project.share.userTeam.notShared', {type: shareTypeNames}) }}
</nothing>
<transition name="modal">
@ -121,11 +121,11 @@
>
<template #header>
<span>{{
$t('list.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName})
$t('project.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName})
}}</span>
</template>
<template #text>
<p>{{ $t('list.share.userTeam.removeText', {type: shareTypeName, sharable: sharableName}) }}</p>
<p>{{ $t('project.share.userTeam.removeText', {type: shareTypeName, sharable: sharableName}) }}</p>
</template>
</modal>
</transition>
@ -145,9 +145,9 @@ import UserNamespaceService from '@/services/userNamespace'
import UserNamespaceModel from '@/models/userNamespace'
import type {IUserNamespace} from '@/modelTypes/IUserNamespace'
import UserListService from '@/services/userList'
import UserListModel from '@/models/userList'
import type {IUserList} from '@/modelTypes/IUserList'
import UserProjectService from '@/services/userProject'
import UserProjectModel from '@/models/userProject'
import type {IUserProject} from '@/modelTypes/IUserProject'
import UserService from '@/services/user'
import UserModel, { getDisplayName } from '@/models/user'
@ -157,9 +157,9 @@ import TeamNamespaceService from '@/services/teamNamespace'
import TeamNamespaceModel from '@/models/teamNamespace'
import type { ITeamNamespace } from '@/modelTypes/ITeamNamespace'
import TeamListService from '@/services/teamList'
import TeamListModel from '@/models/teamList'
import type { ITeamList } from '@/modelTypes/ITeamList'
import TeamProjectService from '@/services/teamProject'
import TeamProjectModel from '@/models/teamProject'
import type { ITeamProject } from '@/modelTypes/ITeamProject'
import TeamService from '@/services/team'
import TeamModel from '@/models/team'
@ -174,7 +174,7 @@ import {useAuthStore} from '@/stores/auth'
const props = defineProps({
type: {
type: String as PropType<'list' | 'namespace'>,
type: String as PropType<'project' | 'namespace'>,
default: '',
},
shareType: {
@ -193,9 +193,9 @@ const props = defineProps({
const {t} = useI18n({useScope: 'global'})
// This user service is either a userNamespaceService or a userListService, depending on the type we are using
let stuffService: UserNamespaceService | UserListService | TeamListService | TeamNamespaceService
let stuffModel: IUserNamespace | IUserList | ITeamList | ITeamNamespace
// This user service is either a userNamespaceService or a userProjectService, depending on the type we are using
let stuffService: UserNamespaceService | UserProjectService | TeamProjectService | TeamNamespaceService
let stuffModel: IUserNamespace | IUserProject | ITeamProject | ITeamNamespace
let searchService: UserService | TeamService
let sharable: Ref<IUser | ITeam>
@ -203,7 +203,7 @@ const searchLabel = ref('')
const selectedRight = ref({})
// This holds either teams or users who this namepace or list is shared with
// This holds either teams or users who this namepace or project is shared with
const sharables = ref([])
const showDeleteModal = ref(false)
@ -214,11 +214,11 @@ const userInfo = computed(() => authStore.info)
function createShareTypeNameComputed(count: number) {
return computed(() => {
if (props.shareType === 'user') {
return t('list.share.userTeam.typeUser', count)
return t('project.share.userTeam.typeUser', count)
}
if (props.shareType === 'team') {
return t('list.share.userTeam.typeTeam', count)
return t('project.share.userTeam.typeTeam', count)
}
return ''
@ -229,8 +229,8 @@ const shareTypeNames = createShareTypeNameComputed(2)
const shareTypeName = createShareTypeNameComputed(1)
const sharableName = computed(() => {
if (props.type === 'list') {
return t('list.list.title')
if (props.type === 'project') {
return t('project.project.title')
}
if (props.shareType === 'namespace') {
@ -246,9 +246,9 @@ if (props.shareType === 'user') {
sharable = ref(new UserModel())
searchLabel.value = 'username'
if (props.type === 'list') {
stuffService = shallowReactive(new UserListService())
stuffModel = reactive(new UserListModel({listId: props.id}))
if (props.type === 'project') {
stuffService = shallowReactive(new UserProjectService())
stuffModel = reactive(new UserProjectModel({projectId: props.id}))
} else if (props.type === 'namespace') {
stuffService = shallowReactive(new UserNamespaceService())
stuffModel = reactive(new UserNamespaceModel({
@ -263,9 +263,9 @@ if (props.shareType === 'user') {
sharable = ref(new TeamModel())
searchLabel.value = 'name'
if (props.type === 'list') {
stuffService = shallowReactive(new TeamListService())
stuffModel = reactive(new TeamListModel({listId: props.id}))
if (props.type === 'project') {
stuffService = shallowReactive(new TeamProjectService())
stuffModel = reactive(new TeamProjectModel({projectId: props.id}))
} else if (props.type === 'namespace') {
stuffService = shallowReactive(new TeamNamespaceService())
stuffModel = reactive(new TeamNamespaceModel({
@ -305,7 +305,7 @@ async function deleteSharable() {
}
}
success({
message: t('list.share.userTeam.removeSuccess', {
message: t('project.share.userTeam.removeSuccess', {
type: shareTypeName.value,
sharable: sharableName.value,
}),
@ -328,7 +328,7 @@ async function add(admin) {
}
await stuffService.create(stuffModel)
success({message: t('list.share.userTeam.addedSuccess', {type: shareTypeName.value})})
success({message: t('project.share.userTeam.addedSuccess', {type: shareTypeName.value})})
await load()
}
@ -360,7 +360,7 @@ async function toggleType(sharable) {
sharables.value[i].right = r.right
}
}
success({message: t('list.share.userTeam.updatedSuccess', {type: shareTypeName.value})})
success({message: t('project.share.userTeam.updatedSuccess', {type: shareTypeName.value})})
}
const found = ref([])

View File

@ -52,7 +52,7 @@ import {parseKebabDate} from '@/helpers/time/parseKebabDate'
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
import type {DateISO} from '@/types/DateISO'
import type {GanttFilters} from '@/views/list/helpers/useGanttFilters'
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters'
import {
extendDayjs,

View File

@ -6,7 +6,7 @@
:disabled="loading || undefined"
class="add-task-textarea input"
:class="{'textarea-empty': newTaskTitle === ''}"
:placeholder="$t('list.list.addPlaceholder')"
:placeholder="$t('project.project.addPlaceholder')"
rows="1"
v-focus
v-model="newTaskTitle"
@ -25,10 +25,10 @@
@click="addTask()"
icon="plus"
:loading="loading"
:aria-label="$t('list.list.add')"
:aria-label="$t('project.project.add')"
>
<span class="button-text">
{{ $t('list.list.add') }}
{{ $t('project.project.add') }}
</span>
</x-button>
</p>
@ -152,7 +152,7 @@ function resetEmptyTitleError(e) {
const loading = computed(() => taskStore.isLoading)
async function addTask() {
if (newTaskTitle.value === '') {
errorMessage.value = t('list.create.addTitleRequired')
errorMessage.value = t('project.create.addTitleRequired')
return
}
errorMessage.value = ''
@ -173,7 +173,7 @@ async function addTask() {
const task = await taskStore.createNewTask({
title,
listId: authStore.settings.defaultListId,
projectId: authStore.settings.defaultProjectId,
position: props.defaultPosition,
})
createdTasks[title] = task
@ -208,7 +208,7 @@ async function addTask() {
}))
createdTask.relatedTasks[RELATION_KIND.PARENTTASK] = [createdParentTask]
// we're only emitting here so that the relation shows up in the task list
// we're only emitting here so that the relation shows up in the project
emit('taskAdded', createdTask)
return rel
@ -216,8 +216,8 @@ async function addTask() {
await Promise.all(relations)
} catch (e: any) {
newTaskTitle.value = taskTitleBackup
if (e?.message === 'NO_LIST') {
errorMessage.value = t('list.create.addListRequired')
if (e?.message === 'NO_PROJECT') {
errorMessage.value = t('project.create.addProjectRequired')
return
}
throw e

View File

@ -1,7 +1,7 @@
<template>
<card
class="taskedit"
:title="$t('list.list.editTask')"
:title="$t('project.project.editTask')"
@close="$emit('close')"
:has-close="true"
>

View File

@ -1,6 +1,6 @@
<template>
<Multiselect
:loading="listUserService.loading"
:loading="projectUserService.loading"
:placeholder="$t('task.assignee.placeholder')"
:multiple="true"
@search="findUser"
@ -30,7 +30,7 @@ import Multiselect from '@/components/input/multiselect.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {includesById} from '@/helpers/utils'
import ListUserService from '@/services/listUsers'
import ProjectUserService from '@/services/projectUsers'
import {success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
@ -42,7 +42,7 @@ const props = defineProps({
type: Number,
required: true,
},
listId: {
projectId: {
type: Number,
required: true,
},
@ -59,7 +59,7 @@ const emit = defineEmits(['update:modelValue'])
const taskStore = useTaskStore()
const {t} = useI18n({useScope: 'global'})
const listUserService = shallowReactive(new ListUserService())
const projectUserService = shallowReactive(new ProjectUserService())
const foundUsers = ref<IUser[]>([])
const assignees = ref<IUser[]>([])
let isAdding = false
@ -94,7 +94,7 @@ async function addAssignee(user: IUser) {
async function removeAssignee(user: IUser) {
await taskStore.removeAssignee({user: user, taskId: props.taskId})
// Remove the assignee from the list
// Remove the assignee from the project
for (const a in assignees.value) {
if (assignees.value[a].id === user.id) {
assignees.value.splice(a, 1)
@ -109,7 +109,7 @@ async function findUser(query: string) {
return
}
const response = await listUserService.getAll({listId: props.listId}, {s: query}) as IUser[]
const response = await projectUserService.getAll({projectId: props.projectId}, {s: query}) as IUser[]
// Filter the results to not include users who are already assigned
foundUsers.value = response

View File

@ -1,18 +1,18 @@
<template>
<Multiselect
class="control is-expanded"
:placeholder="$t('list.search')"
:search-results="foundLists"
:placeholder="$t('project.search')"
:search-results="foundProjects"
label="title"
:select-placeholder="$t('list.searchSelect')"
:model-value="list"
@update:model-value="Object.assign(list, $event)"
:select-placeholder="$t('project.searchSelect')"
:model-value="project"
@update:model-value="Object.assign(project, $event)"
@select="select"
@search="findLists"
@search="findProjects"
>
<template #searchResult="{option}">
<span class="list-namespace-title search-result">{{ namespace((option as IList).namespaceId) }} ></span>
{{ (option as IList).title }}
<span class="project-namespace-title search-result">{{ namespace((option as IProject).namespaceId) }} ></span>
{{ (option as IProject).title }}
</template>
</Multiselect>
</template>
@ -22,19 +22,19 @@ import {reactive, ref, watch} from 'vue'
import type {PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import type {IList} from '@/modelTypes/IList'
import type {IProject} from '@/modelTypes/IProject'
import type {INamespace} from '@/modelTypes/INamespace'
import {useListStore} from '@/stores/lists'
import {useProjectStore} from '@/stores/projects'
import {useNamespaceStore} from '@/stores/namespaces'
import ListModel from '@/models/list'
import ProjectModel from '@/models/project'
import Multiselect from '@/components/input/multiselect.vue'
const props = defineProps({
modelValue: {
type: Object as PropType<IList>,
type: Object as PropType<IProject>,
required: false,
},
})
@ -42,45 +42,45 @@ const emit = defineEmits(['update:modelValue'])
const {t} = useI18n({useScope: 'global'})
const list: IList = reactive(new ListModel())
const project: IProject = reactive(new ProjectModel())
watch(
() => props.modelValue,
(newList) => Object.assign(list, newList),
(newProject) => Object.assign(project, newProject),
{
immediate: true,
deep: true,
},
)
const listStore = useListStore()
const projectStore = useProjectStore()
const namespaceStore = useNamespaceStore()
const foundLists = ref<IList[]>([])
function findLists(query: string) {
const foundProjects = ref<IProject[]>([])
function findProjects(query: string) {
if (query === '') {
select(null)
}
foundLists.value = listStore.searchList(query)
foundProjects.value = projectStore.searchProject(query)
}
function select(l: IList | null) {
function select(l: IProject | null) {
if (l === null) {
return
}
Object.assign(list, l)
emit('update:modelValue', list)
Object.assign(project, l)
emit('update:modelValue', project)
}
function namespace(namespaceId: INamespace['id']) {
const namespace = namespaceStore.getNamespaceById(namespaceId)
return namespace !== null
? namespace.title
: t('list.shared')
: t('project.shared')
}
</script>
<style lang="scss" scoped>
.list-namespace-title {
.project-namespace-title {
color: var(--grey-500);
}
</style>

View File

@ -37,14 +37,14 @@
{{ $t('task.quickAddMagic.multiple') }}
</p>
<h3>{{ $t('list.list.title') }}</h3>
<h3>{{ $t('project.project.title') }}</h3>
<p>
{{ $t('task.quickAddMagic.list1', {prefix: prefixes.list}) }}
{{ $t('task.quickAddMagic.list2') }}
{{ $t('task.quickAddMagic.project1', {prefix: prefixes.project}) }}
{{ $t('task.quickAddMagic.project2') }}
</p>
<p>
{{ $t('task.quickAddMagic.list3') }}
{{ $t('task.quickAddMagic.list4', {prefix: prefixes.list}) }}
{{ $t('task.quickAddMagic.project3') }}
{{ $t('task.quickAddMagic.project4', {prefix: prefixes.project}) }}
</p>
<h3>{{ $t('task.quickAddMagic.dateAndTime') }}</h3>

View File

@ -43,8 +43,8 @@
:class="{'is-strikethrough': task.done}"
>
<span
class="different-list"
v-if="task.listId !== listId"
class="different-project"
v-if="task.projectId !== projectId"
>
<span
v-if="task.differentNamespace !== null"
@ -52,9 +52,9 @@
{{ task.differentNamespace }} >
</span>
<span
v-if="task.differentList !== null"
v-tooltip="$t('task.relation.differentList')">
{{ task.differentList }} >
v-if="task.differentProject !== null"
v-tooltip="$t('task.relation.differentProject')">
{{ task.differentProject }} >
</span>
</span>
{{ task.title }}
@ -98,8 +98,8 @@
:class="{ 'is-strikethrough': t.done}"
>
<span
class="different-list"
v-if="t.listId !== listId"
class="different-project"
v-if="t.projectId !== projectId"
>
<span
v-if="t.differentNamespace !== null"
@ -107,9 +107,9 @@
{{ t.differentNamespace }} >
</span>
<span
v-if="t.differentList !== null"
v-tooltip="$t('task.relation.differentList')">
{{ t.differentList }} >
v-if="t.differentProject !== null"
v-tooltip="$t('task.relation.differentProject')">
{{ t.differentProject }} >
</span>
</span>
{{ t.title }}
@ -185,7 +185,7 @@ const props = defineProps({
type: Boolean,
default: false,
},
listId: {
projectId: {
type: Number,
default: 0,
},
@ -229,17 +229,17 @@ async function findTasks(newQuery: string) {
foundTasks.value = await taskService.getAll({}, {s: newQuery})
}
const getListAndNamespaceById = (listId: number) => namespaceStore.getListAndNamespaceById(listId, true)
const getProjectAndNamespaceById = (projectId: number) => namespaceStore.getProjectAndNamespaceById(projectId, true)
const namespace = computed(() => getListAndNamespaceById(props.listId)?.namespace)
const namespace = computed(() => getProjectAndNamespaceById(props.projectId)?.namespace)
function mapRelatedTasks(tasks: ITask[]) {
return tasks.map(task => {
// by doing this here once we can save a lot of duplicate calls in the template
const {
list,
project,
namespace: taskNamespace,
} = getListAndNamespaceById(task.listId) || {list: null, namespace: null}
} = getProjectAndNamespaceById(task.projectId) || {project: null, namespace: null}
return {
...task,
@ -247,10 +247,10 @@ function mapRelatedTasks(tasks: ITask[]) {
(taskNamespace !== null &&
taskNamespace.id !== namespace.value.id &&
taskNamespace?.title) || null,
differentList:
(list !== null &&
task.listId !== props.listId &&
list?.title) || null,
differentProject:
(project !== null &&
task.projectId !== props.projectId &&
project?.title) || null,
}
})
}
@ -342,7 +342,7 @@ async function removeTaskRelation() {
}
async function createAndRelateTask(title: string) {
const newTask = await taskService.create(new TaskModel({title, listId: props.listId}))
const newTask = await taskService.create(new TaskModel({title, projectId: props.projectId}))
newTaskRelation.task = newTask
await addTaskRelation()
}
@ -350,7 +350,7 @@ async function createAndRelateTask(title: string) {
async function toggleTaskDone(task: ITask) {
await taskStore.update(task)
// Find the task in the list and update it so that it is correctly strike through
// Find the task in the project and update it so that it is correctly strike through
Object.entries(relatedTasks.value).some(([kind, tasks]) => {
return (tasks as ITask[]).some((t, key) => {
const found = t.id === task.id
@ -378,7 +378,7 @@ async function toggleTaskDone(task: ITask) {
}
}
.different-list {
.different-project {
color: var(--grey-500);
width: auto;
}

View File

@ -7,8 +7,8 @@
/>
<ColorBubble
v-if="showListColor && listColor !== ''"
:color="listColor"
v-if="showProjectColor && projectColor !== ''"
:color="projectColor"
class="mr-1"
/>
@ -19,12 +19,12 @@
>
<span>
<router-link
v-if="showList && taskList !== null"
:to="{ name: 'list.list', params: { listId: task.listId } }"
class="task-list"
v-if="showProject && taskProject !== null"
:to="{ name: 'project.project', params: { projectId: task.projectId } }"
class="task-project"
:class="{'mr-2': task.hexColor !== ''}"
v-tooltip="$t('task.detail.belongsToList', {list: taskList.title})">
{{ taskList.title }}
v-tooltip="$t('task.detail.belongsToProject', {project: taskProject.title})">
{{ taskProject.title }}
</router-link>
<ColorBubble
@ -81,13 +81,13 @@
<priority-label :priority="task.priority" :done="task.done"/>
<span>
<span class="list-task-icon" v-if="task.attachments.length > 0">
<span class="project-task-icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
</span>
<span class="list-task-icon" v-if="task.description">
<span class="project-task-icon" v-if="task.description">
<icon icon="align-left"/>
</span>
<span class="list-task-icon" v-if="task.repeatAfter.amount > 0">
<span class="project-task-icon" v-if="task.repeatAfter.amount > 0">
<icon icon="history"/>
</span>
</span>
@ -104,12 +104,12 @@
</progress>
<router-link
v-if="!showList && currentList.id !== task.listId && taskList !== null"
:to="{ name: 'list.list', params: { listId: task.listId } }"
class="task-list"
v-tooltip="$t('task.detail.belongsToList', {list: taskList.title})"
v-if="!showProject && currentProject.id !== task.projectId && taskProject !== null"
:to="{ name: 'project.project', params: { projectId: task.projectId } }"
class="task-project"
v-tooltip="$t('task.detail.belongsToProject', {project: taskProject.title})"
>
{{ taskList.title }}
{{ taskProject.title }}
</router-link>
<BaseButton
@ -147,7 +147,7 @@ import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatDate'
import {success} from '@/message'
import {useListStore} from '@/stores/lists'
import {useProjectStore} from '@/stores/projects'
import {useNamespaceStore} from '@/stores/namespaces'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
@ -161,7 +161,7 @@ const props = defineProps({
type: Boolean,
default: false,
},
showList: {
showProject: {
type: Boolean,
default: false,
},
@ -169,7 +169,7 @@ const props = defineProps({
type: Boolean,
default: false,
},
showListColor: {
showProjectColor: {
type: Boolean,
default: true,
},
@ -206,18 +206,18 @@ onBeforeUnmount(() => {
})
const baseStore = useBaseStore()
const listStore = useListStore()
const projectStore = useProjectStore()
const taskStore = useTaskStore()
const namespaceStore = useNamespaceStore()
const taskList = computed(() => listStore.getListById(task.value.listId))
const listColor = computed(() => taskList.value !== null ? taskList.value.hexColor : '')
const taskProject = computed(() => projectStore.getProjectById(task.value.projectId))
const projectColor = computed(() => taskProject.value !== null ? taskProject.value.hexColor : '')
const currentList = computed(() => {
return typeof baseStore.currentList === 'undefined' ? {
const currentProject = computed(() => {
return typeof baseStore.currentProject === 'undefined' ? {
id: 0,
title: '',
} : baseStore.currentList
} : baseStore.currentProject
})
const taskDetailRoute = computed(() => ({
@ -306,7 +306,7 @@ function hideDeferDueDatePopup(e) {
}
}
.task-list {
.task-project {
width: auto;
color: var(--grey-400);
font-size: .9rem;
@ -321,7 +321,7 @@ function hideDeferDueDatePopup(e) {
width: 27px;
}
.list-task-icon {
.project-task-icon {
margin-left: 6px;
&:not(:first-of-type) {

View File

@ -42,9 +42,9 @@ const SORT_BY_DEFAULT = {
}
/**
* This mixin provides a base set of methods and properties to get tasks on a list.
* This mixin provides a base set of methods and properties to get tasks on a project.
*/
export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
export function useTaskList(projectId, sortByDefault = SORT_BY_DEFAULT) {
const params = ref({...getDefaultParams()})
const search = ref('')
@ -64,7 +64,7 @@ export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
loadParams = formatSortOrder(sortBy.value, loadParams)
return [
{listId: listId.value},
{projectId: projectId.value},
loadParams,
page.value || 1,
]
@ -98,7 +98,7 @@ export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
}, { immediate: true })
// Only listen for query path changes
// Only projecten for query path changes
watch(() => JSON.stringify(getAllTasksParams.value), (newParams, oldParams) => {
if (oldParams === newParams) {
return

View File

@ -1,9 +0,0 @@
import {i18n} from '@/i18n'
import type {IList} from '@/modelTypes/IList'
export function getListTitle(l: IList) {
if (l.id === -1) {
return i18n.global.t('list.pseudo.favorites.title')
}
return l.title
}

View File

@ -3,7 +3,7 @@ import type {INamespace} from '@/modelTypes/INamespace'
export const getNamespaceTitle = (n: INamespace) => {
if (n.id === -1) {
return i18n.global.t('namespace.pseudo.sharedLists.title')
return i18n.global.t('namespace.pseudo.sharedProjects.title')
}
if (n.id === -2) {
return i18n.global.t('namespace.pseudo.favorites.title')

View File

@ -0,0 +1,9 @@
import {i18n} from '@/i18n'
import type {IProject} from '@/modelTypes/IProject'
export function getProjectTitle(l: IProject) {
if (l.id === -1) {
return i18n.global.t('project.pseudo.favorites.title')
}
return l.title
}

View File

@ -1,5 +1,5 @@
import type {IBucket} from '@/modelTypes/IBucket'
import type {IList} from '@/modelTypes/IList'
import type {IProject} from '@/modelTypes/IProject'
const key = 'collapsedBuckets'
@ -13,22 +13,22 @@ function getAllState() {
}
export const saveCollapsedBucketState = (
listId: IList['id'],
projectId: IProject['id'],
collapsedBuckets: CollapsedBuckets,
) => {
const state = getAllState()
state[listId] = collapsedBuckets
for (const bucketId in state[listId]) {
if (!state[listId][bucketId]) {
delete state[listId][bucketId]
state[projectId] = collapsedBuckets
for (const bucketId in state[projectId]) {
if (!state[projectId][bucketId]) {
delete state[projectId][bucketId]
}
}
localStorage.setItem(key, JSON.stringify(state))
}
export function getCollapsedBucketState(listId : IList['id']) {
export function getCollapsedBucketState(projectId : IProject['id']) {
const state = getAllState()
return typeof state[listId] !== 'undefined'
? state[listId]
return typeof state[projectId] !== 'undefined'
? state[projectId]
: {}
}

View File

@ -1,53 +0,0 @@
import type { IList } from '@/modelTypes/IList'
type ListView = Record<IList['id'], string>
const DEFAULT_LIST_VIEW = 'list.list' as const
/**
* Save the current list view to local storage
*/
export function saveListView(listId: IList['id'], routeName: string) {
if (routeName.includes('settings.')) {
return
}
if (!listId) {
return
}
// We use local storage and not the store here to make it persistent across reloads.
const savedListView = localStorage.getItem('listView')
let savedListViewJson: ListView | false = false
if (savedListView !== null) {
savedListViewJson = JSON.parse(savedListView) as ListView
}
let listView: ListView = {}
if (savedListViewJson) {
listView = savedListViewJson
}
listView[listId] = routeName
localStorage.setItem('listView', JSON.stringify(listView))
}
export const getListView = (listId: IList['id']) => {
// Remove old stored settings
const savedListView = localStorage.getItem('listView')
if (savedListView !== null && savedListView.startsWith('list.')) {
localStorage.removeItem('listView')
}
if (!savedListView) {
return DEFAULT_LIST_VIEW
}
const savedListViewJson: ListView = JSON.parse(savedListView)
if (!savedListViewJson[listId]) {
return DEFAULT_LIST_VIEW
}
return savedListViewJson[listId]
}

View File

@ -0,0 +1,53 @@
import type { IProject } from '@/modelTypes/IProject'
type ProjectView = Record<IProject['id'], string>
const DEFAULT_PROJECT_VIEW = 'project.project' as const
/**
* Save the current project view to local storage
*/
export function saveProjectView(projectId: IProject['id'], routeName: string) {
if (routeName.includes('settings.')) {
return
}
if (!projectId) {
return
}
// We use local storage and not the store here to make it persistent across reloads.
const savedProjectView = localStorage.getItem('projectView')
let savedProjectViewJson: ProjectView | false = false
if (savedProjectView !== null) {
savedProjectViewJson = JSON.parse(savedProjectView) as ProjectView
}
let projectView: ProjectView = {}
if (savedProjectViewJson) {
projectView = savedProjectViewJson
}
projectView[projectId] = routeName
localStorage.setItem('projectView', JSON.stringify(projectView))
}
export const getProjectView = (projectId: IProject['id']) => {
// Remove old stored settings
const savedProjectView = localStorage.getItem('projectView')
if (savedProjectView !== null && savedProjectView.startsWith('project.')) {
localStorage.removeItem('projectView')
}
if (!savedProjectView) {
return DEFAULT_PROJECT_VIEW
}
const savedProjectViewJson: ProjectView = JSON.parse(savedProjectView)
if (!savedProjectViewJson[projectId]) {
return DEFAULT_PROJECT_VIEW
}
return savedProjectViewJson[projectId]
}

View File

@ -5,10 +5,10 @@
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Last viewed",
"list": {
"newText": "You can create a new list for your new tasks:",
"new": "New list",
"importText": "Or import your lists and tasks from other services into Vikunja:",
"project": {
"newText": "You can create a new project for your new tasks:",
"new": "New project",
"importText": "Or import your projects and tasks from other services into Vikunja:",
"import": "Import your data into Vikunja"
}
},
@ -85,7 +85,7 @@
"weekStartSunday": "Sunday",
"weekStartMonday": "Monday",
"language": "Language",
"defaultList": "Default List",
"defaultProject": "Default Project",
"timezone": "Time Zone",
"overdueTasksRemindersTime": "Overdue tasks reminder email time"
},
@ -143,7 +143,7 @@
},
"deletion": {
"title": "Delete your Vikunja Account",
"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": "The deletion of your account is permanent and cannot be undone. We will delete all your namespaces, projects, tasks and everything associated with it.",
"text2": "To proceed, please enter your password. You will receive an email with further instructions.",
"confirm": "Delete my account",
"requestSuccess": "The request was successful. You'll receive an email with further instructions.",
@ -157,40 +157,40 @@
},
"export": {
"title": "Export your Vikunja data",
"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": "You can request a copy of all your Vikunja data. This include Namespaces, Projects, Tasks and everything associated to them. You can import this data in any Vikunja instance through the migration function.",
"descriptionPasswordRequired": "Please enter your password to proceed:",
"request": "Request a copy of my Vikunja Data",
"success": "You've successfully requested your Vikunja Data! We will send you an email once it's ready to download.",
"downloadTitle": "Download your exported Vikunja data"
}
},
"list": {
"archived": "This list is archived. It is not possible to create new or edit tasks for it.",
"title": "List Title",
"project": {
"archived": "This project is archived. It is not possible to create new or edit tasks for it.",
"title": "Project Title",
"color": "Color",
"lists": "Lists",
"list": "List",
"search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists",
"noDescriptionAvailable": "No list description is available.",
"projects": "Projects",
"project": "Project",
"search": "Type to search for a project…",
"searchSelect": "Click or press enter to select this project",
"shared": "Shared Projects",
"noDescriptionAvailable": "No project description is available.",
"create": {
"header": "New list",
"titlePlaceholder": "The list's title goes here…",
"header": "New project",
"titlePlaceholder": "The project's title goes here…",
"addTitleRequired": "Please specify a title.",
"createdSuccess": "The list was successfully created.",
"addListRequired": "Please specify a list or set a default list in the settings."
"createdSuccess": "The project was successfully created.",
"addProjectRequired": "Please specify a project or set a default project in the settings."
},
"archive": {
"title": "Archive \"{list}\"",
"archive": "Archive this list",
"unarchive": "Un-Archive this list",
"title": "Archive \"{project}\"",
"archive": "Archive this project",
"unarchive": "Un-Archive this project",
"unarchiveText": "You will be able to create new tasks or edit it.",
"archiveText": "You won't be able to edit this list or create new tasks until you un-archive it.",
"success": "The list was successfully archived."
"archiveText": "You won't be able to edit this project or create new tasks until you un-archive it.",
"success": "The project was successfully archived."
},
"background": {
"title": "Set list background",
"title": "Set project background",
"remove": "Remove Background",
"upload": "Choose a background from your pc",
"searchPlaceholder": "Search for a background…",
@ -200,40 +200,40 @@
"removeSuccess": "The background has been removed successfully!"
},
"delete": {
"title": "Delete \"{list}\"",
"header": "Delete this list",
"text1": "Are you sure you want to delete this list and all of its contents?",
"title": "Delete \"{project}\"",
"header": "Delete this project",
"text1": "Are you sure you want to delete this project and all of its contents?",
"text2": "This includes all tasks and CANNOT BE UNDONE!",
"success": "The list was successfully deleted.",
"success": "The project was successfully deleted.",
"tasksToDelete": "This will irrevocably remove approx. {count} tasks.",
"noTasksToDelete": "This list does not contain any tasks, it should be safe to delete."
"noTasksToDelete": "This project does not contain any tasks, it should be safe to delete."
},
"duplicate": {
"title": "Duplicate this list",
"title": "Duplicate this project",
"label": "Duplicate",
"text": "Select a namespace which should hold the duplicated list:",
"success": "The list was successfully duplicated."
"text": "Select a namespace which should hold the duplicated project:",
"success": "The project was successfully duplicated."
},
"edit": {
"header": "Edit This List",
"title": "Edit \"{list}\"",
"titlePlaceholder": "The list title goes here…",
"identifierTooltip": "The list identifier can be used to uniquely identify a task across lists. You can set it to empty to disable it.",
"identifier": "List Identifier",
"identifierPlaceholder": "The list identifier goes here…",
"header": "Edit This Project",
"title": "Edit \"{project}\"",
"titlePlaceholder": "The project title goes here…",
"identifierTooltip": "The project identifier can be used to uniquely identify a task across projects. You can set it to empty to disable it.",
"identifier": "Project Identifier",
"identifierPlaceholder": "The project identifier goes here…",
"description": "Description",
"descriptionPlaceholder": "The lists description goes here…",
"descriptionPlaceholder": "The projects description goes here…",
"color": "Color",
"success": "The list was successfully updated."
"success": "The project was successfully updated."
},
"share": {
"header": "Share this list",
"title": "Share \"{list}\"",
"header": "Share this project",
"title": "Share \"{project}\"",
"share": "Share",
"links": {
"title": "Share Links",
"what": "What is a share link?",
"explanation": "Share Links allow you to easily share a list with other users who don't have an account on Vikunja.",
"explanation": "Share Links allow you to easily share a project with other users who don't have an account on Vikunja.",
"create": "Create a new link share",
"name": "Name (optional)",
"namePlaceholder": "e.g. Lorem Ipsum",
@ -242,7 +242,7 @@
"passwordExplanation": "When authenticating, the user will be required to enter this password.",
"noName": "No name set",
"remove": "Remove a link share",
"removeText": "Are you sure you want to remove this link share? It will no longer be possible to access this list with this link share. This cannot be undone!",
"removeText": "Are you sure you want to remove this link share? It will no longer be possible to access this project with this link share. This cannot be undone!",
"createSuccess": "The link share was successfully created.",
"deleteSuccess": "The link share was successfully deleted",
"view": "View",
@ -271,11 +271,11 @@
"delete": "Delete"
}
},
"list": {
"title": "List",
"project": {
"title": "Project",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"empty": "This project is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
@ -323,36 +323,36 @@
}
},
"namespace": {
"title": "Namespaces & Lists",
"title": "Namespaces & Projects",
"namespace": "Namespace",
"showArchived": "Show Archived",
"noneAvailable": "You don't have any namespaces right now.",
"unarchive": "Un-Archive",
"archived": "Archived",
"noLists": "This namespace does not contain any lists.",
"createList": "Create a new list in this namespace.",
"noProjects": "This namespace does not contain any projects.",
"createProject": "Create a new project in this namespace.",
"namespaces": "Namespaces",
"search": "Type to search for a namespace…",
"create": {
"title": "New namespace",
"titleRequired": "Please specify a title.",
"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 namespace.",
"explanation": "A namespace is a collection of projects you can share and use to organize your projects with. In fact, every project belongs to a namespace.",
"tooltip": "What's a namespace?",
"success": "The namespace was successfully created."
},
"archive": {
"titleArchive": "Archive \"{namespace}\"",
"titleUnarchive": "Un-Archive \"{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.",
"unarchiveText": "You will be able to create new lists or edit it.",
"archiveText": "You won't be able to edit this namespace or create new projects until you un-archive it. This will also archive all projects in this namespace.",
"unarchiveText": "You will be able to create new projects or edit it.",
"success": "The namespace was successfully archived.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "If a namespace is archived, you cannot create new lists or edit it."
"description": "If a namespace is archived, you cannot create new projects or edit it."
},
"delete": {
"title": "Delete \"{namespace}\"",
"text1": "Are you sure you want to delete this namespace and all of its contents?",
"text2": "This includes all lists and tasks and CANNOT BE UNDONE!",
"text2": "This includes all projects and tasks and CANNOT BE UNDONE!",
"success": "The namespace was successfully deleted."
},
"edit": {
@ -372,8 +372,8 @@
"isArchived": "This namespace is archived"
},
"pseudo": {
"sharedLists": {
"title": "Shared Lists"
"sharedProjects": {
"title": "Shared Projects"
},
"favorites": {
"title": "Favorites"
@ -404,7 +404,7 @@
},
"create": {
"title": "New Saved Filter",
"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": "A saved filter is a virtual project which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
"action": "Create new saved filter"
},
"delete": {
@ -422,7 +422,7 @@
"titleService": "Import your data from {name} into Vikunja",
"import": "Import your data into Vikunja",
"description": "Click on the logo of one of the third-party services below to get started.",
"descriptionDo": "Vikunja will import all lists, tasks, notes, reminders and files you have access to.",
"descriptionDo": "Vikunja will import all projects, tasks, notes, reminders and files you have access to.",
"authorize": "To authorize Vikunja to access your {name} Account, click the button below.",
"getStarted": "Get Started",
"inProgress": "Importing in progress…",
@ -435,7 +435,7 @@
"label": {
"title": "Labels",
"manage": "Manage labels",
"description": "Click on a label to edit it. You can edit all labels you created, you can use all labels which are associated with a task to whose list you have access.",
"description": "Click on a label to edit it. You can edit all labels you created, you can use all labels which are associated with a task to whose project you have access.",
"newCTA": "You currently do not have any labels.",
"search": "Type to search for a label…",
"create": {
@ -460,7 +460,7 @@
},
"sharing": {
"authenticating": "Authenticating…",
"passwordRequired": "This shared list requires a password. Please enter it below:",
"passwordRequired": "This shared project requires a password. Please enter it below:",
"error": "An error occured.",
"invalidPassword": "The password is invalid."
},
@ -528,8 +528,8 @@
"strikethrough": "Strikethrough",
"code": "Code",
"quote": "Quote",
"unorderedList": "Unordered List",
"orderedList": "Ordered List",
"unorderedProject": "Unordered Project",
"orderedProject": "Ordered Project",
"cleanBlock": "Clean Block",
"link": "Link",
"image": "Image",
@ -621,7 +621,7 @@
"chooseDueDate": "Click here to set a due date",
"chooseStartDate": "Click here to set a start date",
"chooseEndDate": "Click here to set an end date",
"move": "Move task to a different list",
"move": "Move task to a different project",
"done": "Mark task done!",
"undone": "Mark as undone",
"created": "Created {0} by {1}",
@ -629,7 +629,7 @@
"doneAt": "Done {0}",
"updateSuccess": "The task was saved successfully.",
"deleteSuccess": "The task has been deleted successfully.",
"belongsToList": "This task belongs to list '{list}'",
"belongsToProject": "This task belongs to project '{project}'",
"due": "Due {at}",
"closePopup": "Close popup",
"delete": {
@ -649,7 +649,7 @@
"percentDone": "Set Progress",
"attachments": "Add Attachments",
"relatedTasks": "Add Relation",
"moveList": "Move",
"moveProject": "Move",
"color": "Set Color",
"delete": "Delete",
"favorite": "Add to Favorites",
@ -676,21 +676,21 @@
"updated": "Updated"
},
"subscription": {
"subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedProjectThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this project through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedTaskThroughParentProject": "You can't unsubscribe here because you are subscribed to this task through its project.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedProject": "You are currently subscribed to this project and will receive notifications for changes.",
"notSubscribedProject": "You are not subscribed to this project and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessProject": "You are now subscribed to this project",
"unsubscribeSuccessProject": "You are now unsubscribed to this project",
"subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task"
},
@ -764,7 +764,7 @@
"new": "New Task Relation",
"searchPlaceholder": "Type search for a new task to add as related…",
"createPlaceholder": "Add this as new related task",
"differentList": "This task belongs to a different list.",
"differentProject": "This task belongs to a different project.",
"differentNamespace": "This task belongs to a different namespace.",
"noneYet": "No task relations yet.",
"delete": "Delete Task Relation",
@ -814,10 +814,10 @@
"priority1": "To set a task's priority, add a number 1-5, prefixed with a {prefix}.",
"priority2": "The higher the number, the higher the priority.",
"assignees": "To directly assign the task to a user, add their username prefixed with {prefix} to the task.",
"list1": "To set a list for the task to appear in, enter its name prefixed with {prefix}.",
"list2": "This will return an error if the list does not exist.",
"list3": "To use spaces, simply add a \" or ' around the list name.",
"list4": "For example: {prefix}\"List with spaces\".",
"project1": "To set a project for the task to appear in, enter its name prefixed with {prefix}.",
"project2": "This will return an error if the project does not exist.",
"project3": "To use spaces, simply add a \" or ' around the project name.",
"project4": "For example: {prefix}\"Project with spaces\".",
"dateAndTime": "Date and time",
"date": "Any date will be used as the due date of the new task. You can use dates in any of these formats:",
"dateWeekday": "any weekday, will use the next date with that date",
@ -850,19 +850,19 @@
"delete": {
"header": "Delete the team",
"text1": "Are you sure you want to delete this team and all of its members?",
"text2": "All team members will lose access to lists and namespaces shared with this team. This CANNOT BE UNDONE!",
"text2": "All team members will lose access to projects and namespaces shared with this team. This CANNOT BE UNDONE!",
"success": "The team was successfully deleted."
},
"deleteUser": {
"header": "Remove a user from the team",
"text1": "Are you sure you want to remove this user from the team?",
"text2": "They will lose access to all lists and namespaces this team has access to. This CANNOT BE UNDONE!",
"text2": "They will lose access to all projects and namespaces this team has access to. This CANNOT BE UNDONE!",
"success": "The user was successfully deleted from the team."
},
"leave": {
"title": "Leave team",
"text1": "Are you sure you want to leave this team?",
"text2": "You will lose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"text2": "You will lose access to all projects and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team."
}
},
@ -894,13 +894,13 @@
"attachment": "Add an attachment to this task",
"related": "Modify related tasks of this task",
"color": "Change the color of this task",
"move": "Move this task to another list",
"move": "Move this task to another project",
"reminder": "Manage reminders of this task",
"description": "Toggle editing of the task description"
},
"list": {
"title": "List Views",
"switchToListView": "Switch to list view",
"project": {
"title": "Project Views",
"switchToProjectView": "Switch to project view",
"switchToGanttView": "Switch to gantt view",
"switchToKanbanView": "Switch to kanban view",
"switchToTableView": "Switch to table view"
@ -909,7 +909,7 @@
"title": "Navigation",
"overview": "Navigate to overview",
"upcoming": "Navigate to upcoming tasks",
"namespaces": "Navigate to namespaces & lists",
"namespaces": "Navigate to namespaces & projects",
"labels": "Navigate to labels",
"teams": "Navigate to teams"
}
@ -926,7 +926,7 @@
"unarchive": "Un-Archive",
"setBackground": "Set background",
"share": "Share",
"newList": "New list"
"newProject": "New project"
},
"apiConfig": {
"url": "Vikunja URL",
@ -945,24 +945,24 @@
"notification": {
"title": "Notifications",
"none": "You don't have any notifications. Have a nice day!",
"explainer": "Notifications will appear here when actions on namespaces, lists or tasks you subscribed to happen."
"explainer": "Notifications will appear here when actions on namespaces, projects or tasks you subscribed to happen."
},
"quickActions": {
"commands": "Commands",
"placeholder": "Type a command or search…",
"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": "You can use {project} to limit the search to a project. Combine {project} or {label} (labels) with a search query to search for a task with these labels or on that project. Use {assignee} to only search for teams.",
"tasks": "Tasks",
"lists": "Lists",
"projects": "Projects",
"teams": "Teams",
"newList": "Enter the title of the new list…",
"newProject": "Enter the title of the new project…",
"newTask": "Enter the title of the new task…",
"newNamespace": "Enter the title of the new namespace…",
"newTeam": "Enter the name of the new team…",
"createTask": "Create a task in the current list ({title})",
"createList": "Create a list in the current namespace ({title})",
"createTask": "Create a task in the current project ({title})",
"createProject": "Create a project in the current namespace ({title})",
"cmds": {
"newTask": "New task",
"newList": "New list",
"newProject": "New project",
"newNamespace": "New namespace",
"newTeam": "New team"
}
@ -994,15 +994,15 @@
"1018": "The user avatar type setting is invalid.",
"2001": "ID cannot be empty or 0.",
"2002": "Some of the request data was invalid.",
"3001": "The list does not exist.",
"3004": "You need to have read permissions on that list to perform that action.",
"3005": "The list title cannot be empty.",
"3006": "The list share does not exist.",
"3007": "A list with this identifier already exists.",
"3008": "The list is archived and can therefore only be accessed read only. This is also true for all tasks associated with this list.",
"4001": "The list task text cannot be empty.",
"4002": "The list task does not exist.",
"4003": "All bulk editing tasks must belong to the same list.",
"3001": "The project does not exist.",
"3004": "You need to have read permissions on that project to perform that action.",
"3005": "The project title cannot be empty.",
"3006": "The project share does not exist.",
"3007": "A project with this identifier already exists.",
"3008": "The project is archived and can therefore only be accessed read only. This is also true for all tasks associated with this project.",
"4001": "The project task text cannot be empty.",
"4002": "The project task does not exist.",
"4003": "All bulk editing tasks must belong to the same project.",
"4004": "Need at least one task when bulk editing tasks.",
"4005": "You do not have the right to see the task.",
"4006": "You can't set a parent task as the task itself.",
@ -1028,21 +1028,21 @@
"5012": "The namespace is archived and can therefore only be accessed read only.",
"6001": "The team name cannot be empty.",
"6002": "The team does not exist.",
"6004": "The team already has access to that namespace or list.",
"6004": "The team already has access to that namespace or project.",
"6005": "The user is already a member of that team.",
"6006": "Cannot delete the last team member.",
"6007": "The team does not have access to the list to perform that action.",
"7002": "The user already has access to that list.",
"7003": "You do not have access to that list.",
"6007": "The team does not have access to the project to perform that action.",
"7002": "The user already has access to that project.",
"7003": "You do not have access to that project.",
"8001": "This label already exists on that task.",
"8002": "The label does not exist.",
"8003": "You do not have access to this label.",
"9001": "The right is invalid.",
"10001": "The bucket does not exist.",
"10002": "The bucket does not belong to that list.",
"10003": "You cannot remove the last bucket on a list.",
"10002": "The bucket does not belong to that project.",
"10003": "You cannot remove the last bucket on a project.",
"10004": "You cannot add the task to this bucket as it already exceeded the limit of tasks it can hold.",
"10005": "There can be only one done bucket per list.",
"10005": "There can be only one done bucket per project.",
"11001": "The saved filter does not exist.",
"11002": "Saved filters are not available for link shares.",
"12001": "The subscription entity type is invalid.",

View File

@ -5,7 +5,7 @@ import type {ITask} from './ITask'
export interface IBucket extends IAbstract {
id: number
title: string
listId: number
projectId: number
limit: number
tasks: ITask[]
isDoneBucket: boolean

View File

@ -7,7 +7,7 @@ export interface ILabel extends IAbstract {
hexColor: string
description: string
createdBy: IUser
listId: number
projectId: number
textColor: string
created: Date

View File

@ -8,7 +8,7 @@ export interface ILinkShare extends IAbstract {
right: Right
sharedBy: IUser
sharingType: number // FIXME: use correct numbers
listId: number
projectId: number
name: string
password: string

View File

@ -1,9 +0,0 @@
import type {IAbstract} from './IAbstract'
import type {IList} from './IList'
import type {INamespace} from './INamespace'
export interface IListDuplicate extends IAbstract {
listId: number
namespaceId: INamespace['id']
list: IList
}

View File

@ -1,5 +1,5 @@
import type {IAbstract} from './IAbstract'
import type {IList} from './IList'
import type {IProject} from './IProject'
import type {IUser} from './IUser'
import type {ISubscription} from './ISubscription'
@ -8,7 +8,7 @@ export interface INamespace extends IAbstract {
title: string
description: string
owner: IUser
lists: IList[]
projects: IProject[]
isArchived: boolean
hexColor: string
subscription: ISubscription

View File

@ -3,13 +3,13 @@ import type {IUser} from './IUser'
import type {ITask} from './ITask'
import type {ITaskComment} from './ITaskComment'
import type {ITeam} from './ITeam'
import type { IList } from './IList'
import type { IProject } from './IProject'
export const NOTIFICATION_NAMES = {
'TASK_COMMENT': 'task.comment',
'TASK_ASSIGNED': 'task.assigned',
'TASK_DELETED': 'task.deleted',
'LIST_CREATED': 'list.created',
'PROJECT_CREATED': 'project.created',
'TEAM_MEMBER_ADDED': 'team.member.added',
} as const
@ -32,7 +32,7 @@ interface NotificationDeleted extends Notification {
interface NotificationCreated extends Notification {
task: ITask
list: IList
project: IProject
}
interface NotificationMemberAdded extends Notification {

View File

@ -5,7 +5,7 @@ import type {ISubscription} from './ISubscription'
import type {INamespace} from './INamespace'
export interface IList extends IAbstract {
export interface IProject extends IAbstract {
id: number
title: string
description: string

View File

@ -0,0 +1,9 @@
import type {IAbstract} from './IAbstract'
import type {IProject} from './IProject'
import type {INamespace} from './INamespace'
export interface IProjectDuplicate extends IAbstract {
projectId: number
namespaceId: INamespace['id']
project: IProject
}

View File

@ -5,7 +5,7 @@ import type {IUser} from './IUser'
import type {ILabel} from './ILabel'
import type {IAttachment} from './IAttachment'
import type {ISubscription} from './ISubscription'
import type {IList} from './IList'
import type {IProject} from './IProject'
import type {IBucket} from './IBucket'
import type {IRelationKind} from '@/types/IRelationKind'
@ -49,7 +49,7 @@ export interface ITask extends IAbstract {
created: Date
updated: Date
listId: IList['id'] // Meta, only used when creating a new task
projectId: IProject['id'] // Meta, only used when creating a new task
bucketId: IBucket['id']
}

View File

@ -1,6 +0,0 @@
import type {ITeamShareBase} from './ITeamShareBase'
import type {IList} from './IList'
export interface ITeamList extends ITeamShareBase {
listId: IList['id']
}

View File

@ -1,7 +1,7 @@
import type {IUser} from './IUser'
import type {IList} from './IList'
import type {IProject} from './IProject'
export interface ITeamMember extends IUser {
admin: boolean
teamId: IList['id']
teamId: IProject['id']
}

View File

@ -0,0 +1,6 @@
import type {ITeamShareBase} from './ITeamShareBase'
import type {IProject} from './IProject'
export interface ITeamProject extends ITeamShareBase {
projectId: IProject['id']
}

View File

@ -1,6 +0,0 @@
import type {IUserShareBase} from './IUserShareBase'
import type {IList} from './IList'
export interface IUserList extends IUserShareBase {
listId: IList['id']
}

View File

@ -0,0 +1,6 @@
import type {IUserShareBase} from './IUserShareBase'
import type {IProject} from './IProject'
export interface IUserProject extends IUserShareBase {
projectId: IProject['id']
}

View File

@ -1,6 +1,6 @@
import type {IAbstract} from './IAbstract'
import type {IList} from './IList'
import type {IProject} from './IProject'
export interface IUserSettings extends IAbstract {
name: string
@ -9,7 +9,7 @@ export interface IUserSettings extends IAbstract {
discoverableByEmail: boolean
overdueTasksRemindersEnabled: boolean
overdueTasksRemindersTime: any
defaultListId: undefined | IList['id']
defaultProjectId: undefined | IProject['id']
weekStart: 0 | 1 | 2 | 3 | 4 | 5 | 6
timezone: string
language: string

View File

@ -9,7 +9,7 @@ import type {IUser} from '@/modelTypes/IUser'
export default class BucketModel extends AbstractModel<IBucket> implements IBucket {
id = 0
title = ''
listId = ''
projectId = ''
limit = 0
tasks: ITask[] = []
isDoneBucket = false

View File

@ -16,7 +16,7 @@ export default class LabelModel extends AbstractModel<ILabel> implements ILabel
hexColor = DEFAULT_LABEL_BACKGROUND_COLOR
description = ''
createdBy: IUser
listId = 0
projectId = 0
textColor = ''
created: Date = null

View File

@ -11,7 +11,7 @@ export default class LinkShareModel extends AbstractModel<ILinkShare> implements
right: Right = RIGHTS.READ
sharedBy: IUser = UserModel
sharingType = 0 // FIXME: use correct numbers
listId = 0
projectId = 0
name: ''
password: ''
created: Date = null

View File

@ -1,19 +0,0 @@
import AbstractModel from './abstractModel'
import ListModel from './list'
import type {IListDuplicate} from '@/modelTypes/IListDuplicate'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IList} from '@/modelTypes/IList'
export default class ListDuplicateModel extends AbstractModel<IListDuplicate> implements IListDuplicate {
listId = 0
namespaceId: INamespace['id'] = 0
list: IList = ListModel
constructor(data : Partial<IListDuplicate>) {
super()
this.assignData(data)
this.list = new ListModel(this.list)
}
}

View File

@ -1,11 +1,11 @@
import AbstractModel from './abstractModel'
import ListModel from './list'
import ProjectModel from './project'
import UserModel from './user'
import SubscriptionModel from '@/models/subscription'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IUser} from '@/modelTypes/IUser'
import type {IList} from '@/modelTypes/IList'
import type {IProject} from '@/modelTypes/IProject'
import type {ISubscription} from '@/modelTypes/ISubscription'
export default class NamespaceModel extends AbstractModel<INamespace> implements INamespace {
@ -13,7 +13,7 @@ export default class NamespaceModel extends AbstractModel<INamespace> implements
title = ''
description = ''
owner: IUser = UserModel
lists: IList[] = []
projects: IProject[] = []
isArchived = false
hexColor = ''
subscription: ISubscription = null
@ -29,8 +29,8 @@ export default class NamespaceModel extends AbstractModel<INamespace> implements
this.hexColor = '#' + this.hexColor
}
this.lists = this.lists.map(l => {
return new ListModel(l)
this.projects = this.projects.map(l => {
return new ProjectModel(l)
})
this.owner = new UserModel(this.owner)

View File

@ -3,7 +3,7 @@ import {parseDateOrNull} from '@/helpers/parseDateOrNull'
import UserModel, {getDisplayName} from '@/models/user'
import TaskModel from '@/models/task'
import TaskCommentModel from '@/models/taskComment'
import ListModel from '@/models/list'
import ProjectModel from '@/models/project'
import TeamModel from '@/models/team'
import {NOTIFICATION_NAMES, type INotification} from '@/modelTypes/INotification'
@ -43,10 +43,10 @@ export default class NotificationModel extends AbstractModel<INotification> impl
task: new TaskModel(this.notification.task),
}
break
case NOTIFICATION_NAMES.LIST_CREATED:
case NOTIFICATION_NAMES.PROJECT_CREATED:
this.notification = {
doer: new UserModel(this.notification.doer),
list: new ListModel(this.notification.list),
project: new ProjectModel(this.notification.project),
}
break
case NOTIFICATION_NAMES.TEAM_MEMBER_ADDED:
@ -78,8 +78,8 @@ export default class NotificationModel extends AbstractModel<INotification> impl
return `assigned ${who} to ${this.notification.task.getTextIdentifier()}`
case NOTIFICATION_NAMES.TASK_DELETED:
return `deleted ${this.notification.task.getTextIdentifier()}`
case NOTIFICATION_NAMES.LIST_CREATED:
return `created ${this.notification.list.title}`
case NOTIFICATION_NAMES.PROJECT_CREATED:
return `created ${this.notification.project.title}`
case NOTIFICATION_NAMES.TEAM_MEMBER_ADDED:
who = `${getDisplayName(this.notification.member)}`

View File

@ -3,13 +3,13 @@ import TaskModel from '@/models/task'
import UserModel from '@/models/user'
import SubscriptionModel from '@/models/subscription'
import type {IList} from '@/modelTypes/IList'
import type {IProject} from '@/modelTypes/IProject'
import type {IUser} from '@/modelTypes/IUser'
import type {ITask} from '@/modelTypes/ITask'
import type {INamespace} from '@/modelTypes/INamespace'
import type {ISubscription} from '@/modelTypes/ISubscription'
export default class ListModel extends AbstractModel<IList> implements IList {
export default class ProjectModel extends AbstractModel<IProject> implements IProject {
id = 0
title = ''
description = ''
@ -28,7 +28,7 @@ export default class ListModel extends AbstractModel<IList> implements IList {
created: Date = null
updated: Date = null
constructor(data: Partial<IList> = {}) {
constructor(data: Partial<IProject> = {}) {
super()
this.assignData(data)

View File

@ -0,0 +1,19 @@
import AbstractModel from './abstractModel'
import ProjectModel from './project'
import type {IProjectDuplicate} from '@/modelTypes/IProjectDuplicate'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IProject} from '@/modelTypes/IProject'
export default class ProjectDuplicateModel extends AbstractModel<IProjectDuplicate> implements IProjectDuplicate {
projectId = 0
namespaceId: INamespace['id'] = 0
project: IProject = ProjectModel
constructor(data : Partial<IProjectDuplicate>) {
super()
this.assignData(data)
this.project = new ProjectModel(this.project)
}
}

View File

@ -5,7 +5,7 @@ import type {ITask} from '@/modelTypes/ITask'
import type {ILabel} from '@/modelTypes/ILabel'
import type {IUser} from '@/modelTypes/IUser'
import type {IAttachment} from '@/modelTypes/IAttachment'
import type {IList} from '@/modelTypes/IList'
import type {IProject} from '@/modelTypes/IProject'
import type {ISubscription} from '@/modelTypes/ISubscription'
import type {IBucket} from '@/modelTypes/IBucket'
@ -93,7 +93,7 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
created: Date = null
updated: Date = null
listId: IList['id'] = 0
projectId: IProject['id'] = 0
bucketId: IBucket['id'] = 0
constructor(data: Partial<ITask> = {}) {
@ -142,7 +142,7 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
// Make all attachments to attachment models
this.attachments = this.attachments.map(a => new AttachmentModel(a))
// Set the task identifier to empty if the list does not have one
// Set the task identifier to empty if the project does not have one
if (this.identifier === `-${this.index}`) {
this.identifier = ''
}
@ -155,7 +155,7 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
this.created = new Date(this.created)
this.updated = new Date(this.updated)
this.listId = Number(this.listId)
this.projectId = Number(this.projectId)
}
getTextIdentifier() {

View File

@ -1,13 +0,0 @@
import TeamShareBaseModel from './teamShareBase'
import type {ITeamList} from '@/modelTypes/ITeamList'
import type {IList} from '@/modelTypes/IList'
export default class TeamListModel extends TeamShareBaseModel implements ITeamList {
listId: IList['id'] = 0
constructor(data: Partial<ITeamList>) {
super(data)
this.assignData(data)
}
}

View File

@ -1,11 +1,11 @@
import UserModel from './user'
import type {ITeamMember} from '@/modelTypes/ITeamMember'
import type {IList} from '@/modelTypes/IList'
import type {IProject} from '@/modelTypes/IProject'
export default class TeamMemberModel extends UserModel implements ITeamMember {
admin = false
teamId: IList['id'] = 0
teamId: IProject['id'] = 0
constructor(data: Partial<ITeamMember>) {
super(data)

13
src/models/teamProject.ts Normal file
View File

@ -0,0 +1,13 @@
import TeamShareBaseModel from './teamShareBase'
import type {ITeamProject} from '@/modelTypes/ITeamProject'
import type {IProject} from '@/modelTypes/IProject'
export default class TeamProjectModel extends TeamShareBaseModel implements ITeamProject {
projectId: IProject['id'] = 0
constructor(data: Partial<ITeamProject>) {
super(data)
this.assignData(data)
}
}

View File

@ -6,7 +6,7 @@ import type {ITeam} from '@/modelTypes/ITeam'
/**
* This class is a base class for common team sharing model.
* It is extended in a way so it can be used for namespaces as well for lists.
* It is extended in a way so it can be used for namespaces as well for projects.
*/
export default class TeamShareBaseModel extends AbstractModel<ITeamShareBase> implements ITeamShareBase {
teamId: ITeam['id'] = 0

View File

@ -1,14 +0,0 @@
import UserShareBaseModel from './userShareBase'
import type {IUserList} from '@/modelTypes/IUserList'
import type {IList} from '@/modelTypes/IList'
// This class extends the user share model with a 'rights' parameter which is used in sharing
export default class UserListModel extends UserShareBaseModel implements IUserList {
listId: IList['id'] = 0
constructor(data: Partial<IUserList>) {
super(data)
this.assignData(data)
}
}

14
src/models/userProject.ts Normal file
View File

@ -0,0 +1,14 @@
import UserShareBaseModel from './userShareBase'
import type {IUserProject} from '@/modelTypes/IUserProject'
import type {IProject} from '@/modelTypes/IProject'
// This class extends the user share model with a 'rights' parameter which is used in sharing
export default class UserProjectModel extends UserShareBaseModel implements IUserProject {
projectId: IProject['id'] = 0
constructor(data: Partial<IUserProject>) {
super(data)
this.assignData(data)
}
}

View File

@ -10,7 +10,7 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
discoverableByEmail = false
overdueTasksRemindersEnabled = true
overdueTasksRemindersTime = undefined
defaultListId = undefined
defaultProjectId = undefined
weekStart = 0 as IUserSettings['weekStart']
timezone = ''
language = getCurrentLanguage()

View File

@ -1,82 +0,0 @@
import {test, expect, vi} from 'vitest'
import {getHistory, removeListFromHistory, saveListToHistory} from './listHistory'
test('return an empty history when none was saved', () => {
Storage.prototype.getItem = vi.fn(() => null)
const h = getHistory()
expect(h).toStrictEqual([])
})
test('return a saved history', () => {
const saved = [{id: 1}, {id: 2}]
Storage.prototype.getItem = vi.fn(() => JSON.stringify(saved))
const h = getHistory()
expect(h).toStrictEqual(saved)
})
test('store list in history', () => {
let saved = {}
Storage.prototype.getItem = vi.fn(() => null)
Storage.prototype.setItem = vi.fn((key, lists) => {
saved = lists
})
saveListToHistory({id: 1})
expect(saved).toBe('[{"id":1}]')
})
test('store only the last 5 lists in history', () => {
let saved: string | null = null
Storage.prototype.getItem = vi.fn(() => saved)
Storage.prototype.setItem = vi.fn((key: string, lists: string) => {
saved = lists
})
saveListToHistory({id: 1})
saveListToHistory({id: 2})
saveListToHistory({id: 3})
saveListToHistory({id: 4})
saveListToHistory({id: 5})
saveListToHistory({id: 6})
expect(saved).toBe('[{"id":6},{"id":5},{"id":4},{"id":3},{"id":2}]')
})
test('don\'t store the same list twice', () => {
let saved: string | null = null
Storage.prototype.getItem = vi.fn(() => saved)
Storage.prototype.setItem = vi.fn((key: string, lists: string) => {
saved = lists
})
saveListToHistory({id: 1})
saveListToHistory({id: 1})
expect(saved).toBe('[{"id":1}]')
})
test('move a list to the beginning when storing it multiple times', () => {
let saved: string | null = null
Storage.prototype.getItem = vi.fn(() => saved)
Storage.prototype.setItem = vi.fn((key: string, lists: string) => {
saved = lists
})
saveListToHistory({id: 1})
saveListToHistory({id: 2})
saveListToHistory({id: 1})
expect(saved).toBe('[{"id":1},{"id":2}]')
})
test('remove list from history', () => {
let saved: string | null = '[{"id": 1}]'
Storage.prototype.getItem = vi.fn(() => null)
Storage.prototype.setItem = vi.fn((key: string, lists: string) => {
saved = lists
})
Storage.prototype.removeItem = vi.fn((key: string) => {
saved = null
})
removeListFromHistory({id: 1})
expect(saved).toBeNull()
})

View File

@ -1,51 +0,0 @@
export interface ListHistory {
id: number;
}
export function getHistory(): ListHistory[] {
const savedHistory = localStorage.getItem('listHistory')
if (savedHistory === null) {
return []
}
return JSON.parse(savedHistory)
}
function saveHistory(history: ListHistory[]) {
if (history.length === 0) {
localStorage.removeItem('listHistory')
return
}
localStorage.setItem('listHistory', JSON.stringify(history))
}
export function saveListToHistory(list: ListHistory) {
const history: ListHistory[] = getHistory()
// Remove the element if it already exists in history, preventing duplicates and essentially moving it to the beginning
history.forEach((l, i) => {
if (l.id === list.id) {
history.splice(i, 1)
}
})
// Add the new list to the beginning of the list
history.unshift(list)
if (history.length > 5) {
history.pop()
}
saveHistory(history)
}
export function removeListFromHistory(list: ListHistory) {
const history: ListHistory[] = getHistory()
history.forEach((l, i) => {
if (l.id === list.id) {
history.splice(i, 1)
}
})
saveHistory(history)
}

View File

@ -21,14 +21,14 @@ describe('Parse Task Text', () => {
})
it('should not parse text when disabled', () => {
const text = 'Lorem Ipsum today *label +list !2 @user'
const text = 'Lorem Ipsum today *label +project !2 @user'
const result = parseTaskText(text, PrefixMode.Disabled)
expect(result.text).toBe(text)
})
it('should parse text in todoist mode when configured', () => {
const result = parseTaskText('Lorem Ipsum today @label #list !2 +user', PrefixMode.Todoist)
const result = parseTaskText('Lorem Ipsum today @label #project !2 +user', PrefixMode.Todoist)
expect(result.text).toBe('Lorem Ipsum')
const now = new Date()
@ -37,7 +37,7 @@ describe('Parse Task Text', () => {
expect(result?.date?.getDate()).toBe(now.getDate())
expect(result.labels).toHaveLength(1)
expect(result.labels[0]).toBe('label')
expect(result.list).toBe('list')
expect(result.project).toBe('project')
expect(result.priority).toBe(2)
expect(result.assignees).toHaveLength(1)
expect(result.assignees[0]).toBe('user')
@ -575,36 +575,36 @@ describe('Parse Task Text', () => {
})
})
describe('List', () => {
it('should parse a list', () => {
const result = parseTaskText('Lorem Ipsum +list')
describe('Project', () => {
it('should parse a project', () => {
const result = parseTaskText('Lorem Ipsum +project')
expect(result.text).toBe('Lorem Ipsum')
expect(result.list).toBe('list')
expect(result.project).toBe('project')
})
it('should parse a list with a space in it', () => {
const result = parseTaskText(`Lorem Ipsum +'list with long name'`)
it('should parse a project with a space in it', () => {
const result = parseTaskText(`Lorem Ipsum +'project with long name'`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.list).toBe('list with long name')
expect(result.project).toBe('project with long name')
})
it('should parse a list with a space in it and "', () => {
const result = parseTaskText(`Lorem Ipsum +"list with long name"`)
it('should parse a project with a space in it and "', () => {
const result = parseTaskText(`Lorem Ipsum +"project with long name"`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.list).toBe('list with long name')
expect(result.project).toBe('project with long name')
})
it('should parse only the first list', () => {
const result = parseTaskText(`Lorem Ipsum +list1 +list2 +list3`)
it('should parse only the first project', () => {
const result = parseTaskText(`Lorem Ipsum +project1 +project2 +project3`)
expect(result.text).toBe('Lorem Ipsum +list2 +list3')
expect(result.list).toBe('list1')
expect(result.text).toBe('Lorem Ipsum +project2 +project3')
expect(result.project).toBe('project1')
})
it('should parse a list that\'s called like a date as list', () => {
it('should parse a project that\'s called like a date as project', () => {
const result = parseTaskText(`Lorem Ipsum +today`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.list).toBe('today')
expect(result.project).toBe('today')
})
})

View File

@ -4,14 +4,14 @@ import {REPEAT_TYPES, type IRepeatAfter, type IRepeatType} from '@/types/IRepeat
const VIKUNJA_PREFIXES: Prefixes = {
label: '*',
list: '+',
project: '+',
priority: '!',
assignee: '@',
}
const TODOIST_PREFIXES: Prefixes = {
label: '@',
list: '#',
project: '#',
priority: '!',
assignee: '+',
}
@ -37,7 +37,7 @@ export interface ParsedTaskText {
text: string,
date: Date | null,
labels: string[],
list: string | null,
project: string | null,
priority: number | null,
assignees: string[],
repeats: IRepeatAfter | null,
@ -45,13 +45,13 @@ export interface ParsedTaskText {
interface Prefixes {
label: string,
list: string,
project: string,
priority: string,
assignee: string,
}
/**
* Parses task text for dates, assignees, labels, lists, priorities and returns an object with all found intents.
* Parses task text for dates, assignees, labels, projects, priorities and returns an object with all found intents.
*
* @param text
*/
@ -60,7 +60,7 @@ export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMod
text: text,
date: null,
labels: [],
list: null,
project: null,
priority: null,
assignees: [],
repeats: null,
@ -74,9 +74,9 @@ export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMod
result.labels = getItemsFromPrefix(text, prefixes.label)
result.text = cleanupItemText(result.text, result.labels, prefixes.label)
const lists: string[] = getItemsFromPrefix(result.text, prefixes.list)
result.list = lists.length > 0 ? lists[0] : null
result.text = result.list !== null ? cleanupItemText(result.text, [result.list], prefixes.list) : result.text
const projects: string[] = getItemsFromPrefix(result.text, prefixes.project)
result.project = projects.length > 0 ? projects[0] : null
result.text = result.project !== null ? cleanupItemText(result.text, [result.project], prefixes.project) : result.text
result.priority = getPriority(result.text, prefixes.priority)
result.text = result.priority !== null ? cleanupItemText(result.text, [String(result.priority)], prefixes.priority) : result.text
@ -269,7 +269,7 @@ const cleanupItemText = (text: string, items: string[], prefix: string): string
const cleanupResult = (result: ParsedTaskText, prefixes: Prefixes): ParsedTaskText => {
result.text = cleanupItemText(result.text, result.labels, prefixes.label)
result.text = result.list !== null ? cleanupItemText(result.text, [result.list], prefixes.list) : result.text
result.text = result.project !== null ? cleanupItemText(result.text, [result.project], prefixes.project) : result.text
result.text = result.priority !== null ? cleanupItemText(result.text, [String(result.priority)], prefixes.priority) : result.text
result.text = cleanupItemText(result.text, result.assignees, prefixes.assignee)
result.text = result.text.trim()

View File

@ -0,0 +1,82 @@
import {test, expect, vi} from 'vitest'
import {getHistory, removeProjectFromHistory, saveProjectToHistory} from './projectHistory'
test('return an empty history when none was saved', () => {
Storage.prototype.getItem = vi.fn(() => null)
const h = getHistory()
expect(h).toStrictEqual([])
})
test('return a saved history', () => {
const saved = [{id: 1}, {id: 2}]
Storage.prototype.getItem = vi.fn(() => JSON.stringify(saved))
const h = getHistory()
expect(h).toStrictEqual(saved)
})
test('store project in history', () => {
let saved = {}
Storage.prototype.getItem = vi.fn(() => null)
Storage.prototype.setItem = vi.fn((key, projects) => {
saved = projects
})
saveProjectToHistory({id: 1})
expect(saved).toBe('[{"id":1}]')
})
test('store only the last 5 projects in history', () => {
let saved: string | null = null
Storage.prototype.getItem = vi.fn(() => saved)
Storage.prototype.setItem = vi.fn((key: string, projects: string) => {
saved = projects
})
saveProjectToHistory({id: 1})
saveProjectToHistory({id: 2})
saveProjectToHistory({id: 3})
saveProjectToHistory({id: 4})
saveProjectToHistory({id: 5})
saveProjectToHistory({id: 6})
expect(saved).toBe('[{"id":6},{"id":5},{"id":4},{"id":3},{"id":2}]')
})
test('don\'t store the same project twice', () => {
let saved: string | null = null
Storage.prototype.getItem = vi.fn(() => saved)
Storage.prototype.setItem = vi.fn((key: string, projects: string) => {
saved = projects
})
saveProjectToHistory({id: 1})
saveProjectToHistory({id: 1})
expect(saved).toBe('[{"id":1}]')
})
test('move a project to the beginning when storing it multiple times', () => {
let saved: string | null = null
Storage.prototype.getItem = vi.fn(() => saved)
Storage.prototype.setItem = vi.fn((key: string, projects: string) => {
saved = projects
})
saveProjectToHistory({id: 1})
saveProjectToHistory({id: 2})
saveProjectToHistory({id: 1})
expect(saved).toBe('[{"id":1},{"id":2}]')
})
test('remove project from history', () => {
let saved: string | null = '[{"id": 1}]'
Storage.prototype.getItem = vi.fn(() => null)
Storage.prototype.setItem = vi.fn((key: string, projects: string) => {
saved = projects
})
Storage.prototype.removeItem = vi.fn((key: string) => {
saved = null
})
removeProjectFromHistory({id: 1})
expect(saved).toBeNull()
})

View File

@ -0,0 +1,51 @@
export interface ProjectHistory {
id: number;
}
export function getHistory(): ProjectHistory[] {
const savedHistory = localStorage.getItem('projectHistory')
if (savedHistory === null) {
return []
}
return JSON.parse(savedHistory)
}
function saveHistory(history: ProjectHistory[]) {
if (history.length === 0) {
localStorage.removeItem('projectHistory')
return
}
localStorage.setItem('projectHistory', JSON.stringify(history))
}
export function saveProjectToHistory(project: ProjectHistory) {
const history: ProjectHistory[] = getHistory()
// Remove the element if it already exists in history, preventing duplicates and essentially moving it to the beginning
history.forEach((l, i) => {
if (l.id === project.id) {
history.splice(i, 1)
}
})
// Add the new project to the beginning of the project
history.unshift(project)
if (history.length > 5) {
history.pop()
}
saveHistory(history)
}
export function removeProjectFromHistory(project: ProjectHistory) {
const history: ProjectHistory[] = getHistory()
history.forEach((l, i) => {
if (l.id === project.id) {
history.splice(i, 1)
}
})
saveHistory(history)
}

View File

@ -2,12 +2,12 @@ import { createRouter, createWebHistory } from 'vue-router'
import type { RouteLocation } from 'vue-router'
import {saveLastVisited} from '@/helpers/saveLastVisited'
import {saveListView, getListView} from '@/helpers/saveListView'
import {saveProjectView, getProjectView} from '@/helpers/saveProjectView'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import {getNextWeekDate} from '@/helpers/time/getNextWeekDate'
import {setTitle} from '@/helpers/setTitle'
import {useListStore} from '@/stores/lists'
import {useProjectStore} from '@/stores/projects'
import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
@ -22,31 +22,31 @@ import DataExportDownload from '../views/user/DataExportDownload.vue'
// Tasks
import UpcomingTasksComponent from '../views/tasks/ShowTasks.vue'
import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth.vue'
import ListNamespaces from '../views/namespaces/ListNamespaces.vue'
import ProjectNamespaces from '../views/namespaces/ListNamespaces.vue'
const TaskDetailView = () => import('../views/tasks/TaskDetailView.vue')
// Team Handling
import ListTeamsComponent from '../views/teams/ListTeams.vue'
import ProjectTeamsComponent from '../views/teams/ListTeams.vue'
// Label Handling
import ListLabelsComponent from '../views/labels/ListLabels.vue'
import ProjectLabelsComponent from '../views/labels/ListLabels.vue'
import NewLabelComponent from '../views/labels/NewLabel.vue'
// Migration
const MigrationComponent = () => import('@/views/migrate/Migration.vue')
const MigrationHandlerComponent = () => import('@/views/migrate/MigrationHandler.vue')
// List Views
import ListList from '../views/list/ListList.vue'
const ListGantt = () => import('../views/list/ListGantt.vue')
import ListTable from '../views/list/ListTable.vue'
import ListKanban from '../views/list/ListKanban.vue'
const ListInfo = () => import('../views/list/ListInfo.vue')
// Project Views
import ProjectList from '../views/project/ProjectList.vue'
const ProjectGantt = () => import('../views/project/ProjectGantt.vue')
import ProjectTable from '../views/project/ProjectTable.vue'
import ProjectKanban from '../views/project/ProjectKanban.vue'
const ProjectInfo = () => import('../views/project/ProjectInfo.vue')
// List Settings
import ListSettingEdit from '../views/list/settings/edit.vue'
import ListSettingBackground from '../views/list/settings/background.vue'
import ListSettingDuplicate from '../views/list/settings/duplicate.vue'
import ListSettingShare from '../views/list/settings/share.vue'
import ListSettingDelete from '../views/list/settings/delete.vue'
import ListSettingArchive from '../views/list/settings/archive.vue'
// Project Settings
import ProjectSettingEdit from '../views/project/settings/edit.vue'
import ProjectSettingBackground from '../views/project/settings/background.vue'
import ProjectSettingDuplicate from '../views/project/settings/duplicate.vue'
import ProjectSettingShare from '../views/project/settings/share.vue'
import ProjectSettingDelete from '../views/project/settings/delete.vue'
import ProjectSettingArchive from '../views/project/settings/archive.vue'
// Namespace Settings
import NamespaceSettingEdit from '../views/namespaces/settings/edit.vue'
@ -71,8 +71,8 @@ const UserSettingsGeneralComponent = () => import('../views/user/settings/Genera
const UserSettingsPasswordUpdateComponent = () => import('../views/user/settings/PasswordUpdate.vue')
const UserSettingsTOTPComponent = () => import('../views/user/settings/TOTP.vue')
// List Handling
const NewListComponent = () => import('../views/list/NewList.vue')
// Project Handling
const NewProjectComponent = () => import('../views/project/NewProject.vue')
// Namespace Handling
const NewNamespaceComponent = () => import('../views/namespaces/NewNamespace.vue')
@ -206,7 +206,7 @@ const router = createRouter({
{
path: '/namespaces',
name: 'namespaces.index',
component: ListNamespaces,
component: ProjectNamespaces,
},
{
path: '/namespaces/new',
@ -269,147 +269,147 @@ const router = createRouter({
}),
},
{
path: '/lists/new/:namespaceId/',
name: 'list.create',
component: NewListComponent,
path: '/projects/new/:namespaceId/',
name: 'project.create',
component: NewProjectComponent,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/edit',
name: 'list.settings.edit',
component: ListSettingEdit,
props: route => ({ listId: Number(route.params.listId as string) }),
path: '/projects/:projectId/settings/edit',
name: 'project.settings.edit',
component: ProjectSettingEdit,
props: route => ({ projectId: Number(route.params.projectId as string) }),
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/background',
name: 'list.settings.background',
component: ListSettingBackground,
path: '/projects/:projectId/settings/background',
name: 'project.settings.background',
component: ProjectSettingBackground,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/duplicate',
name: 'list.settings.duplicate',
component: ListSettingDuplicate,
path: '/projects/:projectId/settings/duplicate',
name: 'project.settings.duplicate',
component: ProjectSettingDuplicate,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/share',
name: 'list.settings.share',
component: ListSettingShare,
path: '/projects/:projectId/settings/share',
name: 'project.settings.share',
component: ProjectSettingShare,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/delete',
name: 'list.settings.delete',
component: ListSettingDelete,
path: '/projects/:projectId/settings/delete',
name: 'project.settings.delete',
component: ProjectSettingDelete,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/archive',
name: 'list.settings.archive',
component: ListSettingArchive,
path: '/projects/:projectId/settings/archive',
name: 'project.settings.archive',
component: ProjectSettingArchive,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/edit',
path: '/projects/:projectId/settings/edit',
name: 'filter.settings.edit',
component: FilterEdit,
meta: {
showAsModal: true,
},
props: route => ({ listId: Number(route.params.listId as string) }),
props: route => ({ projectId: Number(route.params.projectId as string) }),
},
{
path: '/lists/:listId/settings/delete',
path: '/projects/:projectId/settings/delete',
name: 'filter.settings.delete',
component: FilterDelete,
meta: {
showAsModal: true,
},
props: route => ({ listId: Number(route.params.listId as string) }),
props: route => ({ projectId: Number(route.params.projectId as string) }),
},
{
path: '/lists/:listId/info',
name: 'list.info',
component: ListInfo,
path: '/projects/:projectId/info',
name: 'project.info',
component: ProjectInfo,
meta: {
showAsModal: true,
},
props: route => ({ listId: Number(route.params.listId as string) }),
props: route => ({ projectId: Number(route.params.projectId as string) }),
},
{
path: '/lists/:listId',
name: 'list.index',
path: '/projects/:projectId',
name: 'project.index',
redirect(to) {
// Redirect the user to list view by default
// Redirect the user to project view by default
const savedListView = getListView(to.params.listId)
console.debug('Replaced list view with', savedListView)
const savedProjectView = getProjectView(to.params.projectId)
console.debug('Replaced project view with', savedProjectView)
return {
name: router.hasRoute(savedListView)
? savedListView
: 'list.list',
params: {listId: to.params.listId},
name: router.hasRoute(savedProjectView)
? savedProjectView
: 'project.project',
params: {projectId: to.params.projectId},
}
},
},
{
path: '/lists/:listId/list',
name: 'list.list',
component: ListList,
beforeEnter: (to) => saveListView(to.params.listId, to.name),
props: route => ({ listId: Number(route.params.listId as string) }),
path: '/projects/:projectId/project',
name: 'project.project',
component: ProjectList,
beforeEnter: (to) => saveProjectView(to.params.projectId, to.name),
props: route => ({ projectId: Number(route.params.projectId as string) }),
},
{
path: '/lists/:listId/gantt',
name: 'list.gantt',
component: ListGantt,
beforeEnter: (to) => saveListView(to.params.listId, to.name),
path: '/projects/:projectId/gantt',
name: 'project.gantt',
component: ProjectGantt,
beforeEnter: (to) => saveProjectView(to.params.projectId, to.name),
// FIXME: test if `useRoute` would be the same. If it would use it instead.
props: route => ({route}),
},
{
path: '/lists/:listId/table',
name: 'list.table',
component: ListTable,
beforeEnter: (to) => saveListView(to.params.listId, to.name),
props: route => ({ listId: Number(route.params.listId as string) }),
path: '/projects/:projectId/table',
name: 'project.table',
component: ProjectTable,
beforeEnter: (to) => saveProjectView(to.params.projectId, to.name),
props: route => ({ projectId: Number(route.params.projectId as string) }),
},
{
path: '/lists/:listId/kanban',
name: 'list.kanban',
component: ListKanban,
path: '/projects/:projectId/kanban',
name: 'project.kanban',
component: ProjectKanban,
beforeEnter: (to) => {
saveListView(to.params.listId, to.name)
saveProjectView(to.params.projectId, to.name)
// Properly set the page title when a task popup is closed
const listStore = useListStore()
const listFromStore = listStore.getListById(Number(to.params.listId))
if(listFromStore) {
setTitle(listFromStore.title)
const projectStore = useProjectStore()
const projectFromStore = projectStore.getProjectById(Number(to.params.projectId))
if(projectFromStore) {
setTitle(projectFromStore.title)
}
},
props: route => ({ listId: Number(route.params.listId as string) }),
props: route => ({ projectId: Number(route.params.projectId as string) }),
},
{
path: '/teams',
name: 'teams.index',
component: ListTeamsComponent,
component: ProjectTeamsComponent,
},
{
path: '/teams/new',
@ -427,7 +427,7 @@ const router = createRouter({
{
path: '/labels',
name: 'labels.index',
component: ListLabelsComponent,
component: ProjectLabelsComponent,
},
{
path: '/labels/new',

View File

@ -1,13 +1,13 @@
import AbstractService from './abstractService'
import BackgroundImageModel from '../models/backgroundImage'
import ListModel from '@/models/list'
import ProjectModel from '@/models/project'
import type { IBackgroundImage } from '@/modelTypes/IBackgroundImage'
export default class BackgroundUnsplashService extends AbstractService<IBackgroundImage> {
constructor() {
super({
getAll: '/backgrounds/unsplash/search',
update: '/lists/{listId}/backgrounds/unsplash',
update: '/projects/{projectId}/backgrounds/unsplash',
})
}
@ -16,7 +16,7 @@ export default class BackgroundUnsplashService extends AbstractService<IBackgrou
}
modelUpdateFactory(data) {
return new ListModel(data)
return new ProjectModel(data)
}
async thumb(model) {

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