Compare commits

...
This repository has been archived on 2024-02-08. You can view files and clone it, but cannot push or open issues or pull requests.

80 Commits

Author SHA1 Message Date
kolaente 634af32400
wip: use map instead of object to store projects 2023-04-10 17:39:29 +02:00
kolaente d694f168b2
feat: add setting for infinite nesting
continuous-integration/drone/pr Build is failing Details
2023-04-03 13:53:28 +02:00
kolaente 154d85bd3f
fix: use menu tag everywhere
continuous-integration/drone/pr Build is failing Details
2023-04-03 12:09:58 +02:00
kolaente 4d5d1b3e31
fix: collapsing child projects
continuous-integration/drone/pr Build is failing Details
2023-04-03 11:29:11 +02:00
kolaente 4868661dc4
feat: don't use child_projects property from api
continuous-integration/drone/pr Build is failing Details
2023-04-03 11:27:31 +02:00
kolaente 74f7aa0f53
chore: format
continuous-integration/drone/pr Build is failing Details
2023-04-03 11:19:46 +02:00
kolaente ba48426103
fix: don't show child projects when the project is only a favorite 2023-04-03 11:18:10 +02:00
kolaente 1889abfa9b
chore: move more logic to ProjectsNavigationItem.vue
continuous-integration/drone/pr Build is failing Details
2023-04-02 19:51:44 +02:00
kolaente 637523a77f
chore: move ProjectsNavigationWrapper back to navigation.vue
continuous-integration/drone/pr Build is failing Details
2023-04-02 17:21:08 +02:00
kolaente 3904626529
feat: load all projects earlier than in the navigation and use the loading state of the store
continuous-integration/drone/pr Build is failing Details
2023-04-02 14:52:28 +02:00
kolaente 1fd14224b8
chore: move loading styles to variant into the component
continuous-integration/drone/pr Build is failing Details
2023-04-02 14:44:13 +02:00
kolaente 24420cb14e
chore: remove old comment
continuous-integration/drone/pr Build is failing Details
2023-04-01 22:03:53 +02:00
kolaente ad4eb25eac
chore: use <menu> instead of <ul>
continuous-integration/drone/pr Build is failing Details
2023-04-01 22:02:08 +02:00
kolaente f451f69dc6
fix: indention
continuous-integration/drone/pr Build is failing Details
2023-04-01 21:57:32 +02:00
kolaente 0449242f87
chore: improve prop type definition
continuous-integration/drone/pr Build is failing Details
2023-04-01 21:56:43 +02:00
kolaente 1416a6852c
chore: only apply padding where needed
continuous-integration/drone/pr Build is failing Details
2023-04-01 21:52:52 +02:00
kolaente 8a3e88528b
chore: remove old todo 2023-04-01 21:48:46 +02:00
kolaente b2d34ccb11
feat: move navigation item to component
continuous-integration/drone/pr Build is failing Details
2023-04-01 21:47:31 +02:00
kolaente 81dd64563e
chore: use long variable name 2023-04-01 21:31:03 +02:00
kolaente 24604ca3ec
chore: rename alias 2023-04-01 21:30:25 +02:00
kolaente 5367573ddc
chore: remove unused class
continuous-integration/drone/pr Build is failing Details
2023-04-01 16:54:24 +02:00
kolaente 5027ff460c
fix: remove leftover suspense
continuous-integration/drone/pr Build is failing Details
2023-04-01 16:48:15 +02:00
kolaente 33b8a96bb8
chore: use klona to clone project objet
continuous-integration/drone/pr Build is failing Details
2023-04-01 15:20:00 +02:00
kolaente 733f0c1e19
fix: passing readonly projects data to navigation
continuous-integration/drone/pr Build is failing Details
2023-04-01 11:24:22 +02:00
kolaente c915652e65
chore: move loader class 2023-04-01 11:24:22 +02:00
kolaente e9bc1e9253
chore: export favorite projects from store 2023-04-01 11:24:22 +02:00
kolaente c36103a5ca
chore: remove unnecessary map 2023-04-01 11:24:22 +02:00
kolaente 1f8f13846a
chore: export not archived root projects 2023-04-01 11:24:22 +02:00
kolaente c629e2499d
fix: show favorite on hover 2023-04-01 11:24:22 +02:00
kolaente aa77f91b9c
fix: don't show > for top-level projects 2023-04-01 11:24:22 +02:00
kolaente 6192746602
feat: allow selecting a parent project when editing a project 2023-04-01 11:24:22 +02:00
kolaente c092ae4043
feat: allow selecting a parent project when creating a project 2023-04-01 11:24:22 +02:00
kolaente 99d5bfd898
feat: allow selecting a parent project when duplicating a project 2023-04-01 11:24:22 +02:00
kolaente e2a7f9aff5
feat: don't handle child projects and instead only save the ids 2023-04-01 11:24:21 +02:00
kolaente 16a300d9c4
fix: make computed side-effect free 2023-04-01 11:24:21 +02:00
kolaente e2dd82a7e6
chore: refactor get parents project and move to projects store 2023-04-01 11:24:21 +02:00
kolaente fc72f47751
feat: show all parent projects in project search 2023-04-01 11:24:21 +02:00
kolaente e10d1a63e5
feat: show all parent projects in task detail view 2023-04-01 11:24:21 +02:00
kolaente f6e2648b71
fix: add await 2023-04-01 11:24:21 +02:00
kolaente a627960191
fix(filters): load projects after updating a filter 2023-04-01 11:24:21 +02:00
kolaente 8ef2d8957e
fix(filters): load projects after deleting a filter 2023-04-01 11:24:21 +02:00
kolaente 0732d6befc
fix(filters): load projects after creating a filter 2023-04-01 11:24:21 +02:00
kolaente 4dba97c958
chore(task): move toggleFavorite to store 2023-04-01 11:24:21 +02:00
kolaente c1f244089d
feat(projects): move hasProjects check to store 2023-04-01 11:24:20 +02:00
kolaente 6c46f5504a
feat: wrap projects navigation in a <Suspense> so that we can use top level await 2023-04-01 11:24:20 +02:00
kolaente 5702781b6b
chore: use long variable name 2023-04-01 11:24:20 +02:00
kolaente d90458c17a
chore: rename archived message key 2023-04-01 11:24:20 +02:00
kolaente 34238e29df
fix: use correct shortcut to open projects overview 2023-04-01 11:24:20 +02:00
kolaente 9ce963ff75
fix: simplify sort 2023-04-01 11:24:20 +02:00
kolaente 592e58adbd
chore: export projects as array directly from projects store 2023-04-01 11:24:20 +02:00
kolaente fea0aab9fa
chore: rename prop 2023-04-01 11:24:20 +02:00
kolaente fddcae71da
feat(tests): add project tests derived from old namespace tests 2023-04-01 11:24:20 +02:00
kolaente fe5f844baa
fix(projects): make sure the project hierarchy is properly updated when moving projects between parents 2023-04-01 11:24:20 +02:00
kolaente 055d82f22a
feat(navigation): show favorite projects on top 2023-04-01 11:24:20 +02:00
kolaente 89b9dfcb73
fix(navigation): make sure updating a project's state works for sub projects as well. 2023-04-01 11:24:19 +02:00
kolaente c7d81de2e7
fix(navigation): make marking a project as favorite work 2023-04-01 11:24:19 +02:00
kolaente dce448ca9a
fix(navigation): make sure the Favorites project shows up when marking or unmarking a task as favorite 2023-04-01 11:24:19 +02:00
kolaente 4ec8f533d5
fix(navigation): favorites project 2023-04-01 11:24:19 +02:00
kolaente 1db0a86752
fix(task detail view): make project display show the task's project 2023-04-01 11:24:19 +02:00
kolaente f2a4e5eb66
fix: make check if projects are available work again 2023-04-01 11:24:19 +02:00
kolaente 6b971fc9ee
fix: cleanup unused translation strings 2023-04-01 11:24:19 +02:00
kolaente 212041bef2
fix: make tests work again 2023-04-01 11:24:17 +02:00
kolaente 3c38d912f7
chore: cleanup namespace leftovers 2023-04-01 11:24:12 +02:00
kolaente c9fa00f80a
fix(navigation): hide left ul border 2023-04-01 11:24:12 +02:00
kolaente b13a0c67b8
feat(navigation): make dragging a project under another project work 2023-04-01 11:24:12 +02:00
kolaente 63e7b5f5a3
feat(navigation): allow dragging a project out from its parent project 2023-04-01 11:24:12 +02:00
kolaente 9d041cf600
feat(navigation): make dragging a project to a parent work 2023-04-01 11:24:12 +02:00
kolaente 6f5e5ac88f
fix(navigation): hover state of other menu items 2023-04-01 11:24:11 +02:00
kolaente c33a0778e8
feat(navigation): add hiding child projects 2023-04-01 11:24:11 +02:00
kolaente 239c965b3c
feat: translate inbox project title 2023-04-01 11:24:11 +02:00
kolaente a6146f8ad5
chore: format 2023-04-01 11:24:11 +02:00
kolaente 8366dda718
feat(navigation): correctly show child projects 2023-04-01 11:24:11 +02:00
kolaente e0b8116fe4
fix(navigation): make the styles work again 2023-04-01 11:24:11 +02:00
kolaente 0d0e355a25
fix(navigation): watcher 2023-04-01 11:24:11 +02:00
kolaente 5fe350327d
feat: rebuild main navigation so that it works recursively with projects 2023-04-01 11:24:11 +02:00
kolaente 2dfac1063e
fix: remove namespace routes 2023-04-01 11:24:11 +02:00
kolaente 95033662ae
fix: remove namespace store reference 2023-04-01 11:24:11 +02:00
kolaente f357ffc1f7
feat: remove all namespace leftovers 2023-04-01 11:24:10 +02:00
kolaente e9fff13c90
fix: route to create new project 2023-04-01 11:24:10 +02:00
kolaente c27226e40e
feat: move namespaces list to projects list 2023-04-01 11:24:10 +02:00
76 changed files with 898 additions and 2309 deletions

View File

@ -54,6 +54,7 @@ ENV VIKUNJA_LOG_FORMAT main
ENV VIKUNJA_API_URL /api/v1
ENV VIKUNJA_SENTRY_ENABLED false
ENV VIKUNJA_SENTRY_DSN https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480
ENV VIKUNJA_INFINITE_PROJECT_NESTING_ENABLED false
COPY docker/injector.sh /docker-entrypoint.d/50-injector.sh
COPY docker/ipv6-disable.sh /docker-entrypoint.d/60-ipv6-disable.sh

View File

@ -2,7 +2,6 @@ import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import {NamespaceFactory} from '../../factories/namespace'
import {UserProjectFactory} from '../../factories/users_project'
import {BucketFactory} from '../../factories/bucket'
@ -10,7 +9,6 @@ describe('Editor', () => {
createFakeUserAndLogin()
beforeEach(() => {
NamespaceFactory.create(1)
ProjectFactory.create(1)
BucketFactory.create(1)
TaskFactory.truncate()

View File

@ -8,20 +8,20 @@ describe('The Menu', () => {
})
it('Is visible by default on desktop', () => {
cy.get('.namespace-container')
cy.get('.menu-container')
.should('have.class', 'is-active')
})
it('Can be hidden on desktop', () => {
cy.get('button.menu-show-button:visible')
.click()
cy.get('.namespace-container')
cy.get('.menu-container')
.should('not.have.class', 'is-active')
})
it('Is hidden by default on mobile', () => {
cy.viewport('iphone-8')
cy.get('.namespace-container')
cy.get('.menu-container')
.should('not.have.class', 'is-active')
})
@ -29,7 +29,7 @@ describe('The Menu', () => {
cy.viewport('iphone-8')
cy.get('button.menu-show-button:visible')
.click()
cy.get('.namespace-container')
cy.get('.menu-container')
.should('have.class', 'is-active')
})
})

View File

@ -1,145 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {NamespaceFactory} from '../../factories/namespace'
describe('Namepaces', () => {
createFakeUserAndLogin()
let namespaces
beforeEach(() => {
namespaces = NamespaceFactory.create(1)
ProjectFactory.create(1)
})
it('Should be all there', () => {
cy.visit('/namespaces')
cy.get('[data-cy="namespace-title"]')
.should('contain', namespaces[0].title)
})
it('Should create a new Namespace', () => {
const newNamespaceTitle = 'New Namespace'
cy.visit('/namespaces')
cy.get('[data-cy="new-namespace"]')
.should('contain', 'New namespace')
.click()
cy.url()
.should('contain', '/namespaces/new')
cy.get('.card-header-title')
.should('contain', 'New namespace')
cy.get('input.input')
.type(newNamespaceTitle)
cy.get('.button')
.contains('Create')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container')
.should('contain', newNamespaceTitle)
cy.url()
.should('contain', '/namespaces')
})
it('Should rename the namespace all places', () => {
const newNamespaces = NamespaceFactory.create(5)
const newNamespaceName = 'New namespace name'
cy.visit('/namespaces')
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
.click()
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.url()
.should('contain', '/settings/edit')
cy.get('#namespacetext')
.invoke('val')
.should('equal', newNamespaces[0].title) // wait until the namespace data is loaded
cy.get('#namespacetext')
.type(`{selectall}${newNamespaceName}`)
cy.get('footer.card-footer .button')
.contains('Save')
.click()
cy.get('.global-notification', { timeout: 1000 })
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists')
.should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title)
cy.get('[data-cy="namespaces-list"]')
.should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title)
})
it('Should remove a namespace when deleting it', () => {
const newNamespaces = NamespaceFactory.create(5)
cy.visit('/')
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
.click()
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('[data-cy="modalPrimary"]')
.contains('Do it')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists')
.should('not.contain', newNamespaces[0].title)
})
it('Should not show archived projects & namespaces if the filter is not checked', () => {
const n = NamespaceFactory.create(1, {
id: 2,
is_archived: true,
}, false)
ProjectFactory.create(1, {
id: 2,
namespace_id: n[0].id,
}, false)
ProjectFactory.create(1, {
id: 3,
is_archived: true,
}, false)
// Initial
cy.visit('/namespaces')
cy.get('.namespace')
.should('not.contain', 'Archived')
// Show archived
cy.get('[data-cy="show-archived-check"] .fancycheckbox__content')
.should('be.visible')
.click()
cy.get('[data-cy="show-archived-check"] input')
.should('be.checked')
cy.get('.namespace')
.should('contain', 'Archived')
// Don't show archived
cy.get('[data-cy="show-archived-check"] .fancycheckbox__content')
.should('be.visible')
.click()
cy.get('[data-cy="show-archived-check"] input')
.should('not.be.checked')
// Second time visiting after unchecking
cy.visit('/namespaces')
cy.get('[data-cy="show-archived-check"] input')
.should('not.be.checked')
cy.get('.namespace')
.should('not.contain', 'Archived')
})
})

View File

