Compare commits

...

115 Commits

Author SHA1 Message Date
kolaente 9241c7e587
fix: lint 2023-04-18 12:21:12 +02:00
kolaente cbf04d1f6f
feat: allow creating a new project directly as a child project from another one 2023-04-18 12:21:12 +02:00
kolaente 7cc7b18583
chore: don't recalculate everything 2023-04-18 12:21:12 +02:00
kolaente e8f02bd254
chore: remove type annotation for computed 2023-04-18 12:21:12 +02:00
kolaente f428fd151b
fix: don't set the current project when setting a project 2023-04-18 12:21:12 +02:00
kolaente 99245da0d1
chore: don't show selection for parent project when no projects are available 2023-04-18 12:21:12 +02:00
kolaente 5c7477ca91
chore: re-add top menu spacing 2023-04-18 12:21:12 +02:00
kolaente 7aab067e4f
fix: sort in store 2023-04-18 12:21:12 +02:00
kolaente 9f5031516b
feat: replace color dot with handle icon on hover 2023-04-18 12:21:11 +02:00
kolaente 4eb38deaec
chore: use project id type 2023-04-18 12:21:11 +02:00
kolaente fde52c4284
chore: don't set the current project to null if it's undefined already 2023-04-18 12:21:11 +02:00
kolaente c8a370d381
chore: move duplicate project logic to composable 2023-04-18 12:21:11 +02:00
kolaente 632f5f46b3
chore: redirect to new project after creating from store 2023-04-18 12:21:11 +02:00
kolaente 5b14148991
chore: remove unused code 2023-04-18 12:21:11 +02:00
kolaente 8ae40dfa8e
chore: don't wrap a computed in another computed 2023-04-18 12:21:11 +02:00
kolaente 1d6c9469e8
fix: move parent project handling out of useProject 2023-04-18 12:21:11 +02:00
kolaente 735524c3d5
fix: rename getParentProjects method to make it clear what it does 2023-04-18 12:21:11 +02:00
kolaente abc0b821d0
fix: return updated project instead of the old one 2023-04-18 12:21:11 +02:00
kolaente e19fd96b9a
fix: remove leftovers of childIds 2023-04-18 12:21:11 +02:00
kolaente d48707354e
fix: properly determine if there are projects 2023-04-18 12:21:10 +02:00
kolaente d14ba333fe
fix: recreate project instead of editing before 2023-04-18 12:21:10 +02:00
kolaente a189ae94e9
fix: remove unnecessary fallback 2023-04-18 12:21:10 +02:00
kolaente 3d6f5379f8
fix: remove getProjectById and replace all usages of it 2023-04-18 12:21:10 +02:00
kolaente 5ce3b8d09d
fix: add default for level 2023-04-18 12:21:10 +02:00
kolaente b08c8f12ef
fix: only bind child projects data down 2023-04-18 12:21:10 +02:00
kolaente 95a1f51d35
fix: move parent project child id mutation to store 2023-04-18 12:21:10 +02:00
kolaente 138fb8cbf7
chore: rename flag 2023-04-18 12:21:10 +02:00
kolaente afb4c675b9
fix: move the collapsable placeholder to the button 2023-04-18 12:21:10 +02:00
kolaente f64445f4c2
fix: bottom margin of project header 2023-04-18 12:21:10 +02:00
kolaente 4f2f5d61a4
fix: use the color bubble as handle if the project has a color 2023-04-18 12:21:10 +02:00
kolaente 17f484b35f
chore: use stores directly 2023-04-18 12:21:09 +02:00
kolaente c212aa8f11
chore: move v-if 2023-04-18 12:21:09 +02:00
kolaente b68dce60a4
chore: set project id from the outside 2023-04-18 12:21:09 +02:00
kolaente a5c4a056ee
chore: replace section with a div 2023-04-18 12:21:09 +02:00
kolaente ff31cb57c6
chore: move all options to component props 2023-04-18 12:21:09 +02:00
kolaente 3b366d154f
chore: add types for emit 2023-04-18 12:21:09 +02:00
kolaente ab918bebb8
feat: add setting for infinite nesting 2023-04-18 12:21:09 +02:00
kolaente 0aba7882b9
fix: use menu tag everywhere 2023-04-18 12:21:09 +02:00
kolaente e094bce77e
fix: collapsing child projects 2023-04-18 12:21:09 +02:00
kolaente 94d8d1b953
feat: don't use child_projects property from api 2023-04-18 12:21:09 +02:00
kolaente 610bdbf9cf
chore: format 2023-04-18 12:21:09 +02:00
kolaente 01036aad8c
fix: don't show child projects when the project is only a favorite 2023-04-18 12:21:08 +02:00
kolaente e6117cb9ce
chore: move more logic to ProjectsNavigationItem.vue 2023-04-18 12:21:08 +02:00
kolaente 35ece7cabc
chore: move ProjectsNavigationWrapper back to navigation.vue 2023-04-18 12:21:08 +02:00
kolaente 1a833b58c2
feat: load all projects earlier than in the navigation and use the loading state of the store 2023-04-18 12:21:08 +02:00
kolaente 6674554f10
chore: move loading styles to variant into the component 2023-04-18 12:21:08 +02:00
kolaente 1d083efdd1
chore: remove old comment 2023-04-18 12:21:08 +02:00
kolaente 1079ce0821
chore: use <menu> instead of <ul> 2023-04-18 12:21:08 +02:00
kolaente 13e9a0141c
fix: indention 2023-04-18 12:21:08 +02:00
kolaente 3245ebb848
chore: improve prop type definition 2023-04-18 12:21:08 +02:00
kolaente 0fc56135b3
chore: only apply padding where needed 2023-04-18 12:21:07 +02:00
kolaente 7892133565
chore: remove old todo 2023-04-18 12:21:07 +02:00
kolaente d973add465
feat: move navigation item to component 2023-04-18 12:21:07 +02:00
kolaente 4284424ed9
chore: use long variable name 2023-04-18 12:21:07 +02:00
kolaente 5e342f72cf
chore: rename alias 2023-04-18 12:21:07 +02:00
kolaente 85cdfaea31
chore: remove unused class 2023-04-18 12:21:07 +02:00
kolaente 38394ff998
fix: remove leftover suspense 2023-04-18 12:21:07 +02:00
kolaente 402063810f
chore: use klona to clone project objet 2023-04-18 12:21:07 +02:00
kolaente 64c2d76f86
fix: passing readonly projects data to navigation 2023-04-18 12:21:07 +02:00
kolaente 7556351717
chore: move loader class 2023-04-18 12:21:07 +02:00
kolaente e98912ce64
chore: export favorite projects from store 2023-04-18 12:21:07 +02:00
kolaente ac5d50d514
chore: remove unnecessary map 2023-04-18 12:21:06 +02:00
kolaente 3e37fa3760
chore: export not archived root projects 2023-04-18 12:21:06 +02:00
kolaente e5be4646af
fix: show favorite on hover 2023-04-18 12:21:06 +02:00
kolaente 909c7b63e2
fix: don't show > for top-level projects 2023-04-18 12:21:06 +02:00
kolaente b2c38aa04b
feat: allow selecting a parent project when editing a project 2023-04-18 12:21:06 +02:00
kolaente 9b578fc27f
feat: allow selecting a parent project when creating a project 2023-04-18 12:21:06 +02:00
kolaente 4fc03e9532
feat: allow selecting a parent project when duplicating a project 2023-04-18 12:21:06 +02:00
kolaente 04bfdde08d
feat: don't handle child projects and instead only save the ids 2023-04-18 12:21:06 +02:00
kolaente 83480cc80b
fix: make computed side-effect free 2023-04-18 12:21:06 +02:00
kolaente d2e106e4e4
chore: refactor get parents project and move to projects store 2023-04-18 12:21:06 +02:00
kolaente 7b3146c2d8
feat: show all parent projects in project search 2023-04-18 12:21:05 +02:00
kolaente e389dec1ab
feat: show all parent projects in task detail view 2023-04-18 12:21:05 +02:00
kolaente f35c6197b5
fix: add await 2023-04-18 12:21:05 +02:00
kolaente b61060090c
fix(filters): load projects after updating a filter 2023-04-18 12:21:05 +02:00
kolaente cfbaef4f8a
fix(filters): load projects after deleting a filter 2023-04-18 12:21:05 +02:00
kolaente a93bc72dcc
fix(filters): load projects after creating a filter 2023-04-18 12:21:05 +02:00
kolaente 5a2ad85f0b
chore(task): move toggleFavorite to store 2023-04-18 12:21:05 +02:00
kolaente fed9a30d05
feat(projects): move hasProjects check to store 2023-04-18 12:21:05 +02:00
kolaente c46560d346
feat: wrap projects navigation in a <Suspense> so that we can use top level await 2023-04-18 12:21:05 +02:00
kolaente 73a88e65cf
chore: use long variable name 2023-04-18 12:21:05 +02:00
kolaente 1ee4641c8b
chore: rename archived message key 2023-04-18 12:21:04 +02:00
kolaente e0e18a84f7
fix: use correct shortcut to open projects overview 2023-04-18 12:21:04 +02:00
kolaente a6f1c8ee2f
fix: simplify sort 2023-04-18 12:21:04 +02:00
kolaente b5bcdff2cf
chore: export projects as array directly from projects store 2023-04-18 12:21:04 +02:00
kolaente 4abc230434
chore: rename prop 2023-04-18 12:21:04 +02:00
kolaente ca3efc6f58
feat(tests): add project tests derived from old namespace tests 2023-04-18 12:21:04 +02:00
kolaente d62eaba689
fix(projects): make sure the project hierarchy is properly updated when moving projects between parents 2023-04-18 12:21:04 +02:00
kolaente 25bd4b4e17
feat(navigation): show favorite projects on top 2023-04-18 12:21:04 +02:00
kolaente bf8569a36a
fix(navigation): make sure updating a project's state works for sub projects as well. 2023-04-18 12:21:04 +02:00
kolaente fe50e75ec1
fix(navigation): make marking a project as favorite work 2023-04-18 12:21:04 +02:00
kolaente 41b0ce7d6b
fix(navigation): make sure the Favorites project shows up when marking or unmarking a task as favorite 2023-04-18 12:21:04 +02:00
kolaente 8f472f9d86
fix(navigation): favorites project 2023-04-18 12:21:03 +02:00
kolaente 89a55be70a
fix(task detail view): make project display show the task's project 2023-04-18 12:21:03 +02:00
kolaente ec81f868ab
fix: make check if projects are available work again 2023-04-18 12:21:03 +02:00
kolaente 074fccfcd7
fix: cleanup unused translation strings 2023-04-18 12:21:03 +02:00
kolaente e6302b7434
fix: make tests work again 2023-04-18 12:21:03 +02:00
kolaente 6e6438a7cf
chore: cleanup namespace leftovers 2023-04-18 12:21:03 +02:00
kolaente c4a7c23d84
fix(navigation): hide left ul border 2023-04-18 12:21:03 +02:00
kolaente 5cc9d6ccf6
feat(navigation): make dragging a project under another project work 2023-04-18 12:21:03 +02:00
kolaente eebbfb9b5b
feat(navigation): allow dragging a project out from its parent project 2023-04-18 12:21:03 +02:00
kolaente 9868d77f6e
feat(navigation): make dragging a project to a parent work 2023-04-18 12:21:02 +02:00
kolaente 4571e489f9
fix(navigation): hover state of other menu items 2023-04-18 12:21:02 +02:00
kolaente 39b8882ed3
feat(navigation): add hiding child projects 2023-04-18 12:21:02 +02:00
kolaente 7bd9af8f27
feat: translate inbox project title 2023-04-18 12:21:02 +02:00
kolaente 61fd310966
chore: format 2023-04-18 12:21:02 +02:00
kolaente dc90883455
feat(navigation): correctly show child projects 2023-04-18 12:21:02 +02:00
kolaente 86c2e20618
fix(navigation): make the styles work again 2023-04-18 12:21:02 +02:00
kolaente 189e7d5bf0
fix(navigation): watcher 2023-04-18 12:21:02 +02:00
kolaente c23105da7b
feat: rebuild main navigation so that it works recursively with projects 2023-04-18 12:21:01 +02:00
kolaente 3f9cba8689
fix: remove namespace routes 2023-04-18 12:21:01 +02:00
kolaente 97fda619b3
fix: remove namespace store reference 2023-04-18 12:21:01 +02:00
kolaente f787e23f37
feat: remove all namespace leftovers 2023-04-18 12:21:01 +02:00
kolaente 3586a57c54
fix: route to create new project 2023-04-18 12:21:01 +02:00
kolaente 845f758bdf
feat: move namespaces list to projects list 2023-04-18 12:21:01 +02:00
84 changed files with 974 additions and 2385 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_PROJECT_INFINITE_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.PROJECT_INFINITE_NESTING_ENABLED\s*=)\s*.+:\1 '${VIKUNJA_PROJECT_INFINITE_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.PROJECT_INFINITE_NESTING_ENABLED = false
</script>
</body>
</html>

View File

@ -0,0 +1,107 @@
<template>
<draggable
v-model="availableProjects"
animation="100"
ghostClass="ghost"
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"
:data-project-id="project.id"
/>
</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?: IProject[],
canEditOrder: boolean,
canCollapse?: boolean,
level?: number,
}>()
const emit = defineEmits<{
(e: 'update:modelValue', projects: IProject[]): void
}>()
const drag = ref(false)
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<IProject[]>([])
watch(
() => props.modelValue,
projects => {
availableProjects.value = projects || []
},
{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.length ? e.newIndex - 1 : e.newIndex
const projectId = parseInt(e.item.dataset.projectId)
const project = projectStore.projects[projectId]
const parentProjectId = e.to.parentNode.dataset.projectId ? parseInt(e.to.parentNode.dataset.projectId) : 0
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,
parentProjectId,
})
emit('update:modelValue', availableProjects.value)
} finally {
projectUpdating.value[project.id] = false
}
}
</script>

View File

@ -0,0 +1,156 @@
<template>
<li
class="list-menu loader-container is-loading-small"
:class="{'is-loading': isLoading}"
>
<div>
<BaseButton
v-if="canCollapse && childProjects?.length > 0"
@click="childProjectsOpen = !childProjectsOpen"
class="collapse-project-button"
>
<icon icon="chevron-down" :class="{ 'project-is-collapsed': !childProjectsOpen }"/>
</BaseButton>
<BaseButton
:to="{ name: 'project.index', params: { projectId: project.id} }"
class="list-menu-link"
:class="{'router-link-exact-active': currentProject?.id === project.id}"
>
<span
v-if="!canCollapse || childProjects?.length === 0"
class="collapse-project-button-placeholder"
></span>
<div class="color-bubble-handle-wrapper">
<ColorBubble
v-if="project.hexColor !== ''"
:color="project.hexColor"
/>
<span
class="icon menu-item-icon handle lines-handle"
:class="{'has-color-bubble': project.hexColor !== ''}"
>
<icon icon="grip-lines"/>
</span>
</div>
<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
v-if="project.id > 0"
class="menu-list-dropdown"
:project="project"
:level="level"
>
<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>
</div>
<ProjectsNavigation
v-if="canNestDeeper && childProjectsOpen && canCollapse"
:model-value="childProjects"
:can-edit-order="true"
:can-collapse="canCollapse"
:level="level + 1"
/>
</li>
</template>
<script setup lang="ts">
import {computed, 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'
import {canNestProjectDeeper} from '@/helpers/canNestProjectDeeper'
const props = withDefaults(defineProps<{
project: IProject,
isLoading?: boolean,
canCollapse?: boolean,
level?: number,
}>(), {
level: 0,
})
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(() => canNestProjectDeeper(props.level))
</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 > div > .favorite {
opacity: 1;
}
.list-menu:hover > div > a > .color-bubble-handle-wrapper > .color-bubble {
opacity: 0;
}
.color-bubble-handle-wrapper {
position: relative;
width: 1rem;
height: 1rem;
display: flex;
align-items: center;
justify-content: flex-start;
margin-right: .25rem;
.color-bubble, .icon {
transition: all $transition;
position: absolute;
width: 12px;
margin: 0 !important;
padding: 0 !important;
}
}
</style>

View File

@ -7,7 +7,7 @@
<MenuButton class="menu-button" />
<div v-if="currentProject.id" class="project-title-wrapper">
<div v-if="currentProject?.id" class="project-title-wrapper">
<h1 class="project-title">{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
</h1>
@ -89,7 +89,7 @@ import { useAuthStore } from '@/stores/auth'
const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject)
const background = computed(() => baseStore.background)
const canWriteCurrentProject = computed(() => baseStore.currentProject.maxRight > Rights.READ)
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxRight > Rights.READ)
const menuActive = computed(() => baseStore.menuActive)
const authStore = useAuthStore()

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