@ -1,9 +1,7 @@
import {ProjectFactory} from '../../factories/project'
import {NamespaceFactory} from '../../factories/namespace'
import {TaskFactory} from '../../factories/task'
export function createProjects() {
NamespaceFactory.create(1)
const projects = ProjectFactory.create(1, {
title: 'First Project'
})

View File

@ -8,37 +8,30 @@ describe('Project History', () => {
prepareProjects()
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') + '/projects*').as('loadProjectArray')
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
const projects = ProjectFactory.create(6)
cy.visit('/')
cy.wait('@loadNamespaces')
cy.wait('@loadProjectArray')
cy.get('body')
.should('not.contain', 'Last viewed')
cy.visit(`/projects/${projects[0].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[1].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[2].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[3].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[4].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[5].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
// cy.visit('/')
// cy.wait('@loadNamespaces')
// Not using cy.visit here to work around the redirect issue fixed in #1337
cy.get('nav.menu.top-menu a')
.contains('Overview')

View File

@ -58,7 +58,6 @@ describe('Project View Project', () => {
})
const projects = ProjectFactory.create(2, {
owner_id: '{increment}',
namespace_id: '{increment}',
})
cy.visit(`/projects/${projects[1].id}/`)

View File

@ -1,6 +1,7 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
describe('Projects', () => {
@ -10,14 +11,11 @@ describe('Projects', () => {
prepareProjects((newProjects) => (projects = newProjects))
it('Should create a new project', () => {
cy.visit('/')
cy.get('.namespace-title .dropdown-trigger')
.click()
cy.get('.namespace-title .dropdown .dropdown-item')
.contains('New project')
cy.visit('/projects')
cy.get('.project-header [data-cy=new-project]')
.click()
cy.url()
.should('contain', '/projects/new/1')
.should('contain', '/projects/new')
cy.get('.card-header-title')
.contains('New project')
cy.get('input.input')
@ -26,7 +24,7 @@ describe('Projects', () => {
.contains('Create')
.click()
cy.get('.global-notification', { timeout: 1000 }) // Waiting until the request to create the new project 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', '/projects/')
@ -56,9 +54,9 @@ describe('Projects', () => {
cy.get('.project-title')
.should('contain', 'First Project')
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.get('#title')
@ -72,21 +70,21 @@ describe('Projects', () => {
cy.get('.project-title')
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child')
cy.get('.menu-container .menu-list li:first-child')
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
cy.visit('/')
cy.get('.card-content')
cy.get('.project-grid')
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
})
it('Should remove a project', () => {
it('Should remove a project when deleting it', () => {
cy.visit(`/projects/${projects[0].id}`)
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
@ -97,15 +95,15 @@ describe('Projects', () => {
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
cy.get('.menu-container .menu-list')
.should('not.contain', projects[0].title)
cy.location('pathname')
.should('equal', '/')
})
it('Should archive a project', () => {
cy.visit(`/projects/${projects[0].id}`)
cy.get('.project-title-dropdown')
.click()
cy.get('.project-title-dropdown .dropdown-menu .dropdown-item')
@ -115,10 +113,59 @@ describe('Projects', () => {
.should('contain.text', 'Archive this project')
cy.get('.modal-content [data-cy=modalPrimary]')
.click()
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
cy.get('.menu-container .menu-list')
.should('not.contain', projects[0].title)
cy.get('main.app-content')
.should('contain.text', 'This project is archived. It is not possible to create new or edit tasks for it.')
})
it('Should show all projects on the projects page', () => {
const projects = ProjectFactory.create(10)
cy.visit('/projects')
projects.forEach(p => {
cy.get('[data-cy="projects-list"]')
.should('contain', p.title)
})
})
it('Should not show archived projects if the filter is not checked', () => {
ProjectFactory.create(1, {
id: 2,
}, false)
ProjectFactory.create(1, {
id: 3,
is_archived: true,
}, false)
// Initial
cy.visit('/projects')
cy.get('.project-grid')
.should('not.contain', 'Archived')
// Show archived
cy.get('[data-cy="show-archived-check"] label.check span')
.should('be.visible')
.click()
cy.get('[data-cy="show-archived-check"] input')
.should('be.checked')
cy.get('.project-grid')
.should('contain', 'Archived')
// Don't show archived
cy.get('[data-cy="show-archived-check"] label.check span')
.should('be.visible')
.click()
cy.get('[data-cy="show-archived-check"] input')
.should('not.be.checked')
// Second time visiting after unchecking
cy.visit('/projects')
cy.get('[data-cy="show-archived-check"] input')
.should('not.be.checked')
cy.get('.project-grid')
.should('not.contain', 'Archived')
})
})

View File

@ -3,12 +3,10 @@ import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {seed} from '../../support/seed'
import {TaskFactory} from '../../factories/task'
import {NamespaceFactory} from '../../factories/namespace'
import {BucketFactory} from '../../factories/bucket'
import {updateUserSettings} from '../../support/updateUserSettings'
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
NamespaceFactory.create(1)
const project = ProjectFactory.create()[0]
BucketFactory.create(1, {
project_id: project.id,
@ -137,8 +135,7 @@ describe('Home Page Task Overview', () => {
cy.visit('/')
cy.get('.home.app-content .content')
.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:')
.should('contain.text', 'Import your projects and tasks from other services into Vikunja:')
})
it('Should not show the cta buttons for new project when there are tasks', () => {

View File

@ -4,7 +4,6 @@ import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import {TaskCommentFactory} from '../../factories/task_comment'
import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {UserProjectFactory} from '../../factories/users_project'
import {TaskAssigneeFactory} from '../../factories/task_assignee'
import {LabelFactory} from '../../factories/labels'
@ -47,13 +46,11 @@ function uploadAttachmentAndVerify(taskId: number) {
describe('Task', () => {
createFakeUserAndLogin()
let namespaces
let projects
let buckets
beforeEach(() => {
// UserFactory.create(1)
namespaces = NamespaceFactory.create(1)
projects = ProjectFactory.create(1)
buckets = BucketFactory.create(1, {
project_id: projects[0].id,
@ -110,7 +107,7 @@ describe('Task', () => {
cy.get('.tasks .task .favorite')
.first()
.click()
cy.get('.menu.namespaces-lists')
cy.get('.menu-container')
.should('contain', 'Favorites')
})
@ -133,7 +130,6 @@ describe('Task', () => {
cy.get('.task-view h1.title.task-id')
.should('contain', '#1')
cy.get('.task-view h6.subtitle')
.should('contain', namespaces[0].title)
.should('contain', projects[0].title)
cy.get('.task-view .details.content.description')
.should('contain', tasks[0].description)
@ -260,7 +256,6 @@ describe('Task', () => {
.click()
cy.get('.task-view h6.subtitle')
.should('contain', namespaces[0].title)
.should('contain', projects[1].title)
cy.get('.global-notification')
.should('contain', 'Success')

View File

@ -1,18 +0,0 @@
import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory'
export class NamespaceFactory extends Factory {
static table = 'namespaces'
static factory() {
const now = new Date()
return {
id: '{increment}',
title: faker.lorem.words(3),
owner_id: 1,
created: now.toISOString(),
updated: now.toISOString(),
}
}
}

View File

@ -11,7 +11,6 @@ export class ProjectFactory extends Factory {
id: '{increment}',
title: faker.lorem.words(3),
owner_id: 1,
namespace_id: 1,
created: now.toISOString(),
updated: now.toISOString(),
}

View File

@ -11,5 +11,6 @@ VIKUNJA_SENTRY_DSN="$(echo "$VIKUNJA_SENTRY_DSN" | sed -r 's/([:;])/\\\1/g')"
sed -ri "s:^(\s*window.API_URL\s*=)\s*.+:\1 '${VIKUNJA_API_URL}':g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.SENTRY_ENABLED\s*=)\s*.+:\1 ${VIKUNJA_SENTRY_ENABLED}:g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.SENTRY_DSN\s*=)\s*.+:\1 '${VIKUNJA_SENTRY_DSN}':g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.INFINITE_PROJECT_NESTING_ENABLED\s*=)\s*.+:\1 '${VIKUNJA_INFINITE_PROJECT_NESTING_ENABLED}':g" /usr/share/nginx/html/index.html
date -uIseconds | xargs echo 'info: started at'

View File

@ -27,6 +27,9 @@
// our sentry instance to notify us of potential problems.
window.SENTRY_ENABLED = false
window.SENTRY_DSN = 'https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480'
// If enabled, allows the user to nest projects infinitely, instead of the default 2 levels.
// This setting might change in the future or be removed completely.
window.INFINITE_PROJECT_NESTING_ENABLED = false
</script>
</body>
</html>

View File

@ -61,6 +61,7 @@
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"codemirror": "5.65.12",
"core-js": "^3.30.0",
"date-fns": "2.29.3",
"dayjs": "1.11.7",
"dompurify": "3.0.1",

View File

@ -54,6 +54,9 @@ dependencies:
codemirror:
specifier: 5.65.12
version: 5.65.12
core-js:
specifier: ^3.30.0
version: 3.30.0
date-fns:
specifier: 2.29.3
version: 2.29.3
@ -4236,7 +4239,7 @@ packages:
'@babel/core': 7.21.3
'@babel/preset-env': 7.20.2(@babel/core@7.21.3)
browserslist: 4.21.5
core-js: 3.29.1
core-js: 3.30.0
magic-string: 0.30.0
regenerator-runtime: 0.13.11
systemjs: 6.14.0
@ -6081,10 +6084,9 @@ packages:
browserslist: 4.21.5
dev: true
/core-js@3.29.1:
resolution: {integrity: sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==}
/core-js@3.30.0:
resolution: {integrity: sha512-hQotSSARoNh1mYPi9O2YaWeiq/cEB95kOrFb4NCrO4RIFt1qqNpKsaE+vy/L3oiqvND5cThqXzUU3r9F7Efztg==}
requiresBuild: true
dev: true
/core-util-is@1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}

View File

@ -0,0 +1,113 @@
<template>
<draggable
v-model="availableProjects"
v-bind="dragOptions"
group="projects"
@start="() => drag = true"
@end="saveProjectPosition"
handle=".handle"
tag="menu"
item-key="id"
:disabled="!canEditOrder"
:component-data="{
type: 'transition-group',
name: !drag ? 'flip-list' : null,
class: [
'menu-list can-be-hidden',
{ 'dragging-disabled': !canEditOrder }
]
}"
>
<template #item="{element: project}">
<ProjectsNavigationItem
:project="project"
:is-loading="projectUpdating[project.id]"
:can-collapse="canCollapse"
:level="level"
/>
</template>
</draggable>
</template>
<script lang="ts" setup>
import {ref, watch} from 'vue'
import draggable from 'zhyswan-vuedraggable'
import type {SortableEvent} from 'sortablejs'
import ProjectsNavigationItem from '@/components/home/ProjectsNavigationItem.vue'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import type {IProject} from '@/modelTypes/IProject'
import {useProjectStore} from '@/stores/projects'
const props = defineProps<{
modelValue?: Map<number, IProject>,
canEditOrder: boolean,
canCollapse?: boolean,
level?: number,
}>()
const emit = defineEmits(['update:modelValue'])
const drag = ref(false)
const dragOptions = {
animation: 100,
ghostClass: 'ghost',
}
const projectStore = useProjectStore()
// Vue draggable will modify the projects list as it changes their position which will not work on a prop.
// Hence, we'll clone the prop and work on the clone.
const availableProjects = ref<Map<number, IProject>>(new Map())
watch(
() => props.modelValue,
projects => {
availableProjects.value = projects || new Map<number, IProject>()
},
{immediate: true},
)
const projectUpdating = ref<{ [id: IProject['id']]: boolean }>({})
async function saveProjectPosition(e: SortableEvent) {
if (!e.newIndex && e.newIndex !== 0) return
const projectsActive = availableProjects.value
// 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 === projectsActive.size > 0 ? e.newIndex - 1 : e.newIndex
const projectId = parseInt(e.item.dataset.projectId)
const project = projectStore.getProjectById(projectId)
const parentProjectId = e.to.parentNode.dataset.projectId ? parseInt(e.to.parentNode.dataset.projectId) : 0
const projectBefore = projectsActive.get(newIndex - 1) ?? null
const projectAfter = projectsActive.get(newIndex + 1) ?? null
projectUpdating.value[project.id] = true
const position = calculateItemPosition(
projectBefore !== null ? projectBefore.position : null,
projectAfter !== null ? projectAfter.position : null,
)
if (project.parentProjectId !== parentProjectId && project.parentProjectId > 0) {
const parentProject = projectStore.getProjectById(project.parentProjectId)
const childProjectIndex = parentProject.childProjectIds.findIndex(pId => pId === project.id)
parentProject.childProjectIds.splice(childProjectIndex, 1)
}
try {
// create a copy of the project in order to not violate pinia manipulation
await projectStore.updateProject({
...project,
position,
parentProjectId,
})
emit('update:modelValue', availableProjects.value)
} finally {
projectUpdating.value[project.id] = false
}
}
</script>

View File

@ -0,0 +1,126 @@
<template>
<li
class="list-menu loader-container is-loading-small"
:class="{'is-loading': isLoading}"
:data-project-id="project.id"
>
<section>
<BaseButton
v-if="canCollapse && childProjects?.length > 0"
@click="childProjectsOpen = !childProjectsOpen"
class="collapse-project-button"
>
<icon icon="chevron-down" :class="{ 'project-is-collapsed': !childProjectsOpen }"/>
</BaseButton>
<span class="collapse-project-button-placeholder" v-else></span>
<BaseButton
:to="{ name: 'project.index', params: { projectId: project.id} }"
class="list-menu-link"
:class="{'router-link-exact-active': currentProject.id === project.id}"
>
<span class="icon menu-item-icon handle">
<icon icon="grip-lines"/>
</span>
<ColorBubble
v-if="project.hexColor !== ''"
:color="project.hexColor"
class="mr-1"
/>
<span class="list-menu-title">{{ getProjectTitle(project) }}</span>
</BaseButton>
<BaseButton
v-if="project.id > 0"
class="favorite"
:class="{'is-favorite': project.isFavorite}"
@click="projectStore.toggleProjectFavorite(project)"
>
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']"/>
</BaseButton>
<ProjectSettingsDropdown class="menu-list-dropdown" :project="project" v-if="project.id > 0">
<template #trigger="{toggleOpen}">
<BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon"/>
</BaseButton>
</template>
</ProjectSettingsDropdown>
<span class="list-setting-spacer" v-else></span>
</section>
<ProjectsNavigation
v-if="canNestDeeper && childProjectsOpen && canCollapse"
v-model="childProjects"
:can-edit-order="true"
:can-collapse="canCollapse"
:level="level + 1"
/>
</li>
</template>
<script setup lang="ts">
import {computed, watch, ref} from 'vue'
import {useProjectStore} from '@/stores/projects'
import {useBaseStore} from '@/stores/base'
import type {IProject} from '@/modelTypes/IProject'
import BaseButton from '@/components/base/BaseButton.vue'
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import ColorBubble from '@/components/misc/colorBubble.vue'
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
const props = defineProps<{
project: IProject,
isLoading?: boolean,
canCollapse?: boolean,
level?: number,
}>()
const projectStore = useProjectStore()
const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject)
const childProjectsOpen = ref(true)
const childProjects = computed(() => {
if (!canNestDeeper.value) {
return []
}
return projectStore.getChildProjects(props.project.id)
.sort((a, b) => a.position - b.position)
})
const canNestDeeper = computed(() => {
if (props.level < 2) {
return true
}
return props.level >= 2 && window.INFINITE_PROJECT_NESTING_ENABLED
})
</script>
<style lang="scss" scoped>
.list-setting-spacer {
width: 5rem;
flex-shrink: 0;
}
.project-is-collapsed {
transform: rotate(-90deg);
}
.favorite {
transition: opacity $transition, color $transition;
opacity: 0;
&:hover,
&.is-favorite {
opacity: 1;
color: var(--warning);
}
}
.list-menu:hover > section > .favorite {
opacity: 1;
}
</style>

View File

@ -69,6 +69,7 @@ import BaseButton from '@/components/base/BaseButton.vue'
import {useBaseStore} from '@/stores/base'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
import {useRouteWithModal} from '@/composables/useRouteWithModal'
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
@ -94,14 +95,13 @@ watch(() => route.name as string, (routeName) => {
(
[
'home',
'namespace.edit',
'teams.index',
'teams.edit',
'tasks.range',
'labels.index',
'migrate.start',
'migrate.wunderlist',
'namespaces.index',
'projects.index',
].includes(routeName) ||
routeName.startsWith('user.settings')
)
@ -116,6 +116,9 @@ useRenewTokenOnFocus()
const labelStore = useLabelStore()
labelStore.loadAllLabels()
const projectStore = useProjectStore()
projectStore.loadProjects()
</script>
<style lang="scss" scoped>

View File

@ -1,10 +1,10 @@
<template>
<aside :class="{'is-active': menuActive}" class="namespace-container">
<aside :class="{'is-active': menuActive}" class="menu-container">
<nav class="menu top-menu">
<router-link :to="{name: 'home'}" class="logo">
<Logo width="164" height="48"/>
</router-link>
<ul class="menu-list">
<menu class="menu-list other-menu-items">
<li>
<router-link :to="{ name: 'home'}" v-shortcut="'g o'">
<span class="menu-item-icon icon">
@ -22,11 +22,11 @@
</router-link>
</li>
<li>
<router-link :to="{ name: 'namespaces.index'}" v-shortcut="'g n'">
<router-link :to="{ name: 'projects.index'}" v-shortcut="'g p'">
<span class="menu-item-icon icon">
<icon icon="layer-group"/>
</span>
{{ $t('namespace.title') }}
{{ $t('project.projects') }}
</router-link>
</li>
<li>
@ -45,238 +45,54 @@
{{ $t('team.title') }}
</router-link>
</li>
</ul>
</menu>
</nav>
<nav class="menu namespaces-lists 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="toggleProjects(n.id)"
class="menu-label"
v-tooltip="namespaceTitles[nk]"
>
<ColorBubble
v-if="n.hexColor !== ''"
:color="n.hexColor"
class="mr-1"
/>
<span class="name">{{ namespaceTitles[nk] }}</span>
<div
class="icon menu-item-icon is-small toggle-lists-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}">
({{ namespaceProjectsCount[nk] }})
</span>
</BaseButton>
<namespace-settings-dropdown class="menu-list-dropdown" :namespace="n" v-if="n.id > 0"/>
</div>
<!--
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="projectsVisible[n.id] ?? true"
v-bind="dragOptions"
:modelValue="activeProjects[nk]"
@update:modelValue="(projects) => updateActiveProjects(n, projects)"
group="namespace-lists"
@start="() => drag = true"
@end="saveListPosition"
handle=".handle"
:disabled="n.id < 0 || undefined"
tag="ul"
item-key="id"
:data-namespace-id="n.id"
:data-namespace-index="nk"
:component-data="{
type: 'transition-group',
name: !drag ? 'flip-list' : null,
class: [
'menu-list can-be-hidden',
{ 'dragging-disabled': n.id < 0 }
]
}"
>
<template #item="{element: l}">
<li
class="list-menu loader-container is-loading-small"
:class="{'is-loading': projectUpdating[l.id]}"
>
<BaseButton
:to="{ name: 'project.index', params: { projectId: l.id} }"
class="list-menu-link"
:class="{'router-link-exact-active': currentProject.id === l.id}"
>
<span class="icon menu-item-icon handle">
<icon icon="grip-lines"/>
</span>
<ColorBubble
v-if="l.hexColor !== ''"
:color="l.hexColor"
class="mr-1"
/>
<span class="list-menu-title">{{ getProjectTitle(l) }}</span>
</BaseButton>
<BaseButton
v-if="l.id > 0"
class="favorite"
:class="{'is-favorite': l.isFavorite}"
@click="projectStore.toggleProjectFavorite(l)"
>
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
</BaseButton>
<ProjectSettingsDropdown class="menu-list-dropdown" :project="l" v-if="l.id > 0">
<template #trigger="{toggleOpen}">
<BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon"/>
</BaseButton>
</template>
</ProjectSettingsDropdown>
<span class="list-setting-spacer" v-else></span>
</li>
</template>
</draggable>
</template>
</nav>
<Loading variant="small" v-if="projectsLoading"/>
<template v-else>
<nav class="menu" v-if="favoriteProjects">
<ProjectsNavigation :model-value="favoriteProjects" :can-edit-order="false" :can-collapse="false"/>
</nav>
<nav class="menu">
<ProjectsNavigation
:model-value="projects"
:can-edit-order="true"
:can-collapse="true"
:level="1"
/>
</nav>
</template>
<PoweredByLink/>
</aside>
</template>
<script setup lang="ts">
import {ref, computed, onBeforeMount} from 'vue'
import draggable from 'zhyswan-vuedraggable'
import type {SortableEvent} from 'sortablejs'
import {computed} from 'vue'
import BaseButton from '@/components/base/BaseButton.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 {getProjectTitle} from '@/helpers/getProjectTitle'
import type {IProject} from '@/modelTypes/IProject'
import type {INamespace} from '@/modelTypes/INamespace'
import ColorBubble from '@/components/misc/colorBubble.vue'
import Loading from '@/components/misc/loading.vue'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import {useNamespaceStore} from '@/stores/namespaces'
const drag = ref(false)
const dragOptions = {
animation: 100,
ghostClass: 'ghost',
}
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore()
const currentProject = computed(() => baseStore.currentProject)
const menuActive = computed(() => baseStore.menuActive)
const loading = computed(() => namespaceStore.isLoading)
const namespaces = computed(() => {
return namespaceStore.namespaces.filter(n => !n.isArchived)
})
const activeProjects = computed(() => {
return namespaces.value.map(({projects}) => {
return projects?.filter(item => {
return typeof item !== 'undefined' && !item.isArchived
})
})
})
const namespaceTitles = computed(() => {
return namespaces.value.map((namespace) => getNamespaceTitle(namespace))
})
const namespaceProjectsCount = computed(() => {
return namespaces.value.map((_, index) => activeProjects.value[index]?.length ?? 0)
})
const projectStore = useProjectStore()
const menuActive = computed(() => baseStore.menuActive)
const projectsLoading = computed(() => projectStore.isLoading)
function toggleProjects(namespaceId: INamespace['id']) {
projectsVisible.value[namespaceId] = !projectsVisible.value[namespaceId]
}
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 projectsVisible.value[n.id] === 'undefined') {
projectsVisible.value[n.id] = true
}
})
})
function updateActiveProjects(namespace: INamespace, activeProjects: IProject[]) {
// This is a bit hacky: since we do have to filter out the archived items from the list
// for vue draggable updating it is not as simple as replacing it.
// 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 projects = [
...activeProjects,
...namespace.projects.filter(l => l.isArchived),
]
namespaceStore.setNamespaceById({
...namespace,
projects,
})
}
const projectUpdating = ref<{ [id: INamespace['id']]: boolean }>({})
async function saveListPosition(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 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 === projectsActive.length ? e.newIndex - 1 : e.newIndex
const project = projectsActive[newIndex]
const projectBefore = projectsActive[newIndex - 1] ?? null
const projectAfter = projectsActive[newIndex + 1] ?? null
projectUpdating.value[project.id] = true
const position = calculateItemPosition(
projectBefore !== null ? projectBefore.position : null,
projectAfter !== null ? projectAfter.position : null,
)
try {
// create a copy of the project in order to not violate pinia manipulation
await projectStore.updateProject({
...project,
position,
namespaceId,
})
} finally {
projectUpdating.value[project.id] = false
}
}
const projects = computed(() => projectStore.notArchivedRootProjects
// .sort((a, b) => a.position - b.position))
)
const favoriteProjects = computed(() => projectStore.favoriteProjects
// .sort((a, b) => a.position - b.position))
)
</script>
<style lang="scss" scoped>
$navbar-padding: 2rem;
$vikunja-nav-background: var(--site-background);
$vikunja-nav-color: var(--grey-700);
$vikunja-nav-selected-width: 0.4rem;
.logo {
display: block;
@ -289,8 +105,8 @@ $vikunja-nav-selected-width: 0.4rem;
}
}
.namespace-container {
background: $vikunja-nav-background;
.menu-container {
background: var(--site-background);
color: $vikunja-nav-color;
padding: 0 0 1rem;
transition: transform $transition-duration ease-in;
@ -314,252 +130,24 @@ $vikunja-nav-selected-width: 0.4rem;
}
}
// these are general menu styles
// should be in own components
.menu {
.menu-label,
.menu-list .list-menu-link,
.menu-list a {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
.color-bubble {
height: 12px;
flex: 0 0 12px;
}
}
.menu-list {
li {
height: 44px;
display: flex;
align-items: center;
&:hover {
background: var(--white);
}
.menu-list-dropdown {
opacity: 1;
transition: $transition;
}
@media(hover: hover) and (pointer: fine) {
.menu-list-dropdown {
opacity: 0;
}
&:hover .menu-list-dropdown {
opacity: 1;
}
}
}
.menu-item-icon {
color: var(--grey-400);
}
.menu-list-dropdown-trigger {
display: flex;
padding: 0.5rem;
}
.flip-list-move {
transition: transform $transition-duration;
}
.ghost {
background: var(--grey-200);
* {
opacity: 0;
}
}
a:hover {
background: transparent;
}
.list-menu-link,
li > a {
color: $vikunja-nav-color;
padding: 0.75rem .5rem 0.75rem ($navbar-padding * 1.5 - 1.75rem);
transition: all 0.2s ease;
border-radius: 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: 100%;
border-left: $vikunja-nav-selected-width solid transparent;
&:hover {
border-left: $vikunja-nav-selected-width solid var(--primary);
}
&.router-link-exact-active {
color: var(--primary);
border-left: $vikunja-nav-selected-width solid var(--primary);
}
.icon {
height: 1rem;
vertical-align: middle;
padding-right: 0.5rem;
}
&.router-link-exact-active .icon:not(.handle) {
color: var(--primary);
}
.handle {
opacity: 0;
transition: opacity $transition;
margin-right: .25rem;
}
&:hover .handle {
opacity: 1;
}
}
&:not(.dragging-disabled) .handle {
cursor: grab;
}
}
}
.top-menu {
margin-top: math.div($navbar-padding, 2);
.menu-list {
li {
font-weight: 600;
font-family: $vikunja-font;
}
.list-menu-link,
li > a {
padding-left: 2rem;
display: inline-block;
.icon {
padding-bottom: .25rem;
}
}
}
}
.namespaces-lists {
padding-top: math.div($navbar-padding, 2);
.menu-label {
font-size: 1rem;
font-weight: 700;
font-weight: bold;
font-family: $vikunja-font;
color: $vikunja-nav-color;
.top-menu .menu-list {
li {
font-weight: 600;
min-height: 2.5rem;
padding-top: 0;
padding-left: $navbar-padding;
overflow: hidden;
margin-bottom: 0;
flex: 1 1 auto;
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: auto;
}
.count {
color: var(--grey-500);
margin-right: .5rem;
// align brackets with number
font-feature-settings: "case";
}
font-family: $vikunja-font;
}
.favorite {
margin-left: .25rem;
transition: opacity $transition, color $transition;
opacity: 1;
.list-menu-link,
li > a {
padding-left: 2rem;
display: inline-block;
&.is-favorite {
color: var(--warning);
opacity: 1;
.icon {
padding-bottom: .25rem;
}
}
@media(hover: hover) and (pointer: fine) {
.list-menu .favorite {
opacity: 0;
}
.list-menu:hover .favorite,
.favorite.is-favorite {
opacity: 1;
}
}
.list-menu-title {
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.color-bubble {
width: 14px;
height: 14px;
flex-basis: auto;
}
.is-archived {
min-width: 85px;
}
}
.namespace-title {
display: flex;
align-items: center;
justify-content: space-between;
color: $vikunja-nav-color;
padding: 0 .25rem;
.toggle-lists-icon {
svg {
transition: all $transition;
transform: rotate(90deg);
opacity: 1;
}
&.active svg {
transform: rotate(0deg);
opacity: 0;
}
}
&:hover .toggle-lists-icon svg {
opacity: 1;
}
&:not(.has-menu) .toggle-lists-icon {
padding-right: 1rem;
}
}
.list-setting-spacer {
width: 2.5rem;
flex-shrink: 0;
}
.namespaces-list.loader-container.is-loading {
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem + 1.5rem});
.menu + .menu {
padding-top: math.div($navbar-padding, 2);
}
</style>

View File

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

View File

@ -44,8 +44,8 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
combination: 'then',
},
{
title: 'keyboardShortcuts.navigation.namespaces',
keys: ['g', 'n'],
title: 'keyboardShortcuts.navigation.projects',
keys: ['g', 'p'],
combination: 'then',
},
{

View File

@ -1,13 +1,21 @@
<template>
<div class="loader-container is-loading"></div>
<div class="loader-container is-loading" :class="{'is-small': variant === 'small'}"></div>
</template>
<script lang="ts">
export default {
inheritAttrs: false,
inheritAttrs: true,
}
</script>
<script lang="ts" setup>
const {
variant = 'default',
} = defineProps<{
variant: 'default' | 'small'
}>()
</script>
<style scoped lang="scss">
.loader-container {
height: 100%;
@ -20,5 +28,18 @@ export default {
min-height: 50px;
min-width: 100px;
}
&.is-small {
min-width: 100%;
height: 150px;
&.is-loading::after {
width: 3rem;
height: 3rem;
top: calc(50% - 1.5rem);
left: calc(50% - 1.5rem);
border-width: 3px;
}
}
}
</style>

View File

@ -47,7 +47,7 @@ import {success} from '@/message'
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
const props = defineProps({
entity: String,
entity: String as ISubscription['entity'],
entityId: Number,
isButton: {
type: Boolean,
@ -73,12 +73,6 @@ const {t} = useI18n({useScope: 'global'})
const tooltipText = computed(() => {
if (disabled.value) {
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 === 'project') {
return t('task.subscription.subscribedTaskThroughParentProject')
}
@ -87,10 +81,6 @@ const tooltipText = computed(() => {
}
switch (props.entity) {
case 'namespace':
return props.modelValue !== null ?
t('task.subscription.subscribedNamespace') :
t('task.subscription.notSubscribedNamespace')
case 'project':
return props.modelValue !== null ?
t('task.subscription.subscribedProject') :
@ -130,9 +120,6 @@ async function subscribe() {
let message = ''
switch (props.entity) {
case 'namespace':
message = t('task.subscription.subscribeSuccessNamespace')
break
case 'project':
message = t('task.subscription.subscribeSuccessProject')
break
@ -153,9 +140,6 @@ async function unsubscribe() {
let message = ''
switch (props.entity) {
case 'namespace':
message = t('task.subscription.unsubscribeSuccessNamespace')
break
case 'project':
message = t('task.subscription.unsubscribeSuccessProject')
break

View File

@ -1,103 +0,0 @@
<template>
<dropdown>
<template #trigger="triggerProps">
<slot name="trigger" v-bind="triggerProps">
<BaseButton class="dropdown-trigger" @click="triggerProps.toggleOpen">
<icon icon="ellipsis-h" class="icon"/>
</BaseButton>
</slot>
</template>
<template v-if="namespace.isArchived">
<dropdown-item
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"
icon="archive"
>
{{ $t('menu.unarchive') }}
</dropdown-item>
</template>
<template v-else>
<dropdown-item
:to="{ name: 'namespace.settings.edit', params: { id: namespace.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'namespace.settings.share', params: { namespaceId: namespace.id } }"
icon="share-alt"
>
{{ $t('menu.share') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'project.create', params: { namespaceId: namespace.id } }"
icon="plus"
>
{{ $t('menu.newProject') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"
icon="archive"
>
{{ $t('menu.archive') }}
</dropdown-item>
<Subscription
class="has-no-shadow"
:is-button="false"
entity="namespace"
:entity-id="namespace.id"
:model-value="subscription"
@update:model-value="setSubscriptionInStore"
type="dropdown"
/>
<dropdown-item
:to="{ name: 'namespace.settings.delete', params: { id: namespace.id } }"
icon="trash-alt"
class="has-text-danger"
>
{{ $t('menu.delete') }}
</dropdown-item>
</template>
</dropdown>
</template>
<script setup lang="ts">
import {ref, onMounted, type PropType} from 'vue'
import BaseButton from '@/components/base/BaseButton.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 {INamespace} from '@/modelTypes/INamespace'
import type {ISubscription} from '@/modelTypes/ISubscription'
import {useNamespaceStore} from '@/stores/namespaces'
const props = defineProps({
namespace: {
type: Object as PropType<INamespace>,
required: true,
},
})
const namespaceStore = useNamespaceStore()
const subscription = ref<ISubscription | null>(null)
onMounted(() => {
subscription.value = props.namespace.subscription
})
function setSubscriptionInStore(sub: ISubscription) {
subscription.value = sub
namespaceStore.setNamespaceById({
...props.namespace,
subscription: sub,
})
}
</script>
<style scoped lang="scss">
.dropdown-trigger {
padding: 0.5rem;
display: flex;
}
</style>

View File

@ -46,7 +46,7 @@
</div>
<CustomTransition name="fade">
<Message variant="warning" v-if="currentProject.isArchived" class="mb-4">
{{ $t('project.archived') }}
{{ $t('project.archivedMessage') }}
</Message>
</CustomTransition>

View File

@ -15,7 +15,7 @@
:class="{'is-visible': background}"
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
/>
<span v-if="project.isArchived" class="is-archived" >{{ $t('namespace.archived') }}</span>
<span v-if="project.isArchived" class="is-archived" >{{ $t('project.archived') }}</span>
<div class="project-title" aria-hidden="true">{{ project.title }}</div>
<BaseButton

View File

@ -165,16 +165,6 @@
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('namespace.namespaces') }}</label>
<div class="control">
<SelectNamespace
v-model="entities.namespace"
@select="changeMultiselectFilter('namespace', 'namespace')"
@remove="changeMultiselectFilter('namespace', 'namespace')"
/>
</div>
</div>
</template>
</card>
</template>
@ -189,7 +179,6 @@ 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 {IProject} from '@/modelTypes/IProject'
import {useLabelStore} from '@/stores/labels'
@ -201,7 +190,6 @@ import EditLabels from '@/components/tasks/partials/editLabels.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import SelectUser from '@/components/input/SelectUser.vue'
import SelectProject from '@/components/input/SelectProject.vue'
import SelectNamespace from '@/components/input/SelectNamespace.vue'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
@ -209,7 +197,6 @@ import {objectToSnakeCase} from '@/helpers/case'
import UserService from '@/services/user'
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'
@ -240,7 +227,6 @@ const DEFAULT_FILTERS = {
assignees: '',
labels: '',
project_id: '',
namespace: '',
} as const
const props = defineProps({
@ -265,23 +251,20 @@ const filters = ref({...DEFAULT_FILTERS})
const services = {
users: shallowReactive(new UserService()),
projects: shallowReactive(new ProjectService()),
namespace: shallowReactive(new NamespaceService()),
}
interface Entities {
users: IUser[]
labels: ILabel[]
projects: IProject[]
namespace: INamespace[]
}
type EntityType = 'users' | 'labels' | 'projects' | 'namespace'
type EntityType = 'users' | 'labels' | 'projects'
const entities: Entities = reactive({
users: [],
labels: [],
projects: [],
namespace: [],
})
onMounted(() => {
@ -328,7 +311,6 @@ function prepareFilters() {
prepareDate('reminders')
prepareRelatedObjectFilter('users', 'assignees')
prepareRelatedObjectFilter('projects', 'project_id')
prepareRelatedObjectFilter('namespace')
prepareSingleValue('labels')

View File

@ -96,7 +96,6 @@ import type {ISubscription} from '@/modelTypes/ISubscription'
import {isSavedFilter} from '@/services/savedFilter'
import {useConfigStore} from '@/stores/config'
import {useProjectStore} from '@/stores/projects'
import {useNamespaceStore} from '@/stores/namespaces'
const props = defineProps({
project: {
@ -106,7 +105,6 @@ const props = defineProps({
})
const projectStore = useProjectStore()
const namespaceStore = useNamespaceStore()
const subscription = ref<ISubscription | null>(null)
watchEffect(() => {
subscription.value = props.project.subscription ?? null
@ -122,6 +120,5 @@ function setSubscriptionInStore(sub: ISubscription) {
subscription: sub,
}
projectStore.setProject(updatedProject)
namespaceStore.setProjectInNamespaceById(updatedProject)
}
</script>

View File

@ -61,7 +61,6 @@ import {useRouter} from 'vue-router'
import TaskService from '@/services/task'
import TeamService from '@/services/team'
import NamespaceModel from '@/models/namespace'
import TeamModel from '@/models/team'
import ProjectModel from '@/models/project'
@ -70,7 +69,6 @@ import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import {useNamespaceStore} from '@/stores/namespaces'
import {useLabelStore} from '@/stores/labels'
import {useTaskStore} from '@/stores/tasks'
@ -81,7 +79,6 @@ import {success} from '@/message'
import type {ITeam} from '@/modelTypes/ITeam'
import type {ITask} from '@/modelTypes/ITask'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IProject} from '@/modelTypes/IProject'
const {t} = useI18n({useScope: 'global'})
@ -89,7 +86,6 @@ const router = useRouter()
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const namespaceStore = useNamespaceStore()
const labelStore = useLabelStore()
const taskStore = useTaskStore()
@ -105,7 +101,6 @@ enum ACTION_TYPE {
enum COMMAND_TYPE {
NEW_TASK = 'newTask',
NEW_PROJECT = 'newProject',
NEW_NAMESPACE = 'newNamespace',
NEW_TEAM = 'newTeam',
}
@ -147,7 +142,6 @@ const foundProjects = computed(() => {
return []
}
const ncache: { [id: ProjectModel['id']]: INamespace } = {}
const history = getHistory()
const allProjects = [
...new Set([
@ -156,15 +150,7 @@ const foundProjects = computed(() => {
]),
]
return allProjects.filter((l) => {
if (typeof l === 'undefined' || l === null) {
return false
}
if (typeof ncache[l.namespaceId] === 'undefined') {
ncache[l.namespaceId] = namespaceStore.getNamespaceById(l.namespaceId)
}
return !ncache[l.namespaceId].isArchived
})
return allProjects.filter((l) => typeof l !== 'undefined' && l !== null)
})
// FIXME: use fuzzysearch
@ -205,7 +191,6 @@ const results = computed<Result[]>(() => {
const loading = computed(() =>
taskService.loading ||
namespaceStore.isLoading ||
projectStore.isLoading ||
teamService.loading,
)
@ -230,12 +215,6 @@ const commands = computed<{ [key in COMMAND_TYPE]: Command }>(() => ({
placeholder: t('quickActions.newProject'),
action: newProject,
},
newNamespace: {
type: COMMAND_TYPE.NEW_NAMESPACE,
title: t('quickActions.cmds.newNamespace'),
placeholder: t('quickActions.newNamespace'),
action: newNamespace,
},
newTeam: {
type: COMMAND_TYPE.NEW_TEAM,
title: t('quickActions.cmds.newTeam'),
@ -252,7 +231,6 @@ const currentProject = computed(() => Object.keys(baseStore.currentProject).leng
)
const hintText = computed(() => {
let namespace
if (selectedCmd.value !== null && currentProject.value !== null) {
switch (selectedCmd.value.type) {
case COMMAND_TYPE.NEW_TASK:
@ -260,12 +238,7 @@ const hintText = computed(() => {
title: currentProject.value.title,
})
case COMMAND_TYPE.NEW_PROJECT:
namespace = namespaceStore.getNamespaceById(
currentProject.value.namespaceId,
)
return t('quickActions.createProject', {
title: namespace?.title,
})
return t('quickActions.createProject')
}
}
const prefixes =
@ -278,7 +251,7 @@ const availableCmds = computed(() => {
if (currentProject.value !== null) {
cmds.push(commands.value.newTask, commands.value.newProject)
}
cmds.push(commands.value.newNamespace, commands.value.newTeam)
cmds.push(commands.value.newTeam)
return cmds
})
@ -506,7 +479,6 @@ async function newProject() {
}
const newProject = await projectStore.createProject(new ProjectModel({
title: query.value,
namespaceId: currentProject.value.namespaceId,
}))
success({ message: t('project.create.createdSuccess')})
await router.push({
@ -515,12 +487,6 @@ async function newProject() {
})
}
async function newNamespace() {
const newNamespace = new NamespaceModel({ title: query.value })
await namespaceStore.createNamespace(newNamespace)
success({ message: t('namespace.create.success') })
}
async function newTeam() {
const newTeam = new TeamModel({ name: query.value })
const team = await teamService.create(newTeam)

View File

@ -139,10 +139,6 @@ import {ref, reactive, computed, shallowReactive, type Ref} from 'vue'
import type {PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import UserNamespaceService from '@/services/userNamespace'
import UserNamespaceModel from '@/models/userNamespace'
import type {IUserNamespace} from '@/modelTypes/IUserNamespace'
import UserProjectService from '@/services/userProject'
import UserProjectModel from '@/models/userProject'
import type {IUserProject} from '@/modelTypes/IUserProject'
@ -151,10 +147,6 @@ import UserService from '@/services/user'
import UserModel, { getDisplayName } from '@/models/user'
import type {IUser} from '@/modelTypes/IUser'
import TeamNamespaceService from '@/services/teamNamespace'
import TeamNamespaceModel from '@/models/teamNamespace'
import type { ITeamNamespace } from '@/modelTypes/ITeamNamespace'
import TeamProjectService from '@/services/teamProject'
import TeamProjectModel from '@/models/teamProject'
import type { ITeamProject } from '@/modelTypes/ITeamProject'
@ -170,13 +162,15 @@ import Nothing from '@/components/misc/nothing.vue'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
// FIXME: I think this whole thing can now only manage user/team sharing for projects? Maybe remove a little generalization?
const props = defineProps({
type: {
type: String as PropType<'project' | 'namespace'>,
type: String as PropType<'project'>,
default: '',
},
shareType: {
type: String as PropType<'user' | 'team' | 'namespace'>,
type: String as PropType<'user' | 'team'>,
default: '',
},
id: {
@ -191,9 +185,9 @@ const props = defineProps({
const {t} = useI18n({useScope: 'global'})
// 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
// This user service is a userProjectService, depending on the type we are using
let stuffService: UserProjectService | TeamProjectService
let stuffModel: IUserProject | ITeamProject
let searchService: UserService | TeamService
let sharable: Ref<IUser | ITeam>
@ -231,10 +225,6 @@ const sharableName = computed(() => {
return t('project.list.title')
}
if (props.shareType === 'namespace') {
return t('namespace.namespace')
}
return ''
})
@ -247,11 +237,6 @@ if (props.shareType === 'user') {
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({
namespaceId: props.id,
}))
} else {
throw new Error('Unknown type: ' + props.type)
}
@ -264,11 +249,6 @@ if (props.shareType === 'user') {
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({
namespaceId: props.id,
}))
} else {
throw new Error('Unknown type: ' + props.type)
}

View File

@ -11,8 +11,10 @@
@search="findProjects"
>
<template #searchResult="{option}">
<span class="project-namespace-title search-result">{{ namespace((option as IProject).namespaceId) }} ></span>
{{ (option as IProject).title }}
<span class="has-text-grey" v-if="projectStore.getParentProjects(option).length > 1">
{{ projectStore.getParentProjects(option).filter(p => p.id !== option.id).map(p => getProjectTitle(p)).join(' &gt; ') }} &gt;
</span>
{{ getProjectTitle(option) }}
</template>
</Multiselect>
</template>
@ -23,10 +25,9 @@ import type {PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import type {IProject} from '@/modelTypes/IProject'
import type {INamespace} from '@/modelTypes/INamespace'
import {useProjectStore} from '@/stores/projects'
import {useNamespaceStore} from '@/stores/namespaces'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import ProjectModel from '@/models/project'
@ -54,7 +55,6 @@ watch(
)
const projectStore = useProjectStore()
const namespaceStore = useNamespaceStore()
const foundProjects = ref<IProject[]>([])
function findProjects(query: string) {
if (query === '') {
@ -70,17 +70,4 @@ function select(l: IProject | null) {
Object.assign(project, l)
emit('update:modelValue', project)
}
function namespace(namespaceId: INamespace['id']) {
const namespace = namespaceStore.getNamespaceById(namespaceId)
return namespace !== null
? namespace.title
: t('project.shared')
}
</script>
<style lang="scss" scoped>
.project-namespace-title {
color: var(--grey-500);
}
</style>

View File

@ -46,11 +46,6 @@
class="different-project"
v-if="task.projectId !== projectId"
>
<span
v-if="task.differentNamespace !== null"
v-tooltip="$t('task.relation.differentNamespace')">
{{ task.differentNamespace }} >
</span>
<span
v-if="task.differentProject !== null"
v-tooltip="$t('task.relation.differentProject')">
@ -101,11 +96,6 @@
class="different-project"
v-if="t.projectId !== projectId"
>
<span
v-if="t.differentNamespace !== null"
v-tooltip="$t('task.relation.differentNamespace')">
{{ t.differentNamespace }} >
</span>
<span
v-if="t.differentProject !== null"
v-tooltip="$t('task.relation.differentProject')">
@ -168,10 +158,9 @@ import BaseButton from '@/components/base/BaseButton.vue'
import Multiselect from '@/components/input/multiselect.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import {useNamespaceStore} from '@/stores/namespaces'
import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
import {useProjectStore} from '@/stores/projects'
const props = defineProps({
taskId: {
@ -196,7 +185,7 @@ const props = defineProps({
})
const taskStore = useTaskStore()
const namespaceStore = useNamespaceStore()
const projectStore = useProjectStore()
const route = useRoute()
const {t} = useI18n({useScope: 'global'})
@ -230,24 +219,13 @@ async function findTasks(newQuery: string) {
foundTasks.value = await taskService.getAll({}, {s: newQuery})
}
const getProjectAndNamespaceById = (projectId: number) => namespaceStore.getProjectAndNamespaceById(projectId, true)
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 {
project,
namespace: taskNamespace,
} = getProjectAndNamespaceById(task.projectId) || {project: null, namespace: null}
const project = projectStore.getProjectById(task.ProjectId)
return {
...task,
differentNamespace:
(taskNamespace !== null &&
taskNamespace.id !== namespace.value.id &&
taskNamespace?.title) || null,
differentProject:
(project !== null &&
task.projectId !== props.projectId &&

View File

@ -149,7 +149,6 @@ import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatD
import {success} from '@/message'
import {useProjectStore} from '@/stores/projects'
import {useNamespaceStore} from '@/stores/namespaces'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
@ -209,7 +208,6 @@ onBeforeUnmount(() => {
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const taskStore = useTaskStore()
const namespaceStore = useNamespaceStore()
const project = computed(() => projectStore.getProjectById(task.value.projectId))
const projectColor = computed(() => project.value !== null ? project.value.hexColor : '')
@ -257,10 +255,8 @@ function undoDone(checked: boolean) {
}
async function toggleFavorite() {
task.value.isFavorite = !task.value.isFavorite
task.value = await taskService.update(task.value)
task.value = taskStore.toggleFavorite(task.value)
emit('task-updated', task.value)
namespaceStore.loadNamespacesIfFavoritesDontExist()
}
const deferDueDate = ref<typeof DeferTask | null>(null)

View File

@ -1,19 +0,0 @@
import {ref, computed} from 'vue'
import {useNamespaceStore} from '@/stores/namespaces'
export function useNamespaceSearch() {
const query = ref('')
const namespaceStore = useNamespaceStore()
const namespaces = computed(() => namespaceStore.searchNamespace(query.value))
function findNamespaces(newQuery: string) {
query.value = newQuery
}
return {
namespaces,
findNamespaces,
}
}

View File

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

View File

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

View File

@ -6,9 +6,7 @@
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Last viewed",
"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:",
"importText": "Import your projects and tasks from other services into Vikunja:",
"import": "Import your data into Vikunja"
}
},
@ -143,7 +141,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, projects, tasks and everything associated with it.",
"text1": "The deletion of your account is permanent and cannot be undone. We will delete all your 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.",
@ -165,14 +163,18 @@
}
},
"project": {
"archived": "This project is archived. It is not possible to create new or edit tasks for it.",
"archivedMessage": "This project is archived. It is not possible to create new or edit tasks for it.",
"archived": "Archived",
"showArchived": "Show Archived",
"title": "Project Title",
"color": "Color",
"projects": "Projects",
"parent": "Parent 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.",
"inboxTitle": "Inbox",
"create": {
"header": "New project",
"titlePlaceholder": "The project's title goes here…",
@ -210,7 +212,7 @@
"duplicate": {
"title": "Duplicate this project",
"label": "Duplicate",
"text": "Select a namespace which should hold the duplicated project:",
"text": "Select a parent project which should hold the duplicated project:",
"success": "The project was successfully duplicated."
},
"edit": {
@ -321,67 +323,6 @@
}
}
},
"namespace": {
"title": "Namespaces & Projects",
"namespace": "Namespace",
"showArchived": "Show Archived",
"noneAvailable": "You don't have any namespaces right now.",
"unarchive": "Un-Archive",
"archived": "Archived",
"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 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 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 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 projects and tasks and CANNOT BE UNDONE!",
"success": "The namespace was successfully deleted."
},
"edit": {
"title": "Edit \"{namespace}\"",
"success": "The namespace was successfully updated."
},
"share": {
"title": "Share \"{namespace}\""
},
"attributes": {
"title": "Namespace Title",
"titlePlaceholder": "The namespace title goes here…",
"description": "Description",
"descriptionPlaceholder": "The namespaces description goes here…",
"color": "Color",
"archived": "Is Archived",
"isArchived": "This namespace is archived"
},
"pseudo": {
"sharedProjects": {
"title": "Shared Projects"
},
"favorites": {
"title": "Favorites"
},
"savedFilters": {
"title": "Filters"
}
}
},
"filters": {
"title": "Filters",
"clear": "Clear Filters",
@ -403,7 +344,7 @@
},
"create": {
"title": "New Saved Filter",
"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.",
"description": "A saved filter is a virtual project which is computed from a set of filters each time it is accessed.",
"action": "Create new saved filter",
"titleRequired": "Please provide a title for the filter."
},
@ -677,19 +618,13 @@
"updated": "Updated"
},
"subscription": {
"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.",
"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.",
"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",
"subscribeSuccessProject": "You are now subscribed to this project",
"unsubscribeSuccessProject": "You are now unsubscribed to this project",
"subscribeSuccessTask": "You are now subscribed to this task",
@ -766,7 +701,6 @@
"searchPlaceholder": "Type search for a new task to add as related…",
"createPlaceholder": "Add this as new related task",
"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",
"deleteText1": "Are you sure you want to delete this task relation?",
@ -851,19 +785,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 projects and namespaces shared with this team. This CANNOT BE UNDONE!",
"text2": "All team members will lose access to projects 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 projects and namespaces this team has access to. This CANNOT BE UNDONE!",
"text2": "They will lose access to all projects 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 projects 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 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."
}
},
@ -910,9 +844,9 @@
"title": "Navigation",
"overview": "Navigate to overview",
"upcoming": "Navigate to upcoming tasks",
"namespaces": "Navigate to namespaces & projects",
"labels": "Navigate to labels",
"teams": "Navigate to teams"
"teams": "Navigate to teams",
"projects": "Navigate to projects"
}
},
"update": {
@ -946,7 +880,7 @@
"notification": {
"title": "Notifications",
"none": "You don't have any notifications. Have a nice day!",
"explainer": "Notifications will appear here when actions on namespaces, projects or tasks you subscribed to happen."
"explainer": "Notifications will appear here when actions projects or tasks you subscribed to happen."
},
"quickActions": {
"commands": "Commands",
@ -957,14 +891,12 @@
"teams": "Teams",
"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 project ({title})",
"createProject": "Create a project in the current namespace ({title})",
"createProject": "Create a project",
"cmds": {
"newTask": "New task",
"newProject": "New project",
"newNamespace": "New namespace",
"newTeam": "New team"
}
},
@ -1020,16 +952,9 @@
"4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.",
"5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.",
"5006": "The namespace name cannot be empty.",
"5009": "You need to have namespace read access to perform that action.",
"5010": "This team does not have access to that namespace.",
"5011": "This user has already access to that namespace.",
"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 project.",
"6004": "The team already has access to that 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 project to perform that action.",

View File

@ -23,6 +23,7 @@ declare global {
API_URL: string;
SENTRY_ENABLED: boolean;
SENTRY_DSN: string;
INFINITE_PROJECT_NESTING_ENABLED: boolean;
}
}

View File

@ -1,18 +0,0 @@
import type {IAbstract} from './IAbstract'
import type {IProject} from './IProject'
import type {IUser} from './IUser'
import type {ISubscription} from './ISubscription'
export interface INamespace extends IAbstract {
id: number
title: string
description: string
owner: IUser
projects: IProject[]
isArchived: boolean
hexColor: string
subscription: ISubscription
created: Date
updated: Date
}

View File

@ -2,7 +2,6 @@ import type {IAbstract} from './IAbstract'
import type {ITask} from './ITask'
import type {IUser} from './IUser'
import type {ISubscription} from './ISubscription'
import type {INamespace} from './INamespace'
export interface IProject extends IAbstract {
@ -11,7 +10,6 @@ export interface IProject extends IAbstract {
description: string
owner: IUser
tasks: ITask[]
namespaceId: INamespace['id']
isArchived: boolean
hexColor: string
identifier: string
@ -20,6 +18,8 @@ export interface IProject extends IAbstract {
subscription: ISubscription
position: number
backgroundBlurHash: string
childProjectIds: number[]
parentProjectId: number
created: Date
updated: Date

View File

@ -1,9 +1,8 @@
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
parentProjectId: number
}

View File

@ -1,6 +0,0 @@
import type {ITeamShareBase} from './ITeamShareBase'
import type {INamespace} from './INamespace'
export interface ITeamNamespace extends ITeamShareBase {
namespaceId: INamespace['id']
}

View File

@ -1,6 +0,0 @@
import type {IUserShareBase} from './IUserShareBase'
import type {INamespace} from './INamespace'
export interface IUserNamespace extends IUserShareBase {
namespaceId: INamespace['id']
}

View File

@ -1,45 +0,0 @@
import AbstractModel from './abstractModel'
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 {IProject} from '@/modelTypes/IProject'
import type {ISubscription} from '@/modelTypes/ISubscription'
export default class NamespaceModel extends AbstractModel<INamespace> implements INamespace {
id = 0
title = ''
description = ''
owner: IUser = UserModel
projects: IProject[] = []
isArchived = false
hexColor = ''
subscription: ISubscription = null
created: Date = null
updated: Date = null
constructor(data: Partial<INamespace> = {}) {
super()
this.assignData(data)
if (this.hexColor !== '' && this.hexColor.substring(0, 1) !== '#') {
this.hexColor = '#' + this.hexColor
}
this.projects = this.projects.map(l => {
return new ProjectModel(l)
})
this.owner = new UserModel(this.owner)
if(typeof this.subscription !== 'undefined' && this.subscription !== null) {
this.subscription = new SubscriptionModel(this.subscription)
}
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}
}

View File

@ -6,7 +6,6 @@ import SubscriptionModel from '@/models/subscription'
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 ProjectModel extends AbstractModel<IProject> implements IProject {
@ -15,7 +14,6 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
description = ''
owner: IUser = UserModel
tasks: ITask[] = []
namespaceId: INamespace['id'] = 0
isArchived = false
hexColor = ''
identifier = ''
@ -24,6 +22,8 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
subscription: ISubscription = null
position = 0
backgroundBlurHash = ''
childProjectIds = []
parentProjectId = 0
created: Date = null
updated: Date = null
@ -46,6 +46,8 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
if (typeof this.subscription !== 'undefined' && this.subscription !== null) {
this.subscription = new SubscriptionModel(this.subscription)
}
this.childProjectIds = this.childProjects?.map(p => p.id) || []
this.created = new Date(this.created)
this.updated = new Date(this.updated)

View File

@ -2,12 +2,10 @@ 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>) {

View File

@ -1,13 +0,0 @@
import TeamShareBaseModel from './teamShareBase'
import type {ITeamNamespace} from '@/modelTypes/ITeamNamespace'
import type {INamespace} from '@/modelTypes/INamespace'
export default class TeamNamespaceModel extends TeamShareBaseModel implements ITeamNamespace {
namespaceId: INamespace['id'] = 0
constructor(data: Partial<ITeamNamespace>) {
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 projects.
* It is extended in a way, so it can be used 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 {INamespace} from '@/modelTypes/INamespace'
import type {IUserNamespace} from '@/modelTypes/IUserNamespace'
// This class extends the user share model with a 'rights' parameter which is used in sharing
export default class UserNamespaceModel extends UserShareBaseModel implements IUserNamespace {
namespaceId: INamespace['id'] = 0
constructor(data: Partial<IUserNamespace>) {
super(data)
this.assignData(data)
}
}

View File

@ -22,7 +22,6 @@ const DataExportDownload = () => import('@/views/user/DataExportDownload.vue')
// Tasks
import UpcomingTasksComponent from '@/views/tasks/ShowTasks.vue'
import LinkShareAuthComponent from '@/views/sharing/LinkSharingAuth.vue'
const ListNamespaces = () => import('@/views/namespaces/ListNamespaces.vue')
const TaskDetailView = () => import('@/views/tasks/TaskDetailView.vue')
// Team Handling
@ -41,6 +40,7 @@ const ProjectKanban = () => import('@/views/project/ProjectKanban.vue')
const ProjectInfo = () => import('@/views/project/ProjectInfo.vue')
// Project Settings
const ListProjects = () => import('@/views/project/ListProjects.vue')
const ProjectSettingEdit = () => import('@/views/project/settings/edit.vue')
const ProjectSettingBackground = () => import('@/views/project/settings/background.vue')
const ProjectSettingDuplicate = () => import('@/views/project/settings/duplicate.vue')
@ -48,12 +48,6 @@ const ProjectSettingShare = () => import('@/views/project/settings/share.vue')
const ProjectSettingDelete = () => import('@/views/project/settings/delete.vue')
const ProjectSettingArchive = () => import('@/views/project/settings/archive.vue')
// Namespace Settings
const NamespaceSettingEdit = () => import('@/views/namespaces/settings/edit.vue')
const NamespaceSettingShare = () => import('@/views/namespaces/settings/share.vue')
const NamespaceSettingArchive = () => import('@/views/namespaces/settings/archive.vue')
const NamespaceSettingDelete = () => import('@/views/namespaces/settings/delete.vue')
// Saved Filters
const FilterNew = () => import('@/views/filters/FilterNew.vue')
const FilterEdit = () => import('@/views/filters/FilterEdit.vue')
@ -74,9 +68,6 @@ const UserSettingsTOTPComponent = () => import('@/views/user/settings/TOTP.vue')
// Project Handling
const NewProjectComponent = () => import('@/views/project/NewProject.vue')
// Namespace Handling
const NewNamespaceComponent = () => import('@/views/namespaces/NewNamespace.vue')
const EditTeamComponent = () => import('@/views/teams/EditTeam.vue')
const NewTeamComponent = () => import('@/views/teams/NewTeam.vue')
@ -203,54 +194,6 @@ const router = createRouter({
name: 'link-share.auth',
component: LinkShareAuthComponent,
},
{
path: '/namespaces',
name: 'namespaces.index',
component: ListNamespaces,
},
{
path: '/namespaces/new',
name: 'namespace.create',
component: NewNamespaceComponent,
meta: {
showAsModal: true,
},
},
{
path: '/namespaces/:id/settings/edit',
name: 'namespace.settings.edit',
component: NamespaceSettingEdit,
meta: {
showAsModal: true,
},
props: route => ({ namespaceId: Number(route.params.id as string) }),
},
{
path: '/namespaces/:namespaceId/settings/share',
name: 'namespace.settings.share',
component: NamespaceSettingShare,
meta: {
showAsModal: true,
},
},
{
path: '/namespaces/:id/settings/archive',
name: 'namespace.settings.archive',
component: NamespaceSettingArchive,
meta: {
showAsModal: true,
},
props: route => ({ namespaceId: parseInt(route.params.id as string) }),
},
{
path: '/namespaces/:id/settings/delete',
name: 'namespace.settings.delete',
component: NamespaceSettingDelete,
meta: {
showAsModal: true,
},
props: route => ({ namespaceId: Number(route.params.id as string) }),
},
{
path: '/tasks/:id',
name: 'task.detail',
@ -282,7 +225,12 @@ const router = createRouter({
},
},
{
path: '/projects/new/:namespaceId/',
path: '/projects',
name: 'projects.index',
component: ListProjects,
},
{
path: '/projects/new',
name: 'project.create',
component: NewProjectComponent,
meta: {

View File

@ -1,30 +0,0 @@
import AbstractService from './abstractService'
import NamespaceModel from '../models/namespace'
import type {INamespace} from '@/modelTypes/INamespace'
import {colorFromHex} from '@/helpers/color/colorFromHex'
export default class NamespaceService extends AbstractService<INamespace> {
constructor() {
super({
create: '/namespaces',
get: '/namespaces/{id}',
getAll: '/namespaces',
update: '/namespaces/{id}',
delete: '/namespaces/{id}',
})
}
modelFactory(data) {
return new NamespaceModel(data)
}
beforeUpdate(namespace) {
namespace.hexColor = colorFromHex(namespace.hexColor)
return namespace
}
beforeCreate(namespace) {
namespace.hexColor = colorFromHex(namespace.hexColor)
return namespace
}
}

View File

@ -7,7 +7,7 @@ import {colorFromHex} from '@/helpers/color/colorFromHex'
export default class ProjectService extends AbstractService<IProject> {
constructor() {
super({
create: '/namespaces/{namespaceId}/projects',
create: '/projects',
get: '/projects/{id}',
getAll: '/projects',
update: '/projects/{id}',

View File

@ -12,7 +12,7 @@ import AbstractService from '@/services/abstractService'
import SavedFilterModel from '@/models/savedFilter'
import {useBaseStore} from '@/stores/base'
import {useNamespaceStore} from '@/stores/namespaces'
import {useProjectStore} from '@/stores/projects'
import {objectToSnakeCase, objectToCamelCase} from '@/helpers/case'
import {success} from '@/message'
@ -81,7 +81,7 @@ export default class SavedFilterService extends AbstractService<ISavedFilter> {
export function useSavedFilter(projectId?: MaybeRef<IProject['id']>) {
const router = useRouter()
const {t} = useI18n({useScope:'global'})
const namespaceStore = useNamespaceStore()
const projectStore = useProjectStore()
const filterService = shallowReactive(new SavedFilterService())
@ -110,13 +110,13 @@ export function useSavedFilter(projectId?: MaybeRef<IProject['id']>) {
async function createFilter() {
filter.value = await filterService.create(filter.value)
await namespaceStore.loadNamespaces()
await projectStore.loadProjects()
router.push({name: 'project.index', params: {projectId: getProjectId(filter.value)}})
}
async function saveFilter() {
const response = await filterService.update(filter.value)
await namespaceStore.loadNamespaces()
await projectStore.loadProjects()
success({message: t('filters.edit.success')})
response.filters = objectToSnakeCase(response.filters)
filter.value = response
@ -129,9 +129,9 @@ export function useSavedFilter(projectId?: MaybeRef<IProject['id']>) {
async function deleteFilter() {
await filterService.delete(filter.value)
await namespaceStore.loadNamespaces()
await projectStore.loadProjects()
success({message: t('filters.delete.success')})
router.push({name: 'namespaces.index'})
router.push({name: 'projects.index'})
}
const titleValid = ref(true)

View File

@ -1,23 +0,0 @@
import AbstractService from './abstractService'
import TeamNamespaceModel from '@/models/teamNamespace'
import type {ITeamNamespace} from '@/modelTypes/ITeamNamespace'
import TeamModel from '@/models/team'
export default class TeamNamespaceService extends AbstractService<ITeamNamespace> {
constructor() {
super({
create: '/namespaces/{namespaceId}/teams',
getAll: '/namespaces/{namespaceId}/teams',
update: '/namespaces/{namespaceId}/teams/{teamId}',
delete: '/namespaces/{namespaceId}/teams/{teamId}',
})
}
modelFactory(data) {
return new TeamNamespaceModel(data)
}
modelGetAllFactory(data) {
return new TeamModel(data)
}
}

View File

@ -1,23 +0,0 @@
import AbstractService from './abstractService'
import UserNamespaceModel from '@/models/userNamespace'
import type {IUserNamespace} from '@/modelTypes/IUserNamespace'
import UserModel from '@/models/user'
export default class UserNamespaceService extends AbstractService<IUserNamespace> {
constructor() {
super({
create: '/namespaces/{namespaceId}/users',
getAll: '/namespaces/{namespaceId}/users',
update: '/namespaces/{namespaceId}/users/{userId}',
delete: '/namespaces/{namespaceId}/users/{userId}',
})
}
modelFactory(data) {
return new UserNamespaceModel(data)
}
modelGetAllFactory(data) {
return new UserModel(data)
}
}

View File

@ -1,236 +0,0 @@
import {computed, readonly, ref} from 'vue'
import {defineStore, acceptHMRUpdate} from 'pinia'
import NamespaceService from '../services/namespace'
import {setModuleLoading} from '@/stores/helper'
import {createNewIndexer} from '@/indexes'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IProject} from '@/modelTypes/IProject'
import {useProjectStore} from '@/stores/projects'
const {add, remove, search, update} = createNewIndexer('namespaces', ['title', 'description'])
export const useNamespaceStore = defineStore('namespace', () => {
const projectStore = useProjectStore()
const isLoading = ref(false)
// FIXME: should be object with id as key
const namespaces = ref<INamespace[]>([])
const getProjectAndNamespaceById = computed(() => (projectId: IProject['id'], ignorePseudoNamespaces = false) => {
for (const n in namespaces.value) {
if (ignorePseudoNamespaces && namespaces.value[n].id < 0) {
continue
}
for (const l in namespaces.value[n].projects) {
if (namespaces.value[n].projects[l].id === projectId) {
return {
project: namespaces.value[n].projects[l],
namespace: namespaces.value[n],
}
}
}
}
return null
})
const getNamespaceById = computed(() => (namespaceId: INamespace['id']) => {
return namespaces.value.find(({id}) => id == namespaceId) || null
})
const searchNamespace = computed(() => {
return (query: string) => (
search(query)
?.filter(value => value > 0)
.map(getNamespaceById.value)
.filter(n => n !== null)
|| []
)
})
function setIsLoading(newIsLoading: boolean) {
isLoading.value = newIsLoading
}
function setNamespaces(newNamespaces: INamespace[]) {
namespaces.value = newNamespaces
newNamespaces.forEach(n => {
add(n)
// Check for each project in that namespace if it has a subscription and set it if not
n.projects.forEach(l => {
if (l.subscription === null || l.subscription.entity !== 'project') {
l.subscription = n.subscription
}
})
})
}
function setNamespaceById(namespace: INamespace) {
const namespaceIndex = namespaces.value.findIndex(n => n.id === namespace.id)
if (namespaceIndex === -1) {
return
}
if (!namespace.projects || namespace.projects.length === 0) {
namespace.projects = namespaces.value[namespaceIndex].projects
}
// Check for each project in that namespace if it has a subscription and set it if not
namespace.projects.forEach(l => {
if (l.subscription === null || l.subscription.entity !== 'project') {
l.subscription = namespace.subscription
}
})
namespaces.value[namespaceIndex] = namespace
update(namespace)
}
function setProjectInNamespaceById(project: IProject) {
for (const n in namespaces.value) {
// We don't have the namespace id on the project which means we need to loop over all projects until we find it.
// FIXME: Not ideal at all - we should fix that at the api level.
if (namespaces.value[n].id === project.namespaceId) {
for (const l in namespaces.value[n].projects) {
if (namespaces.value[n].projects[l].id === project.id) {
const namespace = namespaces.value[n]
namespace.projects[l] = project
namespaces.value[n] = namespace
return
}
}
}
}
}
function addNamespace(namespace: INamespace) {
namespaces.value.push(namespace)
add(namespace)
}
function removeNamespaceById(namespaceId: INamespace['id']) {
for (const n in namespaces.value) {
if (namespaces.value[n].id === namespaceId) {
remove(namespaces.value[n])
namespaces.value.splice(n, 1)
return
}
}
}
function addProjectToNamespace(project: IProject) {
for (const n in namespaces.value) {
if (namespaces.value[n].id === project.namespaceId) {
namespaces.value[n].projects.push(project)
return
}
}
}
function removeProjectFromNamespaceById(project: IProject) {
for (const n in namespaces.value) {
// We don't have the namespace id on the project which means we need to loop over all projects until we find it.
// FIXME: Not ideal at all - we should fix that at the api level.
if (namespaces.value[n].id === project.namespaceId) {
for (const l in namespaces.value[n].projects) {
if (namespaces.value[n].projects[l].id === project.id) {
namespaces.value[n].projects.splice(l, 1)
return
}
}
}
}
}
async function loadNamespaces() {
const cancel = setModuleLoading(setIsLoading)
const namespaceService = new NamespaceService()
try {
// We always load all namespaces and filter them on the frontend
const namespaces = await namespaceService.getAll({}, {is_archived: true}) as INamespace[]
setNamespaces(namespaces)
// Put all projects in the project state
const projects = namespaces.flatMap(({projects}) => projects)
projectStore.setProjects(projects)
return namespaces
} finally {
cancel()
}
}
function loadNamespacesIfFavoritesDontExist() {
// The first or second namespace should be the one holding all favorites
if (namespaces.value[0].id === -2 || namespaces.value[1]?.id === -2) {
return
}
return loadNamespaces()
}
function removeFavoritesNamespaceIfEmpty() {
if (namespaces.value[0].id === -2 && namespaces.value[0].projects.length === 0) {
namespaces.value.splice(0, 1)
}
}
async function deleteNamespace(namespace: INamespace) {
const cancel = setModuleLoading(setIsLoading)
const namespaceService = new NamespaceService()
try {
const response = await namespaceService.delete(namespace)
removeNamespaceById(namespace.id)
return response
} finally {
cancel()
}
}
async function createNamespace(namespace: INamespace) {
const cancel = setModuleLoading(setIsLoading)
const namespaceService = new NamespaceService()
try {
const createdNamespace = await namespaceService.create(namespace)
addNamespace(createdNamespace)
return createdNamespace
} finally {
cancel()
}
}
return {
isLoading: readonly(isLoading),
namespaces: readonly(namespaces),
getProjectAndNamespaceById,
getNamespaceById,
searchNamespace,
setNamespaces,
setNamespaceById,
setProjectInNamespaceById,
addNamespace,
removeNamespaceById,
addProjectToNamespace,
removeProjectFromNamespaceById,
loadNamespaces,
loadNamespacesIfFavoritesDontExist,
removeFavoritesNamespaceIfEmpty,
deleteNamespace,
createNamespace,
}
})
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useNamespaceStore, import.meta.hot))
}

View File

@ -1,12 +1,13 @@
import 'core-js/modules/esnext.map.find'
import {watch, reactive, shallowReactive, unref, toRefs, readonly, ref, computed} from 'vue'
import {acceptHMRUpdate, defineStore} from 'pinia'
import {useI18n} from 'vue-i18n'
import {klona} from 'klona/lite'
import ProjectService from '@/services/project'
import {setModuleLoading} from '@/stores/helper'
import {removeProjectFromHistory} from '@/modules/projectHistory'
import {createNewIndexer} from '@/indexes'
import {useNamespaceStore} from './namespaces'
import type {IProject} from '@/modelTypes/IProject'
@ -18,29 +19,33 @@ import {useBaseStore} from '@/stores/base'
const {add, remove, search, update} = createNewIndexer('projects', ['title', 'description'])
const FavoriteProjectsNamespace = -2
export interface ProjectState {
[id: IProject['id']]: IProject
}
export const useProjectStore = defineStore('project', () => {
const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore()
const isLoading = ref(false)
// The projects are stored as an object which has the project ids as keys.
const projects = ref<ProjectState>({})
const projects = ref<Map<number, IProject>>(new Map<number, IProject>())
const notArchivedRootProjects = computed(() => new Map([...projects.value]
.filter(p => p.parentProjectId === 0 && !p.isArchived)))
const favoriteProjects = computed(() => new Map([...projects.value]
.filter(p => !p.isArchived && p.isFavorite)))
const hasProjects = computed(() => projects.value?.size > 0)
const getProjectById = computed(() => {
return (id: IProject['id']) => typeof projects.value[id] !== 'undefined' ? projects.value[id] : null
return (id: IProject['id']) => projects.value?.get(id) ?? null
})
const getChildProjects = computed(() => {
return (id: IProject['id']) => projects.value.filter(p => p.parentProjectId === id) || []
})
const findProjectByExactname = computed(() => {
return (name: string) => {
const project = Object.values(projects.value).find(l => {
const project = projects.value.find(l => {
return l.title.toLowerCase() === name.toLowerCase()
})
return typeof project === 'undefined' ? null : project
@ -51,9 +56,9 @@ export const useProjectStore = defineStore('project', () => {
return (query: string, includeArchived = false) => {
return search(query)
?.filter(value => value > 0)
.map(id => projects.value[id])
.map(id => projects.value.get(id))
.filter(project => project.isArchived === includeArchived)
|| []
|| []
}
})
@ -62,7 +67,7 @@ export const useProjectStore = defineStore('project', () => {
}
function setProject(project: IProject) {
projects.value[project.id] = project
projects.value?.set(project.id, project)
update(project)
if (baseStore.currentProject?.id === project.id) {
@ -71,15 +76,12 @@ export const useProjectStore = defineStore('project', () => {
}
function setProjects(newProjects: IProject[]) {
newProjects.forEach(l => {
projects.value[l.id] = l
add(l)
})
newProjects.forEach(p => setProject(p))
}
function removeProjectById(project: IProject) {
remove(project)
delete projects.value[project.id]
projects.value?.delete(project.id)
}
function toggleProjectFavorite(project: IProject) {
@ -100,8 +102,6 @@ export const useProjectStore = defineStore('project', () => {
try {
const createdProject = await projectService.create(project)
createdProject.namespaceId = project.namespaceId
namespaceStore.addProjectToNamespace(createdProject)
setProject(createdProject)
return createdProject
} finally {
@ -116,22 +116,10 @@ export const useProjectStore = defineStore('project', () => {
try {
await projectService.update(project)
setProject(project)
namespaceStore.setProjectInNamespaceById(project)
// the returned project from projectService.update is the same!
// in order to not create a manipulation in pinia store we have to create a new copy
const newProject = {
...project,
namespaceId: FavoriteProjectsNamespace,
}
namespaceStore.removeProjectFromNamespaceById(newProject)
if (project.isFavorite) {
namespaceStore.addProjectToNamespace(newProject)
}
namespaceStore.loadNamespacesIfFavoritesDontExist()
namespaceStore.removeFavoritesNamespaceIfEmpty()
return newProject
return klona(project)
} catch (e) {
// Reset the project state to the initial one to avoid confusion for the user
setProject({
@ -151,7 +139,6 @@ export const useProjectStore = defineStore('project', () => {
try {
const response = await projectService.delete(project)
removeProjectById(project)
namespaceStore.removeProjectFromNamespaceById(project)
removeProjectFromHistory({id: project.id})
return response
} finally {
@ -159,11 +146,42 @@ export const useProjectStore = defineStore('project', () => {
}
}
async function loadProjects() {
const cancel = setModuleLoading(setIsLoading)
const projectService = new ProjectService()
try {
const loadedProjects = await projectService.getAll({}, {is_archived: true}) as IProject[]
projects.value = new Map<number, IProject>()
setProjects(loadedProjects)
return loadedProjects
} finally {
cancel()
}
}
function getParentProjects(project: IProject): IProject[] {
if (!project?.parentProjectId) {
return [project]
}
const parentProject = projects.value?.get(project.parentProjectId)
return [
...getParentProjects(parentProject),
project,
]
}
return {
isLoading: readonly(isLoading),
projects: readonly(projects),
notArchivedRootProjects: readonly(notArchivedRootProjects),
favoriteProjects: readonly(favoriteProjects),
hasProjects: readonly(hasProjects),
getProjectById,
getChildProjects,
findProjectByExactname,
searchProject,
@ -171,9 +189,11 @@ export const useProjectStore = defineStore('project', () => {
setProjects,
removeProjectById,
toggleProjectFavorite,
loadProjects,
createProject,
updateProject,
deleteProject,
getParentProjects,
}
})
@ -193,7 +213,20 @@ export function useProject(projectId: MaybeRef<IProject['id']>) {
)
const projectStore = useProjectStore()
const parentProject = ref<IProject | null>(null)
watch(
() => project.parentProjectId,
projectId => {
if (project.parentProjectId) {
parentProject.value = projectStore.getProjectById(project.parentProjectId)
}
},
{immediate: true},
)
async function save() {
project.parentProjectId = parentProject.value.id
await projectStore.updateProject(project)
success({message: t('project.edit.success')})
}
@ -201,11 +234,12 @@ export function useProject(projectId: MaybeRef<IProject['id']>) {
return {
isLoading: readonly(isLoading),
project,
parentProject,
save,
}
}
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useProjectStore, import.meta.hot))
import.meta.hot.accept(acceptHMRUpdate(useProjectStore, import.meta.hot))
}

View File

@ -432,6 +432,17 @@ export const useTaskStore = defineStore('task', () => {
coverImageAttachmentId: attachment ? attachment.id : 0,
})
}
async function toggleFavorite(task: ITask) {
const taskService = new TaskService()
task.isFavorite = !task.isFavorite
task = await taskService.update(task)
// reloading the projects list so that the Favorites project shows up or is hidden when there are (or are not) favorite tasks
await projectStore.loadProjects()
return task
}
return {
tasks,

View File

@ -33,3 +33,7 @@ $switch-view-height: 2.69rem;
$navbar-height: 4rem;
$navbar-width: 300px;
$navbar-padding: 2rem;
$vikunja-nav-color: var(--grey-700);
$vikunja-nav-selected-width: 0.4rem;

View File

@ -8,4 +8,5 @@
@import "link-share";
@import "loading";
@import "flatpickr";
@import 'helpers';
@import 'helpers';
@import 'navigation';

View File

@ -0,0 +1,137 @@
// these are general menu styles
// should be in own components
.menu {
.menu-list .list-menu-link,
.menu-list a {
display: flex;
align-items: center;
cursor: pointer;
.color-bubble {
height: 12px;
flex: 0 0 12px;
}
}
.menu-list {
list-style: none;
margin: 0;
padding: 0;
&.other-menu-items li,
li > section {
height: 44px;
display: flex;
align-items: center;
&:hover {
background: var(--white);
}
}
li > section {
.menu-list-dropdown {
opacity: 1;
transition: $transition;
}
@media(hover: hover) and (pointer: fine) {
.menu-list-dropdown {
opacity: 0;
}
&:hover .menu-list-dropdown {
opacity: 1;
}
}
}
li > menu {
margin: 0 0 0 var(--menu-nested-list-margin);
}
.menu-item-icon {
color: var(--grey-400);
}
.menu-list-dropdown-trigger {
display: flex;
padding: 0.5rem;
}
.flip-list-move {
transition: transform $transition-duration;
}
.ghost {
background: var(--grey-200);
* {
opacity: 0;
}
}
a:hover {
background: transparent;
}
.list-menu-link,
li, li > section {
.collapse-project-button {
padding: .5rem .25rem .5rem .5rem;
svg {
transition: all $transition;
color: var(--grey-400);
}
}
.collapse-project-button-placeholder {
width: 2.25rem;
}
> a {
color: $vikunja-nav-color;
padding: .75rem .5rem .75rem .25rem;
transition: all 0.2s ease;
border-radius: 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: 100%;
&.router-link-exact-active {
color: var(--primary);
}
.icon {
height: 1rem;
vertical-align: middle;
padding-right: 0.5rem;
}
&.router-link-exact-active .icon:not(.handle) {
color: var(--primary);
}
.handle {
opacity: 0;
transition: opacity $transition;
}
&:hover .handle {
opacity: 1;
}
}
}
&:not(.dragging-disabled) .handle {
cursor: grab;
}
menu {
border-left: 0;
}
}
}

View File

@ -17,22 +17,11 @@
@taskAdded="updateTaskKey"
class="is-max-width-desktop"
/>
<template v-if="!hasTasks && !loading">
<template v-if="defaultNamespaceId > 0">
<p class="mt-4">{{ $t('home.project.newText') }}</p>
<x-button
:to="{ name: 'project.create', params: { namespaceId: defaultNamespaceId } }"
:shadow="false"
class="ml-2"
>
{{ $t('home.project.new') }}
</x-button>
</template>
<p class="mt-4" v-if="migratorsEnabled">
<template v-if="!hasTasks && !loading && migratorsEnabled">
<p class="mt-4">
{{ $t('home.project.importText') }}
</p>
<x-button
v-if="migratorsEnabled"
:to="{ name: 'migrate.start' }"
:shadow="false">
{{ $t('home.project.import') }}
@ -66,7 +55,6 @@ import {useDaytimeSalutation} from '@/composables/useDaytimeSalutation'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import {useConfigStore} from '@/stores/config'
import {useNamespaceStore} from '@/stores/namespaces'
import {useAuthStore} from '@/stores/auth'
import {useTaskStore} from '@/stores/tasks'
import type {IProject} from '@/modelTypes/IProject'
@ -76,7 +64,6 @@ const salutation = useDaytimeSalutation()
const baseStore = useBaseStore()
const authStore = useAuthStore()
const configStore = useConfigStore()
const namespaceStore = useNamespaceStore()
const projectStore = useProjectStore()
const taskStore = useTaskStore()
@ -93,8 +80,7 @@ const projectHistory = computed(() => {
const migratorsEnabled = computed(() => configStore.availableMigrators?.length > 0)
const hasTasks = computed(() => baseStore.hasTasks)
const defaultNamespaceId = computed(() => namespaceStore.namespaces?.[0]?.id || 0)
const hasProjects = computed(() => namespaceStore.namespaces?.[0]?.projects.length > 0)
const hasProjects = computed(() => projectStore.hasProjects)
const loading = computed(() => taskStore.isLoading)
const deletionScheduledAt = computed(() => parseDateOrNull(authStore.info?.deletionScheduledAt))

View File

@ -89,8 +89,8 @@ import {formatDateLong} from '@/helpers/time/formatDate'
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
import {MIGRATORS} from './migrators'
import {useNamespaceStore} from '@/stores/namespaces'
import {useTitle} from '@/composables/useTitle'
import {useProjectStore} from '@/stores/projects'
const PROGRESS_DOTS_COUNT = 8
@ -163,8 +163,8 @@ async function migrate() {
? await migrationFileService.migrate(migrationConfig as File)
: await migrationService.migrate(migrationConfig as MigrationConfig)
message.value = result.message
const namespaceStore = useNamespaceStore()
return namespaceStore.loadNamespaces()
const projectStore = useProjectStore()
return projectStore.loadProjects()
} finally {
isMigrating.value = false
}

View File

@ -1,139 +0,0 @@
<template>
<div class="content loader-container" :class="{'is-loading': loading}" v-cy="'namespaces-list'">
<header class="namespace-header">
<fancycheckbox v-model="showArchived" v-cy="'show-archived-check'">
{{ $t('namespace.showArchived') }}
</fancycheckbox>
<div class="action-buttons">
<x-button :to="{name: 'filters.create'}" icon="filter">
{{ $t('filters.create.title') }}
</x-button>
<x-button :to="{name: 'namespace.create'}" icon="plus" v-cy="'new-namespace'">
{{ $t('namespace.create.title') }}
</x-button>
</div>
</header>
<p v-if="namespaces.length === 0" class="has-text-centered has-text-grey mt-4 is-italic">
{{ $t('namespace.noneAvailable') }}
<BaseButton :to="{name: 'namespace.create'}">
{{ $t('namespace.create.title') }}.
</BaseButton>
</p>
<section :key="`n${n.id}`" class="namespace" v-for="n in namespaces">
<x-button
v-if="n.id > 0 && n.projects.length > 0"
:to="{name: 'project.create', params: {namespaceId: n.id}}"
class="is-pulled-right"
variant="secondary"
icon="plus"
>
{{ $t('project.create.header') }}
</x-button>
<x-button
v-if="n.isArchived"
:to="{name: 'namespace.settings.archive', params: {id: n.id}}"
class="is-pulled-right mr-4"
variant="secondary"
icon="archive"
>
{{ $t('namespace.unarchive') }}
</x-button>
<h2 class="namespace-title">
<span v-cy="'namespace-title'">{{ getNamespaceTitle(n) }}</span>
<span v-if="n.isArchived" class="is-archived">
{{ $t('namespace.archived') }}
</span>
</h2>
<p v-if="n.projects.length === 0" class="has-text-centered has-text-grey mt-4 is-italic">
{{ $t('namespace.noProjects') }}
<BaseButton :to="{name: 'project.create', params: {namespaceId: n.id}}">
{{ $t('namespace.createProject') }}
</BaseButton>
</p>
<ProjectCardGrid v-else
:projects="n.projects"
:show-archived="showArchived"
/>
</section>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import ProjectCardGrid from '@/components/project/partials/ProjectCardGrid.vue'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
import {useTitle} from '@/composables/useTitle'
import {useStorage} from '@vueuse/core'
import {useNamespaceStore} from '@/stores/namespaces'
const {t} = useI18n()
const namespaceStore = useNamespaceStore()
useTitle(() => t('namespace.title'))
const showArchived = useStorage('showArchived', false)
const loading = computed(() => namespaceStore.isLoading)
const namespaces = computed(() => {
return namespaceStore.namespaces.filter(namespace => showArchived.value
? true
: !namespace.isArchived,
)
})
</script>
<style lang="scss" scoped>
.namespace-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
@media screen and (max-width: $tablet) {
flex-direction: column;
}
}
.action-buttons {
display: flex;
justify-content: space-between;
gap: 1rem;
@media screen and (max-width: $tablet) {
width: 100%;
flex-direction: column;
align-items: stretch;
}
}
.namespace:not(:first-child) {
margin-top: 1rem;
}
.namespace-title {
display: flex;
align-items: center;
}
.is-archived {
font-size: 0.75rem;
border: 1px solid var(--grey-500);
color: $grey !important;
padding: 2px 4px;
border-radius: 3px;
font-family: $vikunja-font;
background: var(--white-translucent);
margin-left: .5rem;
}
</style>

View File

@ -1,84 +0,0 @@
<template>
<create-edit
:title="$t('namespace.create.title')"
@create="newNamespace()"
:primary-disabled="namespace.title === ''"
>
<div class="field">
<label class="label" for="namespaceTitle">{{ $t('namespace.attributes.title') }}</label>
<div
class="control is-expanded"
:class="{ 'is-loading': namespaceService.loading }"
>
<!-- The user should be able to close the modal by pressing escape - that already works with the default modal.
But with the input modal here since it autofocuses the input that input field catches the focus instead.
Hence we place the listener on the input field directly. -->
<input
@keyup.enter="newNamespace()"
@keyup.esc="$router.back()"
class="input"
:placeholder="$t('namespace.attributes.titlePlaceholder')"
type="text"
:class="{ disabled: namespaceService.loading }"
v-focus
v-model="namespace.title"
/>
</div>
</div>
<p class="help is-danger" v-if="showError && namespace.title === ''">
{{ $t('namespace.create.titleRequired') }}
</p>
<div class="field">
<label class="label">{{ $t('namespace.attributes.color') }}</label>
<div class="control">
<color-picker v-model="namespace.hexColor"/>
</div>
</div>
<message class="mt-4">
<h4 class="title">{{ $t('namespace.create.tooltip') }}</h4>
{{ $t('namespace.create.explanation') }}
</message>
</create-edit>
</template>
<script setup lang="ts">
import {ref, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import Message from '@/components/misc/message.vue'
import CreateEdit from '@/components/misc/create-edit.vue'
import ColorPicker from '@/components/input/ColorPicker.vue'
import NamespaceModel from '@/models/namespace'
import NamespaceService from '@/services/namespace'
import {useNamespaceStore} from '@/stores/namespaces'
import type {INamespace} from '@/modelTypes/INamespace'
import {useTitle} from '@/composables/useTitle'
import {success} from '@/message'
const showError = ref(false)
const namespace = ref<INamespace>(new NamespaceModel())
const namespaceService = shallowReactive(new NamespaceService())
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
useTitle(() => t('namespace.create.title'))
async function newNamespace() {
if (namespace.value.title === '') {
showError.value = true
return
}
showError.value = false
const newNamespace = await namespaceService.create(namespace.value)
useNamespaceStore().addNamespace(newNamespace)
success({message: t('namespace.create.success')})
router.back()
}
</script>

View File

@ -1,89 +0,0 @@
<template>
<modal
@close="$router.back()"
@submit="archiveNamespace()"
>
<template #header><span>{{ title }}</span></template>
<template #text>
<p>
{{
namespace.isArchived
? $t('namespace.archive.unarchiveText')
: $t('namespace.archive.archiveText')
}}
</p>
</template>
</modal>
</template>
<script lang="ts">
export default { name: 'namespace-setting-archive' }
</script>
<script setup lang="ts">
import {watch, ref, computed, shallowReactive, type PropType} from 'vue'
import {useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
import {useNamespaceStore} from '@/stores/namespaces'
import NamespaceService from '@/services/namespace'
import NamespaceModel from '@/models/namespace'
import type {INamespace} from '@/modelTypes/INamespace'
const props = defineProps({
namespaceId: {
type: Number as PropType<INamespace['id']>,
required: true,
},
})
const router = useRouter()
const {t} = useI18n({useScope: 'global'})
const namespaceStore = useNamespaceStore()
const namespaceService = shallowReactive(new NamespaceService())
const namespace = ref<INamespace>(new NamespaceModel())
watch(
() => props.namespaceId,
async () => {
namespace.value = namespaceStore.getNamespaceById(props.namespaceId) || new NamespaceModel()
// FIXME: ressouce should be loaded in store
namespace.value = await namespaceService.get({id: props.namespaceId})
},
{ immediate: true },
)
const title = computed(() => {
if (!namespace.value) {
return
}
return namespace.value.isArchived
? t('namespace.archive.titleUnarchive', {namespace: namespace.value.title})
: t('namespace.archive.titleArchive', {namespace: namespace.value.title})
})
useTitle(title)
async function archiveNamespace() {
try {
const isArchived = !namespace.value.isArchived
const archivedNamespace = await namespaceService.update({
...namespace.value,
isArchived,
})
namespaceStore.setNamespaceById(archivedNamespace)
success({
message: isArchived
? t('namespace.archive.success')
: t('namespace.archive.unarchiveSuccess'),
})
} finally {
router.back()
}
}
</script>

View File

@ -1,69 +0,0 @@
<template>
<modal
@close="$router.back()"
@submit="deleteNamespace()"
>
<template #header><span>{{ title }}</span></template>
<template #text>
<p>{{ $t('namespace.delete.text1') }}<br/>
{{ $t('namespace.delete.text2') }}</p>
</template>
</modal>
</template>
<script lang="ts">
export default { name: 'namespace-setting-delete' }
</script>
<script setup lang="ts">
import {ref, computed, watch, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import {useTitle} from '@/composables/useTitle'
import {success} from '@/message'
import {useNamespaceStore} from '@/stores/namespaces'
import NamespaceModel from '@/models/namespace'
import NamespaceService from '@/services/namespace'
import type { INamespace } from '@/modelTypes/INamespace'
const props = defineProps({
namespaceId: {
type: Number,
required: true,
},
})
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
const namespaceStore = useNamespaceStore()
const namespaceService = shallowReactive(new NamespaceService())
const namespace = ref<INamespace>(new NamespaceModel())
watch(
() => props.namespaceId,
async () => {
namespace.value = namespaceStore.getNamespaceById(props.namespaceId) || new NamespaceModel()
// FIXME: ressouce should be loaded in store
namespace.value = await namespaceService.get({id: props.namespaceId})
},
{ immediate: true },
)
const title = computed(() => {
if (!namespace.value) {
return
}
return t('namespace.delete.title', {namespace: namespace.value.title})
})
useTitle(title)
async function deleteNamespace() {
await namespaceStore.deleteNamespace(namespace.value)
success({message: t('namespace.delete.success')})
router.push({name: 'home'})
}
</script>

View File

@ -1,120 +0,0 @@
<template>
<create-edit
:title="title"
primary-icon=""
:primary-label="$t('misc.save')"
@primary="save"
:tertiary="$t('misc.delete')"
@tertiary="$router.push({ name: 'namespace.settings.delete', params: { id: $route.params.id } })"
>
<form @submit.prevent="save()">
<div class="field">
<label class="label" for="namespacetext">{{ $t('namespace.attributes.title') }}</label>
<div class="control">
<input
:class="{ 'disabled': namespaceService.loading}"
:disabled="namespaceService.loading || undefined"
class="input"
id="namespacetext"
:placeholder="$t('namespace.attributes.titlePlaceholder')"
type="text"
v-focus
v-model="namespace.title"/>
</div>
</div>
<div class="field">
<label class="label" for="namespacedescription">{{ $t('namespace.attributes.description') }}</label>
<div class="control">
<AsyncEditor
:class="{ 'disabled': namespaceService.loading}"
:preview-is-default="false"
id="namespacedescription"
:placeholder="$t('namespace.attributes.descriptionPlaceholder')"
v-if="editorActive"
v-model="namespace.description"
/>
</div>
</div>
<div class="field">
<label class="label" for="isArchivedCheck">{{ $t('namespace.attributes.archived') }}</label>
<div class="control">
<fancycheckbox
v-model="namespace.isArchived"
v-tooltip="$t('namespace.archive.description')">
{{ $t('namespace.attributes.isArchived') }}
</fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">{{ $t('namespace.attributes.color') }}</label>
<div class="control">
<color-picker v-model="namespace.hexColor"/>
</div>
</div>
</form>
</create-edit>
</template>
<script lang="ts" setup>
import {nextTick, ref, watch} from 'vue'
import {success} from '@/message'
import router from '@/router'
import AsyncEditor from '@/components/input/AsyncEditor'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import ColorPicker from '@/components/input/ColorPicker.vue'
import CreateEdit from '@/components/misc/create-edit.vue'
import NamespaceService from '@/services/namespace'
import NamespaceModel from '@/models/namespace'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@/composables/useTitle'
import {useNamespaceStore} from '@/stores/namespaces'
import type {INamespace} from '@/modelTypes/INamespace'
const {t} = useI18n({useScope: 'global'})
const namespaceStore = useNamespaceStore()
const namespaceService = ref(new NamespaceService())
const namespace = ref<INamespace>(new NamespaceModel())
const editorActive = ref(false)
const title = ref('')
useTitle(() => title.value)
const props = defineProps({
namespaceId: {
type: Number,
required: true,
},
})
watch(
() => props.namespaceId,
loadNamespace,
{
immediate: true,
},
)
async function loadNamespace() {
// HACK: This makes the editor trigger its mounted function again which makes it forget every input
// it currently has in its textarea. This is a counter-hack to a hack inside of vue-easymde
// which made it impossible to detect change from the outside. Therefore the component would
// not update if new content from the outside was made available.
// See https://github.com/NikulinIlya/vue-easymde/issues/3
editorActive.value = false
nextTick(() => editorActive.value = true)
namespace.value = await namespaceService.value.get({id: props.namespaceId})
title.value = t('namespace.edit.title', {namespace: namespace.value.title})
}
async function save() {
const updatedNamespace = await namespaceService.value.update(namespace.value)
// Update the namespace in the parent
namespaceStore.setNamespaceById(updatedNamespace)
success({message: t('namespace.edit.success')})
router.back()
}
</script>

View File

@ -1,67 +0,0 @@
<template>
<create-edit
:title="title"
:has-primary-action="false"
>
<template v-if="namespace">
<manageSharing
:id="namespace.id"
:userIsAdmin="userIsAdmin"
shareType="user"
type="namespace"
/>
<manageSharing
:id="namespace.id"
:userIsAdmin="userIsAdmin"
shareType="team"
type="namespace"
/>
</template>
</create-edit>
</template>
<script lang="ts">
export default { name: 'namespace-setting-share' }
</script>
<script lang="ts" setup>
import {ref, computed, watchEffect} from 'vue'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import NamespaceService from '@/services/namespace'
import NamespaceModel from '@/models/namespace'
import type {INamespace} from '@/modelTypes/INamespace'
import {RIGHTS} from '@/constants/rights'
import CreateEdit from '@/components/misc/create-edit.vue'
import manageSharing from '@/components/sharing/userTeam.vue'
import {useTitle} from '@/composables/useTitle'
const {t} = useI18n({useScope: 'global'})
const namespace = ref<INamespace>()
const title = computed(() => namespace.value?.title
? t('namespace.share.title', { namespace: namespace.value.title })
: '',
)
useTitle(title)
const userIsAdmin = computed(() => namespace?.value?.maxRight === RIGHTS.ADMIN)
async function loadNamespace(namespaceId: number) {
if (!namespaceId) return
const namespaceService = new NamespaceService()
namespace.value = await namespaceService.get(new NamespaceModel({id: namespaceId}))
// TODO: set namespace in store
}
const route = useRoute()
const namespaceId = computed(() => route.params.namespaceId !== undefined
? parseInt(route.params.namespaceId as string)
: undefined,
)
watchEffect(() => namespaceId.value !== undefined && loadNamespace(namespaceId.value))
</script>

View File

@ -0,0 +1,145 @@
<template>
<div class="content loader-container" :class="{'is-loading': loading}" v-cy="'projects-list'">
<header class="project-header">
<fancycheckbox v-model="showArchived" v-cy="'show-archived-check'">
{{ $t('project.showArchived') }}
</fancycheckbox>
<div class="action-buttons">
<x-button :to="{name: 'filters.create'}" icon="filter">
{{ $t('filters.create.title') }}
</x-button>
<x-button :to="{name: 'project.create'}" icon="plus" v-cy="'new-project'">
{{ $t('project.create.header') }}
</x-button>
</div>
</header>
<ProjectCardGrid
:projects="projects"
:show-archived="showArchived"
/>
<!-- <p v-if="projects.length === 0" class="has-text-centered has-text-grey mt-4 is-italic">-->
<!-- {{ $t('project.noneAvailable') }}-->
<!-- <BaseButton :to="{name: 'project.create'}">-->
<!-- {{ $t('project.create.title') }}.-->
<!-- </BaseButton>-->
<!-- </p>-->
<!-- <section :key="`n${n.id}`" class="project" v-for="n in projects">-->
<!-- <x-button-->
<!-- v-if="n.id > 0 && n.projects.length > 0"-->
<!-- :to="{name: 'project.create', params: {projectId: n.id}}"-->
<!-- class="is-pulled-right"-->
<!-- variant="secondary"-->
<!-- icon="plus"-->
<!-- >-->
<!-- {{ $t('project.create.header') }}-->
<!-- </x-button>-->
<!-- <x-button-->
<!-- v-if="n.isArchived"-->
<!-- :to="{name: 'project.settings.archive', params: {id: n.id}}"-->
<!-- class="is-pulled-right mr-4"-->
<!-- variant="secondary"-->
<!-- icon="archive"-->
<!-- >-->
<!-- {{ $t('project.unarchive') }}-->
<!-- </x-button>-->
<!-- <h2 class="project-title">-->
<!-- <span v-cy="'project-title'">{{ getProjectTitle(n) }}</span>-->
<!-- <span v-if="n.isArchived" class="is-archived">-->
<!-- {{ $t('project.archived') }}-->
<!-- </span>-->
<!-- </h2>-->
<!-- &lt;!&ndash; <p v-if="n.projects.length === 0" class="has-text-centered has-text-grey mt-4 is-italic">&ndash;&gt;-->
<!-- &lt;!&ndash; {{ $t('project.noProjects') }}&ndash;&gt;-->
<!-- &lt;!&ndash; <BaseButton :to="{name: 'project.create', params: {projectId: n.id}}">&ndash;&gt;-->
<!-- &lt;!&ndash; {{ $t('project.createProject') }}&ndash;&gt;-->
<!-- &lt;!&ndash; </BaseButton>&ndash;&gt;-->
<!-- &lt;!&ndash; </p>&ndash;&gt;-->
<!-- <ProjectCardGrid v-else-->
<!-- :projects="n.projects"-->
<!-- :show-archived="showArchived"-->
<!-- />-->
<!-- </section>-->
</div>
</template>
<script setup lang="ts">
import 'core-js/modules/esnext.map.filter'
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import ProjectCardGrid from '@/components/project/partials/ProjectCardGrid.vue'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import {useTitle} from '@/composables/useTitle'
import {useStorage} from '@vueuse/core'
import {useProjectStore} from '@/stores/projects'
const {t} = useI18n()
const projectStore = useProjectStore()
useTitle(() => t('project.title'))
const showArchived = useStorage('showArchived', false)
const loading = computed(() => projectStore.isLoading)
const projects = computed(() => {
return projectStore.projects.filter(project => showArchived.value
? true
: !project.isArchived,
)
})
</script>
<style lang="scss" scoped>
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
@media screen and (max-width: $tablet) {
flex-direction: column;
}
}
.action-buttons {
display: flex;
justify-content: space-between;
gap: 1rem;
@media screen and (max-width: $tablet) {
width: 100%;
flex-direction: column;
align-items: stretch;
}
}
.project:not(:first-child) {
margin-top: 1rem;
}
.project-title {
display: flex;
align-items: center;
}
.is-archived {
font-size: 0.75rem;
border: 1px solid var(--grey-500);
color: $grey !important;
padding: 2px 4px;
border-radius: 3px;
font-family: $vikunja-font;
background: var(--white-translucent);
margin-left: .5rem;
}
</style>

View File

@ -22,6 +22,12 @@
<p class="help is-danger" v-if="showError && project.title === ''">
{{ $t('project.create.addTitleRequired') }}
</p>
<div class="field">
<label class="label">{{ $t('project.parent') }}</label>
<div class="control">
<project-search v-model="parentProject"/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('project.color') }}</label>
<div class="control">
@ -44,6 +50,8 @@ import ColorPicker from '@/components/input/ColorPicker.vue'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
import {useProjectStore} from '@/stores/projects'
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
import type {IProject} from '@/modelTypes/IProject'
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
@ -55,6 +63,7 @@ const showError = ref(false)
const project = reactive(new ProjectModel())
const projectService = shallowReactive(new ProjectService())
const projectStore = useProjectStore()
const parentProject = ref<IProject | null>(null)
async function createNewProject() {
if (project.title === '') {
@ -62,8 +71,11 @@ async function createNewProject() {
return
}
showError.value = false
if (parentProject.value) {
project.parentProjectId = parentProject.value.id
}
project.namespaceId = Number(route.params.namespaceId as string)
const newProject = await projectStore.createProject(project)
await router.push({
name: 'project.index',

View File

@ -108,7 +108,6 @@ import CustomTransition from '@/components/misc/CustomTransition.vue'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import {useNamespaceStore} from '@/stores/namespaces'
import {useConfigStore} from '@/stores/config'
import BackgroundUnsplashService from '@/services/backgroundUnsplash'
@ -146,7 +145,6 @@ const debounceNewBackgroundSearch = debounce(newBackgroundSearch, SEARCH_DEBOUNC
const backgroundUploadService = ref(new BackgroundUploadService())
const projectService = ref(new ProjectService())
const projectStore = useProjectStore()
const namespaceStore = useNamespaceStore()
const configStore = useConfigStore()
const unsplashBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('unsplash'))
@ -195,7 +193,6 @@ async function setBackground(backgroundId: string) {
projectId: route.params.projectId,
})
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
namespaceStore.setProjectInNamespaceById(project)
projectStore.setProject(project)
success({message: t('project.background.success')})
}
@ -211,7 +208,6 @@ async function uploadBackground() {
backgroundUploadInput.value?.files[0],
)
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
namespaceStore.setProjectInNamespaceById(project)
projectStore.setProject(project)
success({message: t('project.background.success')})
}
@ -219,7 +215,6 @@ async function uploadBackground() {
async function removeBackground() {
const project = await projectService.value.removeBackground(currentProject.value)
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
namespaceStore.setProjectInNamespaceById(project)
projectStore.setProject(project)
success({message: t('project.background.removeSuccess')})
router.back()

View File

@ -7,67 +7,55 @@
:loading="projectDuplicateService.loading"
>
<p>{{ $t('project.duplicate.text') }}</p>
<Multiselect
:placeholder="$t('namespace.search')"
@search="findNamespaces"
:search-results="namespaces"
@select="selectNamespace"
label="title"
:search-delay="10"
/>
<project-search v-model="parentProject"/>
</create-edit>
</template>
<script setup lang="ts">
import {ref, shallowReactive} from 'vue'
import {ref, shallowReactive, watch} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import ProjectDuplicateService from '@/services/projectDuplicateService'
import CreateEdit from '@/components/misc/create-edit.vue'
import Multiselect from '@/components/input/multiselect.vue'
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
import ProjectDuplicateModel from '@/models/projectDuplicateModel'
import type {INamespace} from '@/modelTypes/INamespace'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
import {useNamespaceSearch} from '@/composables/useNamespaceSearch'
import {useProjectStore} from '@/stores/projects'
import {useNamespaceStore} from '@/stores/namespaces'
import type {IProject} from '@/modelTypes/IProject'
const {t} = useI18n({useScope: 'global'})
useTitle(() => t('project.duplicate.title'))
const {
namespaces,
findNamespaces,
} = useNamespaceSearch()
const selectedNamespace = ref<INamespace>()
function selectNamespace(namespace: INamespace) {
selectedNamespace.value = namespace
}
const route = useRoute()
const router = useRouter()
const projectStore = useProjectStore()
const namespaceStore = useNamespaceStore()
const projectDuplicateService = shallowReactive(new ProjectDuplicateService())
const parentProject = ref<IProject | null>(null)
watch(
() => route.params.projectId,
projectId => {
const project = projectStore.getProjectById(route.params.projectId)
if (project.parentProjectId) {
parentProject.value = projectStore.getProjectById(project.parentProjectId)
}
},
{immediate: true},
)
async function duplicateProject() {
const projectDuplicate = new ProjectDuplicateModel({
// FIXME: should be parameter
projectId: route.params.projectId,
namespaceId: selectedNamespace.value?.id,
parentProjectId: parentProject.value.id,
})
const duplicate = await projectDuplicateService.create(projectDuplicate)
namespaceStore.addProjectToNamespace(duplicate.project)
projectStore.setProject(duplicate.project)
success({message: t('project.duplicate.success')})
router.push({name: 'project.index', params: {projectId: duplicate.project.id}})

View File

@ -42,6 +42,12 @@
v-model="project.identifier"/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('project.parent') }}</label>
<div class="control">
<project-search v-model="parentProject"/>
</div>
</div>
<div class="field">
<label class="label" for="projectdescription">{{ $t('project.edit.description') }}</label>
<div class="control">
@ -70,13 +76,14 @@ export default { name: 'project-setting-edit' }
</script>
<script setup lang="ts">
import type {PropType} from 'vue'
import {type PropType} from 'vue'
import {useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import Editor from '@/components/input/AsyncEditor'
import ColorPicker from '@/components/input/ColorPicker.vue'
import CreateEdit from '@/components/misc/create-edit.vue'
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
import type {IProject} from '@/modelTypes/IProject'
@ -96,7 +103,7 @@ const router = useRouter()
const {t} = useI18n({useScope: 'global'})
const {project, save: saveProject, isLoading} = useProject(props.projectId)
const {project, parentProject, save: saveProject, isLoading} = useProject(props.projectId)
useTitle(() => project?.title ? t('project.edit.title', {project: project.title}) : '')

View File

@ -13,11 +13,13 @@
:can-write="canWrite"
ref="heading"
/>
<h6 class="subtitle" v-if="parent && parent.namespace && parent.project">
{{ getNamespaceTitle(parent.namespace) }} &rsaquo;
<router-link :to="{ name: 'project.index', params: { projectId: parent.project.id } }">
{{ getProjectTitle(parent.project) }}
</router-link>
<h6 class="subtitle" v-if="project?.id">
<template v-for="p in projectStore.getParentProjects(project)">
<router-link :to="{ name: 'project.index', params: { projectId: p.id } }">
{{ getProjectTitle(p) }}
</router-link>
<span class="has-text-grey-light" v-if="p.id !== project.id"> &gt; </span>
</template>
</h6>
<checklist-summary :task="task"/>
@ -445,7 +447,7 @@
</template>
<script lang="ts" setup>
import {ref, reactive, toRef, shallowReactive, computed, watch, nextTick, type PropType} from 'vue'
import {ref, reactive, toRef, shallowReactive, computed, watch, watchEffect, nextTick, type PropType} from 'vue'
import {useRouter, type RouteLocation} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {unrefElement} from '@vueuse/core'
@ -483,12 +485,10 @@ import TaskSubscription from '@/components/misc/subscription.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import {uploadFile} from '@/helpers/attachments'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import {scrollIntoView} from '@/helpers/scrollIntoView'
import {useBaseStore} from '@/stores/base'
import {useNamespaceStore} from '@/stores/namespaces'
import {useAttachmentStore} from '@/stores/attachments'
import {useTaskStore} from '@/stores/tasks'
import {useKanbanStore} from '@/stores/kanban'
@ -497,6 +497,7 @@ import {useTitle} from '@/composables/useTitle'
import {success} from '@/message'
import type {Action as MessageAction} from '@/message'
import {useProjectStore} from '@/stores/projects'
const props = defineProps({
taskId: {
@ -514,7 +515,7 @@ const router = useRouter()
const {t} = useI18n({useScope: 'global'})
const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore()
const projectStore = useProjectStore()
const attachmentStore = useAttachmentStore()
const taskStore = useTaskStore()
const kanbanStore = useKanbanStore()
@ -535,32 +536,13 @@ const visible = ref(false)
const taskId = toRef(props, 'taskId')
const parent = computed(() => {
if (!task.projectId) {
return {
namespace: null,
project: null,
}
}
if (!namespaceStore.getProjectAndNamespaceById) {
return null
}
return namespaceStore.getProjectAndNamespaceById(task.projectId)
const project = computed(() => task.projectId ? projectStore.getProjectById(task.projectId) : null)
watchEffect(() => {
baseStore.handleSetCurrentProject({
project: project.value,
})
})
watch(
parent,
(parent) => {
const parentProject = parent !== null ? parent.project : null
if (parentProject !== null) {
baseStore.handleSetCurrentProject({project: parentProject})
}
},
{immediate: true},
)
const canWrite = computed(() => (
task.maxRight !== null &&
task.maxRight > RIGHTS.READ
@ -769,10 +751,8 @@ async function changeProject(project: IProject) {
}
async function toggleFavorite() {
task.isFavorite = !task.isFavorite
const newTask = await taskService.update(task)
const newTask = await taskStore.toggleFavorite(task.value)
Object.assign(task, newTask)
await namespaceStore.loadNamespacesIfFavoritesDontExist()
}
async function setPriority(priority: Priority) {