@ -9,9 +9,9 @@
<Logo class="logo" v-if="logoVisible"/>
<h1
:class="{'m-0': !logoVisible}"
:style="{ 'opacity': currentProject.title === '' ? '0': '1' }"
:style="{ 'opacity': currentProject?.title === '' ? '0': '1' }"
class="title">
{{ currentProject.title === '' ? $t('misc.loading') : currentProject.title }}
{{ currentProject?.title === '' ? $t('misc.loading') : currentProject?.title }}
</h1>
<div class="box has-text-left view">
<router-view/>

View File

@ -1,10 +1,10 @@
<template>
<aside :class="{'is-active': menuActive}" class="namespace-container">
<aside :class="{'is-active': baseStore.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,51 @@
{{ $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
v-if="projectStore.isLoading"
variant="small"
/>
<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()
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)
const favoriteProjects = computed(() => projectStore.favoriteProjects)
</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 +102,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;
@ -301,6 +114,7 @@ $vikunja-nav-selected-width: 0.4rem;
transform: translateX(-100%);
overflow-x: auto;
width: $navbar-width;
margin-top: 1rem;
@media screen and (max-width: $tablet) {
top: 0;
@ -314,252 +128,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

@ -1,6 +1,6 @@
<template>
<div
:class="{ 'is-loading': projectService.loading, 'is-archived': currentProject.isArchived}"
:class="{ 'is-loading': projectService.loading, 'is-archived': currentProject?.isArchived}"
class="loader-container"
>
<div class="switch-view-container">
@ -45,8 +45,8 @@
<slot name="header" />
</div>
<CustomTransition name="fade">
<Message variant="warning" v-if="currentProject.isArchived" class="mb-4">
{{ $t('project.archived') }}
<Message variant="warning" v-if="currentProject?.isArchived" class="mb-4">
{{ $t('project.archivedMessage') }}
</Message>
</CustomTransition>
@ -98,7 +98,7 @@ const currentProject = computed(() => {
maxRight: null,
} : baseStore.currentProject
})
useTitle(() => currentProject.value.id ? getProjectTitle(currentProject.value) : '')
useTitle(() => currentProject.value?.id ? getProjectTitle(currentProject.value) : '')
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
// This resulted in loading and setting the project multiple times, even when navigating away from it.
@ -118,7 +118,7 @@ watch(
(
projectIdToLoad === loadedProjectId.value ||
typeof projectIdToLoad === 'undefined' ||
projectIdToLoad === currentProject.value.id
projectIdToLoad === currentProject.value?.id
)
&& typeof currentProject.value !== 'undefined' && currentProject.value.maxRight !== null
) {
@ -130,8 +130,8 @@ watch(
// Set the current project to the one we're about to load so that the title is already shown at the top
loadedProjectId.value = 0
const projectFromStore = projectStore.getProjectById(projectData.id)
if (projectFromStore !== null) {
const projectFromStore = projectStore.projects[projectData.id]
if (projectFromStore) {
baseStore.setBackground(null)
baseStore.setBlurHash(null)
baseStore.handleSetCurrentProject({project: projectFromStore})

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

@ -72,6 +72,13 @@
@update:model-value="setSubscriptionInStore"
type="dropdown"
/>
<dropdown-item
v-if="level < 2"
:to="{ name: 'project.createFromParent', params: { parentProjectId: project.id } }"
icon="layer-group"
>
{{ $t('menu.createProject') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
icon="trash-alt"
@ -96,17 +103,18 @@ 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: {
type: Object as PropType<IProject>,
required: true,
},
level: {
type: Number,
},
})
const projectStore = useProjectStore()
const namespaceStore = useNamespaceStore()
const subscription = ref<ISubscription | null>(null)
watchEffect(() => {
subscription.value = props.project.subscription ?? null
@ -122,6 +130,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,24 +142,15 @@ const foundProjects = computed(() => {
return []
}
const ncache: { [id: ProjectModel['id']]: INamespace } = {}
const history = getHistory()
const allProjects = [
...new Set([
...history.map((l) => projectStore.getProjectById(l.id)),
...history.map((l) => projectStore.projects[l.id]),
...projectStore.searchProject(project),
]),
]
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 => Boolean(l))
})
// 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
})
@ -396,7 +369,7 @@ function searchTasks() {
const r = await taskService.getAll({}, params) as DoAction<ITask>[]
foundTasks.value = r.map((t) => {
t.type = ACTION_TYPE.TASK
const project = projectStore.getProjectById(t.projectId)
const project = projectStore.projects[t.projectId]
if (project !== null) {
t.title = `${t.title} (${project.title})`
}
@ -504,21 +477,10 @@ async function newProject() {
if (currentProject.value === null) {
return
}
const newProject = await projectStore.createProject(new ProjectModel({
await projectStore.createProject(new ProjectModel({
title: query.value,
namespaceId: currentProject.value.namespaceId,
}))
success({ message: t('project.create.createdSuccess')})
await router.push({
name: 'project.index',
params: { projectId: newProject.id },
})
}
async function newNamespace() {
const newNamespace = new NamespaceModel({ title: query.value })
await namespaceStore.createNamespace(newNamespace)
success({ message: t('namespace.create.success') })
}
async function 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.getAncestors(option).length > 1">
{{ projectStore.getAncestors(option).filter(p => p.id !== option.id).map(p => getProjectTitle(p)).join(' &gt; ') }} &gt;
</span>
{{ getProjectTitle(option) }}
</template>
</Multiselect>
</template>
@ -20,13 +22,11 @@
<script lang="ts" setup>
import {reactive, ref, watch} from 'vue'
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'
@ -40,8 +40,6 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue'])
const {t} = useI18n({useScope: 'global'})
const project: IProject = reactive(new ProjectModel())
watch(
@ -54,7 +52,6 @@ watch(
)
const projectStore = useProjectStore()
const namespaceStore = useNamespaceStore()
const foundProjects = ref<IProject[]>([])
function findProjects(query: string) {
if (query === '') {
@ -70,17 +67,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,26 +219,15 @@ 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.projects[task.ProjectId]
return {
...task,
differentNamespace:
(taskNamespace !== null &&
taskNamespace.id !== namespace.value.id &&
taskNamespace?.title) || null,
differentProject:
(project !== null &&
(project &&
task.projectId !== props.projectId &&
project?.title) || null,
}

View File

@ -7,19 +7,19 @@
/>
<ColorBubble
v-if="showProjectColor && projectColor !== '' && currentProject.id !== task.projectId"
v-if="showProjectColor && projectColor !== '' && currentProject?.id !== task.projectId"
:color="projectColor"
class="mr-1"
/>
<router-link
:to="taskDetailRoute"
:class="{ 'done': task.done, 'show-project': showProject && project !== null}"
:class="{ 'done': task.done, 'show-project': showProject && project}"
class="tasktext"
>
<span>
<router-link
v-if="showProject && project !== null"
v-if="showProject && typeof project !== 'undefined'"
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
class="task-project"
:class="{'mr-2': task.hexColor !== ''}"
@ -104,7 +104,7 @@
</progress>
<router-link
v-if="!showProject && currentProject.id !== task.projectId && project !== null"
v-if="!showProject && currentProject?.id !== task.projectId && project"
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
class="task-project"
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
@ -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,10 +208,9 @@ 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 : '')
const project = computed(() => projectStore.projects[task.value.projectId])
const projectColor = computed(() => project.value ? project.value?.hexColor : '')
const currentProject = computed(() => {
return typeof baseStore.currentProject === 'undefined' ? {
@ -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

@ -0,0 +1,7 @@
export function canNestProjectDeeper(level: number) {
if (level < 2) {
return true
}
return level >= 2 && window.PROJECT_INFINITE_NESTING_ENABLED
}

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,14 @@
import {i18n} from '@/i18n'
import type {IProject} from '@/modelTypes/IProject'
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": {
@ -927,7 +861,8 @@
"unarchive": "Un-Archive",
"setBackground": "Set background",
"share": "Share",
"newProject": "New project"
"newProject": "New project",
"createProject": "Create project"
},
"apiConfig": {
"url": "Vikunja URL",
@ -946,7 +881,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 +892,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 +953,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;
PROJECT_INFINITE_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,7 @@ export interface IProject extends IAbstract {
subscription: ISubscription
position: number
backgroundBlurHash: string
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: IProject['id']
}

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,7 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
subscription: ISubscription = null
position = 0
backgroundBlurHash = ''
parentProjectId = 0
created: Date = null
updated: Date = null
@ -46,7 +45,7 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
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

@ -2,13 +2,12 @@ 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
parentProjectId = 0
constructor(data : Partial<IProjectDuplicate>) {
super()

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,13 +225,27 @@ const router = createRouter({
},
},
{
path: '/projects/new/:namespaceId/',
path: '/projects',
name: 'projects.index',
component: ListProjects,
},
{
path: '/projects/new',
name: 'project.create',
component: NewProjectComponent,
meta: {
showAsModal: true,
},
},
{
path: '/projects/:parentProjectId/new',
name: 'project.createFromParent',
component: NewProjectComponent,
props: route => ({ parentProjectId: Number(route.params.parentProjectId as string) }),
meta: {
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/edit',
name: 'project.settings.edit',
@ -412,7 +369,7 @@ const router = createRouter({
saveProjectView(to.params.projectId, to.name)
// Properly set the page title when a task popup is closed
const projectStore = useProjectStore()
const projectFromStore = projectStore.getProjectById(Number(to.params.projectId))
const projectFromStore = projectStore.projects[Number(to.params.projectId)]
if(projectFromStore) {
setTitle(projectFromStore.title)
}

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'
@ -40,7 +40,7 @@ export function getSavedFilterIdFromProjectId(projectId: IProject['id']) {
}
export function isSavedFilter(project: IProject) {
return getSavedFilterIdFromProjectId(project.id) > 0
return getSavedFilterIdFromProjectId(project?.id) > 0
}
export default class SavedFilterService extends AbstractService<ISavedFilter> {
@ -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

@ -81,7 +81,7 @@ export const useBaseStore = defineStore('base', () => {
async function handleSetCurrentProject(
{project, forceUpdate = false}: {project: IProject | null, forceUpdate?: boolean},
) {
if (project === null) {
if (project === null || typeof project === 'undefined') {
setCurrentProject({})
setBackground('')
setBlurHash('')

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,14 @@
import {watch, reactive, shallowReactive, unref, toRefs, readonly, ref, computed} from 'vue'
import {watch, reactive, shallowReactive, unref, readonly, ref, computed, watchEffect} from 'vue'
import {acceptHMRUpdate, defineStore} from 'pinia'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import ProjectService from '@/services/project'
import ProjectDuplicateService from '@/services/projectDuplicateService'
import ProjectDuplicateModel from '@/models/projectDuplicateModel'
import {setModuleLoading} from '@/stores/helper'
import {removeProjectFromHistory} from '@/modules/projectHistory'
import {createNewIndexer} from '@/indexes'
import {useNamespaceStore} from './namespaces'
import type {IProject} from '@/modelTypes/IProject'
@ -16,9 +18,7 @@ import ProjectModel from '@/models/project'
import {success} from '@/message'
import {useBaseStore} from '@/stores/base'
const {add, remove, search, update} = createNewIndexer('projects', ['title', 'description'])
const FavoriteProjectsNamespace = -2
const {remove, search, update} = createNewIndexer('projects', ['title', 'description'])
export interface ProjectState {
[id: IProject['id']]: IProject
@ -26,18 +26,26 @@ export interface ProjectState {
export const useProjectStore = defineStore('project', () => {
const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore()
const router = useRouter()
const isLoading = ref(false)
// The projects are stored as an object which has the project ids as keys.
const projects = ref<ProjectState>({})
const projectsArray = computed(() => Object.values(projects.value)
.sort((a, b) => a.position - b.position))
const notArchivedRootProjects = computed(() => projectsArray.value
.filter(p => p.parentProjectId === 0 && !p.isArchived))
const favoriteProjects = computed(() => projectsArray.value
.filter(p => !p.isArchived && p.isFavorite))
const hasProjects = computed(() => projectsArray.value.length > 0)
const getProjectById = computed(() => {
return (id: IProject['id']) => typeof projects.value[id] !== 'undefined' ? projects.value[id] : null
const getChildProjects = computed(() => {
return (id: IProject['id']) => projectsArray.value.filter(p => p.parentProjectId === id)
})
watchEffect(() => baseStore.setCurrentProject(projects.value[baseStore.currentProject?.id] || null))
const findProjectByExactname = computed(() => {
return (name: string) => {
const project = Object.values(projects.value).find(l => {
@ -53,7 +61,7 @@ export const useProjectStore = defineStore('project', () => {
?.filter(value => value > 0)
.map(id => projects.value[id])
.filter(project => project.isArchived === includeArchived)
|| []
|| []
}
})
@ -64,17 +72,10 @@ export const useProjectStore = defineStore('project', () => {
function setProject(project: IProject) {
projects.value[project.id] = project
update(project)
if (baseStore.currentProject?.id === project.id) {
baseStore.setCurrentProject(project)
}
}
function setProjects(newProjects: IProject[]) {
newProjects.forEach(l => {
projects.value[l.id] = l
add(l)
})
newProjects.forEach(p => setProject(p))
}
function removeProjectById(project: IProject) {
@ -100,9 +101,11 @@ export const useProjectStore = defineStore('project', () => {
try {
const createdProject = await projectService.create(project)
createdProject.namespaceId = project.namespaceId
namespaceStore.addProjectToNamespace(createdProject)
setProject(createdProject)
router.push({
name: 'project.index',
params: { projectId: createdProject.id },
})
return createdProject
} finally {
cancel()
@ -112,26 +115,14 @@ export const useProjectStore = defineStore('project', () => {
async function updateProject(project: IProject) {
const cancel = setModuleLoading(setIsLoading)
const projectService = new ProjectService()
try {
await projectService.update(project)
const updatedProject = 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 updatedProject
} catch (e) {
// Reset the project state to the initial one to avoid confusion for the user
setProject({
@ -151,7 +142,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 +149,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 = {}
setProjects(loadedProjects)
return loadedProjects
} finally {
cancel()
}
}
function getAncestors(project: IProject): IProject[] {
if (!project?.parentProjectId) {
return [project]
}
const parentProject = projects.value[project.parentProjectId]
return [
...getAncestors(parentProject),
project,
]
}
return {
isLoading: readonly(isLoading),
projects: readonly(projects),
projectsArray: readonly(projectsArray),
notArchivedRootProjects: readonly(notArchivedRootProjects),
favoriteProjects: readonly(favoriteProjects),
hasProjects: readonly(hasProjects),
getProjectById,
getChildProjects,
findProjectByExactname,
searchProject,
@ -171,17 +192,24 @@ export const useProjectStore = defineStore('project', () => {
setProjects,
removeProjectById,
toggleProjectFavorite,
loadProjects,
createProject,
updateProject,
deleteProject,
getAncestors,
}
})
export function useProject(projectId: MaybeRef<IProject['id']>) {
const projectService = shallowReactive(new ProjectService())
const {loading: isLoading} = toRefs(projectService)
const projectDuplicateService = shallowReactive(new ProjectDuplicateService())
const isLoading = computed(() => projectService.loading || projectDuplicateService.loading)
const project: IProject = reactive(new ProjectModel())
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
const projectStore = useProjectStore()
watch(
() => unref(projectId),
@ -192,20 +220,34 @@ export function useProject(projectId: MaybeRef<IProject['id']>) {
{immediate: true},
)
const projectStore = useProjectStore()
async function save() {
await projectStore.updateProject(project)
const updatedProject = await projectStore.updateProject(project)
Object.assign(project, updatedProject)
success({message: t('project.edit.success')})
}
async function duplicateProject(parentProjectId: IProject['id']) {
const projectDuplicate = new ProjectDuplicateModel({
projectId: unref(projectId),
parentProjectId,
})
const duplicate = await projectDuplicateService.create(projectDuplicate)
projectStore.setProject(duplicate.project)
success({message: t('project.duplicate.success')})
router.push({name: 'project.index', params: {projectId: duplicate.project.id}})
}
return {
isLoading: readonly(isLoading),
project,
save,
duplicateProject,
}
}
// 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,
@ -453,6 +464,7 @@ export const useTaskStore = defineStore('task', () => {
setCoverImage,
findProjectId,
ensureLabelsExist,
toggleFavorite,
}
})

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,139 @@
// 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;
opacity: 1;
margin: 0 .5rem 0 .25rem;
}
}
.menu-list {
list-style: none;
margin: 0;
padding: 0;
&.other-menu-items li,
li > div {
height: 44px;
display: flex;
align-items: center;
&:hover {
background: var(--white);
}
}
li > div {
.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 > div {
.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') }}
@ -43,7 +32,7 @@
<ProjectCardGrid :projects="projectHistory" v-cy="'projectCardGrid'" />
</div>
<ShowTasks
v-if="hasProjects"
v-if="projectStore.hasProjects"
class="show-tasks"
:key="showTasksKey"
/>
@ -66,17 +55,14 @@ 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'
const salutation = useDaytimeSalutation()
const baseStore = useBaseStore()
const authStore = useAuthStore()
const configStore = useConfigStore()
const namespaceStore = useNamespaceStore()
const projectStore = useProjectStore()
const taskStore = useTaskStore()
@ -87,14 +73,12 @@ const projectHistory = computed(() => {
}
return getHistory()
.map(l => projectStore.getProjectById(l.id))
.filter((l): l is IProject => l !== null)
.map(l => projectStore.projects[l.id])
.filter(l => Boolean(l))
})
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 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,95 @@
<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"
/>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import ProjectCardGrid from '@/components/project/partials/ProjectCardGrid.vue'
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 showArchived.value
? projectStore.projectsArray
: projectStore.projectsArray.filter(({isArchived}) => !isArchived)
})
</script>
<style lang="scss" scoped>
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 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

@ -1,5 +1,9 @@
<template>
<create-edit :title="$t('project.create.header')" @create="createNewProject()" :primary-disabled="project.title === ''">
<create-edit
:title="$t('project.create.header')"
@create="createNewProject()"
:primary-disabled="project.title === ''"
>
<div class="field">
<label class="label" for="projectTitle">{{ $t('project.title') }}</label>
<div
@ -22,19 +26,24 @@
<p class="help is-danger" v-if="showError && project.title === ''">
{{ $t('project.create.addTitleRequired') }}
</p>
<div class="field" v-if="projectStore.hasProjects">
<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">
<color-picker v-model="project.hexColor" />
<color-picker v-model="project.hexColor"/>
</div>
</div>
</create-edit>
</template>
<script setup lang="ts">
import {ref, reactive, shallowReactive} from 'vue'
import {ref, reactive, shallowReactive, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRouter, useRoute} from 'vue-router'
import ProjectService from '@/services/project'
import ProjectModel from '@/models/project'
@ -44,10 +53,10 @@ 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()
const route = useRoute()
useTitle(() => t('project.create.header'))
@ -55,6 +64,17 @@ const showError = ref(false)
const project = reactive(new ProjectModel())
const projectService = shallowReactive(new ProjectService())
const projectStore = useProjectStore()
const parentProject = ref<IProject | null>(null)
const props = defineProps<{
parentProjectId?: number,
}>()
watch(
() => props.parentProjectId,
() => parentProject.value = projectStore.projects[props.parentProjectId],
{immediate: true},
)
async function createNewProject() {
if (project.title === '') {
@ -63,12 +83,11 @@ async function createNewProject() {
}
showError.value = false
project.namespaceId = Number(route.params.namespaceId as string)
const newProject = await projectStore.createProject(project)
await router.push({
name: 'project.index',
params: { projectId: newProject.id },
})
success({message: t('project.create.createdSuccess') })
if (parentProject.value) {
project.parentProjectId = parentProject.value.id
}
await projectStore.createProject(project)
success({message: t('project.create.createdSuccess')})
}
</script>

View File

@ -75,7 +75,7 @@ const GanttChart = createAsyncComponent(() => import('@/components/tasks/GanttCh
const props = defineProps<{route: RouteLocationNormalized}>()
const baseStore = useBaseStore()
const canWrite = computed(() => baseStore.currentProject.maxRight > RIGHTS.READ)
const canWrite = computed(() => baseStore.currentProject?.maxRight > RIGHTS.READ)
const {route} = toRefs(props)
const {

View File

@ -29,7 +29,7 @@ const props = defineProps({
})
const projectStore = useProjectStore()
const project = computed(() => projectStore.getProjectById(props.projectId))
const project = computed(() => projectStore.projects[props.projectId])
const htmlDescription = computed(() => {
const description = project.value?.description || ''
if (description === '') {

View File

@ -330,7 +330,7 @@ const bucketDraggableComponentData = computed(() => ({
],
}))
const canWrite = computed(() => baseStore.currentProject.maxRight > Rights.READ)
const canWrite = computed(() => baseStore.currentProject?.maxRight > Rights.READ)
const project = computed(() => baseStore.currentProject)
const buckets = computed(() => kanbanStore.buckets)

View File

@ -31,7 +31,7 @@ const projectStore = useProjectStore()
const router = useRouter()
const route = useRoute()
const project = computed(() => projectStore.getProjectById(route.params.projectId))
const project = computed(() => projectStore.projects[route.params.projectId])
useTitle(() => t('project.archive.title', {project: project.value.title}))
async function archiveProject() {

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

@ -43,7 +43,7 @@ const router = useRouter()
const totalTasks = ref<number | null>(null)
const project = computed(() => projectStore.getProjectById(route.params.projectId))
const project = computed(() => projectStore.projects[route.params.projectId])
watchEffect(
() => {

View File

@ -3,73 +3,46 @@
:title="$t('project.duplicate.title')"
primary-icon="paste"
:primary-label="$t('project.duplicate.label')"
@primary="duplicateProject"
:loading="projectDuplicateService.loading"
@primary="duplicate"
:loading="isLoading"
>
<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 {useRoute, useRouter} from 'vue-router'
import {ref, watch} from 'vue'
import {useRoute} 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 ProjectDuplicateModel from '@/models/projectDuplicateModel'
import type {INamespace} from '@/modelTypes/INamespace'
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
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 {useProject, useProjectStore} from '@/stores/projects'
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 {project, isLoading, duplicateProject} = useProject(route.params.projectId)
async function duplicateProject() {
const projectDuplicate = new ProjectDuplicateModel({
// FIXME: should be parameter
projectId: route.params.projectId,
namespaceId: selectedNamespace.value?.id,
})
const parentProject = ref<IProject | null>(null)
watch(
() => project.parentProjectId,
parentProjectId => {
parentProject.value = projectStore.projects[parentProjectId]
},
{immediate: true},
)
const duplicate = await projectDuplicateService.create(projectDuplicate)
namespaceStore.addProjectToNamespace(duplicate.project)
projectStore.setProject(duplicate.project)
async function duplicate() {
await duplicateProject(parentProject.value.id)
success({message: t('project.duplicate.success')})
router.push({name: 'project.index', params: {projectId: duplicate.project.id}})
}
</script>

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">
@ -66,21 +72,23 @@
</template>
<script lang="ts">
export default { name: 'project-setting-edit' }
export default {name: 'project-setting-edit'}
</script>
<script setup lang="ts">
import type {PropType} from 'vue'
import {watch, ref, 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'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import {useProject} from '@/stores/projects'
import {useTitle} from '@/composables/useTitle'
@ -93,14 +101,27 @@ const props = defineProps({
})
const router = useRouter()
const projectStore = useProjectStore()
const {t} = useI18n({useScope: 'global'})
const {project, save: saveProject, isLoading} = useProject(props.projectId)
const parentProject = ref<IProject | null>(null)
watch(
() => project.parentProjectId,
projectId => {
if (project.parentProjectId) {
parentProject.value = projectStore.projects[project.parentProjectId]
}
},
{immediate: true},
)
useTitle(() => project?.title ? t('project.edit.title', {project: project.title}) : '')
async function save() {
project.parentProjectId = parentProject.value.id ?? project.parentProjectId
await saveProject()
await useBaseStore().handleSetCurrentProject({project})
router.back()

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.getAncestors(project)" :key="p.id">
<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(() => projectStore.projects[task.projectId])
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) {

View File

@ -245,7 +245,7 @@ watch(
const projectStore = useProjectStore()
const defaultProject = computed({
get: () => projectStore.getProjectById(settings.value.defaultProjectId) || undefined,
get: () => projectStore.projects[settings.value.defaultProjectId],
set(l) {
settings.value.defaultProjectId = l ? l.id : DEFAULT_PROJECT_ID
},