feat: remove namespaces, make projects infinitely nestable (#3323)
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/3323
This commit is contained in:
commit
ac1d374191
@ -54,6 +54,7 @@ ENV VIKUNJA_LOG_FORMAT main
|
|||||||
ENV VIKUNJA_API_URL /api/v1
|
ENV VIKUNJA_API_URL /api/v1
|
||||||
ENV VIKUNJA_SENTRY_ENABLED false
|
ENV VIKUNJA_SENTRY_ENABLED false
|
||||||
ENV VIKUNJA_SENTRY_DSN https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480
|
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/injector.sh /docker-entrypoint.d/50-injector.sh
|
||||||
COPY docker/ipv6-disable.sh /docker-entrypoint.d/60-ipv6-disable.sh
|
COPY docker/ipv6-disable.sh /docker-entrypoint.d/60-ipv6-disable.sh
|
||||||
|
@ -24,4 +24,5 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
viewportWidth: 1600,
|
viewportWidth: 1600,
|
||||||
viewportHeight: 900,
|
viewportHeight: 900,
|
||||||
|
experimentalMemoryManagement: true,
|
||||||
})
|
})
|
||||||
|
@ -2,7 +2,6 @@ import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|||||||
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
import {TaskFactory} from '../../factories/task'
|
||||||
import {ProjectFactory} from '../../factories/project'
|
import {ProjectFactory} from '../../factories/project'
|
||||||
import {NamespaceFactory} from '../../factories/namespace'
|
|
||||||
import {UserProjectFactory} from '../../factories/users_project'
|
import {UserProjectFactory} from '../../factories/users_project'
|
||||||
import {BucketFactory} from '../../factories/bucket'
|
import {BucketFactory} from '../../factories/bucket'
|
||||||
|
|
||||||
@ -10,7 +9,6 @@ describe('Editor', () => {
|
|||||||
createFakeUserAndLogin()
|
createFakeUserAndLogin()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
NamespaceFactory.create(1)
|
|
||||||
ProjectFactory.create(1)
|
ProjectFactory.create(1)
|
||||||
BucketFactory.create(1)
|
BucketFactory.create(1)
|
||||||
TaskFactory.truncate()
|
TaskFactory.truncate()
|
||||||
|
@ -8,20 +8,20 @@ describe('The Menu', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('Is visible by default on desktop', () => {
|
it('Is visible by default on desktop', () => {
|
||||||
cy.get('.namespace-container')
|
cy.get('.menu-container')
|
||||||
.should('have.class', 'is-active')
|
.should('have.class', 'is-active')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Can be hidden on desktop', () => {
|
it('Can be hidden on desktop', () => {
|
||||||
cy.get('button.menu-show-button:visible')
|
cy.get('button.menu-show-button:visible')
|
||||||
.click()
|
.click()
|
||||||
cy.get('.namespace-container')
|
cy.get('.menu-container')
|
||||||
.should('not.have.class', 'is-active')
|
.should('not.have.class', 'is-active')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Is hidden by default on mobile', () => {
|
it('Is hidden by default on mobile', () => {
|
||||||
cy.viewport('iphone-8')
|
cy.viewport('iphone-8')
|
||||||
cy.get('.namespace-container')
|
cy.get('.menu-container')
|
||||||
.should('not.have.class', 'is-active')
|
.should('not.have.class', 'is-active')
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -29,7 +29,7 @@ describe('The Menu', () => {
|
|||||||
cy.viewport('iphone-8')
|
cy.viewport('iphone-8')
|
||||||
cy.get('button.menu-show-button:visible')
|
cy.get('button.menu-show-button:visible')
|
||||||
.click()
|
.click()
|
||||||
cy.get('.namespace-container')
|
cy.get('.menu-container')
|
||||||
.should('have.class', 'is-active')
|
.should('have.class', 'is-active')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
@ -1,9 +1,7 @@
|
|||||||
import {ProjectFactory} from '../../factories/project'
|
import {ProjectFactory} from '../../factories/project'
|
||||||
import {NamespaceFactory} from '../../factories/namespace'
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
|
||||||
export function createProjects() {
|
export function createProjects() {
|
||||||
NamespaceFactory.create(1)
|
|
||||||
const projects = ProjectFactory.create(1, {
|
const projects = ProjectFactory.create(1, {
|
||||||
title: 'First Project'
|
title: 'First Project'
|
||||||
})
|
})
|
||||||
|
@ -8,37 +8,30 @@ describe('Project History', () => {
|
|||||||
prepareProjects()
|
prepareProjects()
|
||||||
|
|
||||||
it('should show a project history on the home page', () => {
|
it('should show a project history on the home page', () => {
|
||||||
cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces')
|
cy.intercept(Cypress.env('API_URL') + '/projects*').as('loadProjectArray')
|
||||||
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
|
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
|
||||||
|
|
||||||
const projects = ProjectFactory.create(6)
|
const projects = ProjectFactory.create(6)
|
||||||
|
|
||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
cy.wait('@loadNamespaces')
|
cy.wait('@loadProjectArray')
|
||||||
cy.get('body')
|
cy.get('body')
|
||||||
.should('not.contain', 'Last viewed')
|
.should('not.contain', 'Last viewed')
|
||||||
|
|
||||||
cy.visit(`/projects/${projects[0].id}`)
|
cy.visit(`/projects/${projects[0].id}`)
|
||||||
cy.wait('@loadNamespaces')
|
|
||||||
cy.wait('@loadProject')
|
cy.wait('@loadProject')
|
||||||
cy.visit(`/projects/${projects[1].id}`)
|
cy.visit(`/projects/${projects[1].id}`)
|
||||||
cy.wait('@loadNamespaces')
|
|
||||||
cy.wait('@loadProject')
|
cy.wait('@loadProject')
|
||||||
cy.visit(`/projects/${projects[2].id}`)
|
cy.visit(`/projects/${projects[2].id}`)
|
||||||
cy.wait('@loadNamespaces')
|
|
||||||
cy.wait('@loadProject')
|
cy.wait('@loadProject')
|
||||||
cy.visit(`/projects/${projects[3].id}`)
|
cy.visit(`/projects/${projects[3].id}`)
|
||||||
cy.wait('@loadNamespaces')
|
|
||||||
cy.wait('@loadProject')
|
cy.wait('@loadProject')
|
||||||
cy.visit(`/projects/${projects[4].id}`)
|
cy.visit(`/projects/${projects[4].id}`)
|
||||||
cy.wait('@loadNamespaces')
|
|
||||||
cy.wait('@loadProject')
|
cy.wait('@loadProject')
|
||||||
cy.visit(`/projects/${projects[5].id}`)
|
cy.visit(`/projects/${projects[5].id}`)
|
||||||
cy.wait('@loadNamespaces')
|
|
||||||
cy.wait('@loadProject')
|
cy.wait('@loadProject')
|
||||||
|
|
||||||
// cy.visit('/')
|
// cy.visit('/')
|
||||||
// cy.wait('@loadNamespaces')
|
|
||||||
// Not using cy.visit here to work around the redirect issue fixed in #1337
|
// Not using cy.visit here to work around the redirect issue fixed in #1337
|
||||||
cy.get('nav.menu.top-menu a')
|
cy.get('nav.menu.top-menu a')
|
||||||
.contains('Overview')
|
.contains('Overview')
|
||||||
|
@ -58,7 +58,6 @@ describe('Project View Project', () => {
|
|||||||
})
|
})
|
||||||
const projects = ProjectFactory.create(2, {
|
const projects = ProjectFactory.create(2, {
|
||||||
owner_id: '{increment}',
|
owner_id: '{increment}',
|
||||||
namespace_id: '{increment}',
|
|
||||||
})
|
})
|
||||||
cy.visit(`/projects/${projects[1].id}/`)
|
cy.visit(`/projects/${projects[1].id}/`)
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||||
|
|
||||||
import {TaskFactory} from '../../factories/task'
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
import {ProjectFactory} from '../../factories/project'
|
||||||
import {prepareProjects} from './prepareProjects'
|
import {prepareProjects} from './prepareProjects'
|
||||||
|
|
||||||
describe('Projects', () => {
|
describe('Projects', () => {
|
||||||
@ -10,23 +11,20 @@ describe('Projects', () => {
|
|||||||
prepareProjects((newProjects) => (projects = newProjects))
|
prepareProjects((newProjects) => (projects = newProjects))
|
||||||
|
|
||||||
it('Should create a new project', () => {
|
it('Should create a new project', () => {
|
||||||
cy.visit('/')
|
cy.visit('/projects')
|
||||||
cy.get('.namespace-title .dropdown-trigger')
|
cy.get('.project-header [data-cy=new-project]')
|
||||||
.click()
|
|
||||||
cy.get('.namespace-title .dropdown .dropdown-item')
|
|
||||||
.contains('New project')
|
|
||||||
.click()
|
.click()
|
||||||
cy.url()
|
cy.url()
|
||||||
.should('contain', '/projects/new/1')
|
.should('contain', '/projects/new')
|
||||||
cy.get('.card-header-title')
|
cy.get('.card-header-title')
|
||||||
.contains('New project')
|
.contains('New project')
|
||||||
cy.get('input.input')
|
cy.get('input[name=projectTitle]')
|
||||||
.type('New Project')
|
.type('New Project')
|
||||||
cy.get('.button')
|
cy.get('.button')
|
||||||
.contains('Create')
|
.contains('Create')
|
||||||
.click()
|
.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')
|
.should('contain', 'Success')
|
||||||
cy.url()
|
cy.url()
|
||||||
.should('contain', '/projects/')
|
.should('contain', '/projects/')
|
||||||
@ -56,9 +54,9 @@ describe('Projects', () => {
|
|||||||
cy.get('.project-title')
|
cy.get('.project-title')
|
||||||
.should('contain', 'First Project')
|
.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()
|
.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')
|
.contains('Edit')
|
||||||
.click()
|
.click()
|
||||||
cy.get('#title')
|
cy.get('#title')
|
||||||
@ -72,21 +70,21 @@ describe('Projects', () => {
|
|||||||
cy.get('.project-title')
|
cy.get('.project-title')
|
||||||
.should('contain', newProjectName)
|
.should('contain', newProjectName)
|
||||||
.should('not.contain', projects[0].title)
|
.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('contain', newProjectName)
|
||||||
.should('not.contain', projects[0].title)
|
.should('not.contain', projects[0].title)
|
||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
cy.get('.card-content')
|
cy.get('.project-grid')
|
||||||
.should('contain', newProjectName)
|
.should('contain', newProjectName)
|
||||||
.should('not.contain', projects[0].title)
|
.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.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()
|
.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')
|
.contains('Delete')
|
||||||
.click()
|
.click()
|
||||||
cy.url()
|
cy.url()
|
||||||
@ -97,15 +95,15 @@ describe('Projects', () => {
|
|||||||
|
|
||||||
cy.get('.global-notification')
|
cy.get('.global-notification')
|
||||||
.should('contain', 'Success')
|
.should('contain', 'Success')
|
||||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
|
cy.get('.menu-container .menu-list')
|
||||||
.should('not.contain', projects[0].title)
|
.should('not.contain', projects[0].title)
|
||||||
cy.location('pathname')
|
cy.location('pathname')
|
||||||
.should('equal', '/')
|
.should('equal', '/')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should archive a project', () => {
|
it('Should archive a project', () => {
|
||||||
cy.visit(`/projects/${projects[0].id}`)
|
cy.visit(`/projects/${projects[0].id}`)
|
||||||
|
|
||||||
cy.get('.project-title-dropdown')
|
cy.get('.project-title-dropdown')
|
||||||
.click()
|
.click()
|
||||||
cy.get('.project-title-dropdown .dropdown-menu .dropdown-item')
|
cy.get('.project-title-dropdown .dropdown-menu .dropdown-item')
|
||||||
@ -115,10 +113,59 @@ describe('Projects', () => {
|
|||||||
.should('contain.text', 'Archive this project')
|
.should('contain.text', 'Archive this project')
|
||||||
cy.get('.modal-content [data-cy=modalPrimary]')
|
cy.get('.modal-content [data-cy=modalPrimary]')
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
|
cy.get('.menu-container .menu-list')
|
||||||
.should('not.contain', projects[0].title)
|
.should('not.contain', projects[0].title)
|
||||||
cy.get('main.app-content')
|
cy.get('main.app-content')
|
||||||
.should('contain.text', 'This project is archived. It is not possible to create new or edit tasks for it.')
|
.should('contain.text', 'This project is archived. It is not possible to create new or edit tasks for it.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 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 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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -3,12 +3,10 @@ import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|||||||
import {ProjectFactory} from '../../factories/project'
|
import {ProjectFactory} from '../../factories/project'
|
||||||
import {seed} from '../../support/seed'
|
import {seed} from '../../support/seed'
|
||||||
import {TaskFactory} from '../../factories/task'
|
import {TaskFactory} from '../../factories/task'
|
||||||
import {NamespaceFactory} from '../../factories/namespace'
|
|
||||||
import {BucketFactory} from '../../factories/bucket'
|
import {BucketFactory} from '../../factories/bucket'
|
||||||
import {updateUserSettings} from '../../support/updateUserSettings'
|
import {updateUserSettings} from '../../support/updateUserSettings'
|
||||||
|
|
||||||
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
||||||
NamespaceFactory.create(1)
|
|
||||||
const project = ProjectFactory.create()[0]
|
const project = ProjectFactory.create()[0]
|
||||||
BucketFactory.create(1, {
|
BucketFactory.create(1, {
|
||||||
project_id: project.id,
|
project_id: project.id,
|
||||||
@ -137,8 +135,7 @@ describe('Home Page Task Overview', () => {
|
|||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
|
|
||||||
cy.get('.home.app-content .content')
|
cy.get('.home.app-content .content')
|
||||||
.should('contain.text', 'You can create a new project for your new tasks:')
|
.should('contain.text', 'Import your projects and tasks from other services into Vikunja:')
|
||||||
.should('contain.text', 'Or import your projects and tasks from other services into Vikunja:')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should not show the cta buttons for new project when there are tasks', () => {
|
it('Should not show the cta buttons for new project when there are tasks', () => {
|
||||||
|
@ -4,7 +4,6 @@ import {TaskFactory} from '../../factories/task'
|
|||||||
import {ProjectFactory} from '../../factories/project'
|
import {ProjectFactory} from '../../factories/project'
|
||||||
import {TaskCommentFactory} from '../../factories/task_comment'
|
import {TaskCommentFactory} from '../../factories/task_comment'
|
||||||
import {UserFactory} from '../../factories/user'
|
import {UserFactory} from '../../factories/user'
|
||||||
import {NamespaceFactory} from '../../factories/namespace'
|
|
||||||
import {UserProjectFactory} from '../../factories/users_project'
|
import {UserProjectFactory} from '../../factories/users_project'
|
||||||
import {TaskAssigneeFactory} from '../../factories/task_assignee'
|
import {TaskAssigneeFactory} from '../../factories/task_assignee'
|
||||||
import {LabelFactory} from '../../factories/labels'
|
import {LabelFactory} from '../../factories/labels'
|
||||||
@ -47,13 +46,11 @@ function uploadAttachmentAndVerify(taskId: number) {
|
|||||||
describe('Task', () => {
|
describe('Task', () => {
|
||||||
createFakeUserAndLogin()
|
createFakeUserAndLogin()
|
||||||
|
|
||||||
let namespaces
|
|
||||||
let projects
|
let projects
|
||||||
let buckets
|
let buckets
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// UserFactory.create(1)
|
// UserFactory.create(1)
|
||||||
namespaces = NamespaceFactory.create(1)
|
|
||||||
projects = ProjectFactory.create(1)
|
projects = ProjectFactory.create(1)
|
||||||
buckets = BucketFactory.create(1, {
|
buckets = BucketFactory.create(1, {
|
||||||
project_id: projects[0].id,
|
project_id: projects[0].id,
|
||||||
@ -110,7 +107,7 @@ describe('Task', () => {
|
|||||||
cy.get('.tasks .task .favorite')
|
cy.get('.tasks .task .favorite')
|
||||||
.first()
|
.first()
|
||||||
.click()
|
.click()
|
||||||
cy.get('.menu.namespaces-lists')
|
cy.get('.menu-container')
|
||||||
.should('contain', 'Favorites')
|
.should('contain', 'Favorites')
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -133,7 +130,6 @@ describe('Task', () => {
|
|||||||
cy.get('.task-view h1.title.task-id')
|
cy.get('.task-view h1.title.task-id')
|
||||||
.should('contain', '#1')
|
.should('contain', '#1')
|
||||||
cy.get('.task-view h6.subtitle')
|
cy.get('.task-view h6.subtitle')
|
||||||
.should('contain', namespaces[0].title)
|
|
||||||
.should('contain', projects[0].title)
|
.should('contain', projects[0].title)
|
||||||
cy.get('.task-view .details.content.description')
|
cy.get('.task-view .details.content.description')
|
||||||
.should('contain', tasks[0].description)
|
.should('contain', tasks[0].description)
|
||||||
@ -260,7 +256,6 @@ describe('Task', () => {
|
|||||||
.click()
|
.click()
|
||||||
|
|
||||||
cy.get('.task-view h6.subtitle')
|
cy.get('.task-view h6.subtitle')
|
||||||
.should('contain', namespaces[0].title)
|
|
||||||
.should('contain', projects[1].title)
|
.should('contain', projects[1].title)
|
||||||
cy.get('.global-notification')
|
cy.get('.global-notification')
|
||||||
.should('contain', 'Success')
|
.should('contain', 'Success')
|
||||||
|
@ -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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -11,7 +11,6 @@ export class ProjectFactory extends Factory {
|
|||||||
id: '{increment}',
|
id: '{increment}',
|
||||||
title: faker.lorem.words(3),
|
title: faker.lorem.words(3),
|
||||||
owner_id: 1,
|
owner_id: 1,
|
||||||
namespace_id: 1,
|
|
||||||
created: now.toISOString(),
|
created: now.toISOString(),
|
||||||
updated: now.toISOString(),
|
updated: now.toISOString(),
|
||||||
}
|
}
|
||||||
|
@ -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.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_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.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'
|
date -uIseconds | xargs echo 'info: started at'
|
||||||
|
@ -27,6 +27,9 @@
|
|||||||
// our sentry instance to notify us of potential problems.
|
// our sentry instance to notify us of potential problems.
|
||||||
window.SENTRY_ENABLED = false
|
window.SENTRY_ENABLED = false
|
||||||
window.SENTRY_DSN = 'https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480'
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
107
src/components/home/ProjectsNavigation.vue
Normal file
107
src/components/home/ProjectsNavigation.vue
Normal 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>
|
156
src/components/home/ProjectsNavigationItem.vue
Normal file
156
src/components/home/ProjectsNavigationItem.vue
Normal 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>
|
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<MenuButton class="menu-button" />
|
<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 class="project-title">{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@ -89,7 +89,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const currentProject = computed(() => baseStore.currentProject)
|
const currentProject = computed(() => baseStore.currentProject)
|
||||||
const background = computed(() => baseStore.background)
|
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 menuActive = computed(() => baseStore.menuActive)
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
@ -69,6 +69,7 @@ import BaseButton from '@/components/base/BaseButton.vue'
|
|||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useLabelStore} from '@/stores/labels'
|
import {useLabelStore} from '@/stores/labels'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
|
||||||
import {useRouteWithModal} from '@/composables/useRouteWithModal'
|
import {useRouteWithModal} from '@/composables/useRouteWithModal'
|
||||||
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
|
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
|
||||||
@ -94,14 +95,13 @@ watch(() => route.name as string, (routeName) => {
|
|||||||
(
|
(
|
||||||
[
|
[
|
||||||
'home',
|
'home',
|
||||||
'namespace.edit',
|
|
||||||
'teams.index',
|
'teams.index',
|
||||||
'teams.edit',
|
'teams.edit',
|
||||||
'tasks.range',
|
'tasks.range',
|
||||||
'labels.index',
|
'labels.index',
|
||||||
'migrate.start',
|
'migrate.start',
|
||||||
'migrate.wunderlist',
|
'migrate.wunderlist',
|
||||||
'namespaces.index',
|
'projects.index',
|
||||||
].includes(routeName) ||
|
].includes(routeName) ||
|
||||||
routeName.startsWith('user.settings')
|
routeName.startsWith('user.settings')
|
||||||
)
|
)
|
||||||
@ -116,6 +116,9 @@ useRenewTokenOnFocus()
|
|||||||
|
|
||||||
const labelStore = useLabelStore()
|
const labelStore = useLabelStore()
|
||||||
labelStore.loadAllLabels()
|
labelStore.loadAllLabels()
|
||||||
|
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
projectStore.loadProjects()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -9,9 +9,9 @@
|
|||||||
<Logo class="logo" v-if="logoVisible"/>
|
<Logo class="logo" v-if="logoVisible"/>
|
||||||
<h1
|
<h1
|
||||||
:class="{'m-0': !logoVisible}"
|
:class="{'m-0': !logoVisible}"
|
||||||
:style="{ 'opacity': currentProject.title === '' ? '0': '1' }"
|
:style="{ 'opacity': currentProject?.title === '' ? '0': '1' }"
|
||||||
class="title">
|
class="title">
|
||||||
{{ currentProject.title === '' ? $t('misc.loading') : currentProject.title }}
|
{{ currentProject?.title === '' ? $t('misc.loading') : currentProject?.title }}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="box has-text-left view">
|
<div class="box has-text-left view">
|
||||||
<router-view/>
|
<router-view/>
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside :class="{'is-active': menuActive}" class="namespace-container">
|
<aside :class="{'is-active': baseStore.menuActive}" class="menu-container">
|
||||||
<nav class="menu top-menu">
|
<nav class="menu top-menu">
|
||||||
<router-link :to="{name: 'home'}" class="logo">
|
<router-link :to="{name: 'home'}" class="logo">
|
||||||
<Logo width="164" height="48"/>
|
<Logo width="164" height="48"/>
|
||||||
</router-link>
|
</router-link>
|
||||||
<ul class="menu-list">
|
<menu class="menu-list other-menu-items">
|
||||||
<li>
|
<li>
|
||||||
<router-link :to="{ name: 'home'}" v-shortcut="'g o'">
|
<router-link :to="{ name: 'home'}" v-shortcut="'g o'">
|
||||||
<span class="menu-item-icon icon">
|
<span class="menu-item-icon icon">
|
||||||
@ -22,11 +22,11 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<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">
|
<span class="menu-item-icon icon">
|
||||||
<icon icon="layer-group"/>
|
<icon icon="layer-group"/>
|
||||||
</span>
|
</span>
|
||||||
{{ $t('namespace.title') }}
|
{{ $t('project.projects') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@ -45,238 +45,51 @@
|
|||||||
{{ $t('team.title') }}
|
{{ $t('team.title') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</menu>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<nav class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
|
<Loading
|
||||||
<template v-for="(n, nk) in namespaces" :key="n.id">
|
v-if="projectStore.isLoading"
|
||||||
<div class="namespace-title" :class="{'has-menu': n.id > 0}">
|
variant="small"
|
||||||
<BaseButton
|
/>
|
||||||
@click="toggleProjects(n.id)"
|
<template v-else>
|
||||||
class="menu-label"
|
<nav class="menu" v-if="favoriteProjects">
|
||||||
v-tooltip="namespaceTitles[nk]"
|
<ProjectsNavigation :model-value="favoriteProjects" :can-edit-order="false" :can-collapse="false"/>
|
||||||
>
|
</nav>
|
||||||
<ColorBubble
|
|
||||||
v-if="n.hexColor !== ''"
|
<nav class="menu">
|
||||||
:color="n.hexColor"
|
<ProjectsNavigation
|
||||||
class="mr-1"
|
:model-value="projects"
|
||||||
/>
|
:can-edit-order="true"
|
||||||
<span class="name">{{ namespaceTitles[nk] }}</span>
|
:can-collapse="true"
|
||||||
<div
|
:level="1"
|
||||||
class="icon menu-item-icon is-small toggle-lists-icon pl-2"
|
/>
|
||||||
:class="{'active': typeof projectsVisible[n.id] !== 'undefined' ? projectsVisible[n.id] : true}"
|
</nav>
|
||||||
>
|
</template>
|
||||||
<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>
|
|
||||||
<PoweredByLink/>
|
<PoweredByLink/>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, computed, onBeforeMount} from 'vue'
|
import {computed} from 'vue'
|
||||||
import draggable from 'zhyswan-vuedraggable'
|
|
||||||
import type {SortableEvent} from 'sortablejs'
|
|
||||||
|
|
||||||
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 PoweredByLink from '@/components/home/PoweredByLink.vue'
|
||||||
import Logo from '@/components/home/Logo.vue'
|
import Logo from '@/components/home/Logo.vue'
|
||||||
|
import Loading from '@/components/misc/loading.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 {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useNamespaceStore} from '@/stores/namespaces'
|
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||||
|
|
||||||
const drag = ref(false)
|
|
||||||
const dragOptions = {
|
|
||||||
animation: 100,
|
|
||||||
ghostClass: 'ghost',
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const namespaceStore = useNamespaceStore()
|
|
||||||
const currentProject = computed(() => baseStore.currentProject)
|
|
||||||
const menuActive = computed(() => baseStore.menuActive)
|
|
||||||
const loading = computed(() => namespaceStore.isLoading)
|
|
||||||
|
|
||||||
|
|
||||||
const namespaces = computed(() => {
|
|
||||||
return namespaceStore.namespaces.filter(n => !n.isArchived)
|
|
||||||
})
|
|
||||||
const activeProjects = computed(() => {
|
|
||||||
return namespaces.value.map(({projects}) => {
|
|
||||||
return projects?.filter(item => {
|
|
||||||
return typeof item !== 'undefined' && !item.isArchived
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const namespaceTitles = computed(() => {
|
|
||||||
return namespaces.value.map((namespace) => getNamespaceTitle(namespace))
|
|
||||||
})
|
|
||||||
|
|
||||||
const namespaceProjectsCount = computed(() => {
|
|
||||||
return namespaces.value.map((_, index) => activeProjects.value[index]?.length ?? 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
function toggleProjects(namespaceId: INamespace['id']) {
|
const projects = computed(() => projectStore.notArchivedRootProjects)
|
||||||
projectsVisible.value[namespaceId] = !projectsVisible.value[namespaceId]
|
const favoriteProjects = computed(() => projectStore.favoriteProjects)
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<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 {
|
.logo {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
@ -289,8 +102,8 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.namespace-container {
|
.menu-container {
|
||||||
background: $vikunja-nav-background;
|
background: var(--site-background);
|
||||||
color: $vikunja-nav-color;
|
color: $vikunja-nav-color;
|
||||||
padding: 0 0 1rem;
|
padding: 0 0 1rem;
|
||||||
transition: transform $transition-duration ease-in;
|
transition: transform $transition-duration ease-in;
|
||||||
@ -301,6 +114,7 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
width: $navbar-width;
|
width: $navbar-width;
|
||||||
|
margin-top: 1rem;
|
||||||
|
|
||||||
@media screen and (max-width: $tablet) {
|
@media screen and (max-width: $tablet) {
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -314,252 +128,24 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// these are general menu styles
|
.top-menu .menu-list {
|
||||||
// should be in own components
|
li {
|
||||||
.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;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-height: 2.5rem;
|
font-family: $vikunja-font;
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.favorite {
|
.list-menu-link,
|
||||||
margin-left: .25rem;
|
li > a {
|
||||||
transition: opacity $transition, color $transition;
|
padding-left: 2rem;
|
||||||
opacity: 1;
|
display: inline-block;
|
||||||
|
|
||||||
&.is-favorite {
|
.icon {
|
||||||
color: var(--warning);
|
padding-bottom: .25rem;
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@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 {
|
.menu + .menu {
|
||||||
display: flex;
|
padding-top: math.div($navbar-padding, 2);
|
||||||
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});
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -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>
|
|
@ -44,8 +44,8 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
|
|||||||
combination: 'then',
|
combination: 'then',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'keyboardShortcuts.navigation.namespaces',
|
title: 'keyboardShortcuts.navigation.projects',
|
||||||
keys: ['g', 'n'],
|
keys: ['g', 'p'],
|
||||||
combination: 'then',
|
combination: 'then',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -1,13 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="loader-container is-loading"></div>
|
<div class="loader-container is-loading" :class="{'is-small': variant === 'small'}"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export default {
|
export default {
|
||||||
inheritAttrs: false,
|
inheritAttrs: true,
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const {
|
||||||
|
variant = 'default',
|
||||||
|
} = defineProps<{
|
||||||
|
variant: 'default' | 'small'
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.loader-container {
|
.loader-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -20,5 +28,18 @@ export default {
|
|||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
min-width: 100px;
|
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>
|
</style>
|
@ -47,7 +47,7 @@ import {success} from '@/message'
|
|||||||
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
|
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
entity: String,
|
entity: String as ISubscription['entity'],
|
||||||
entityId: Number,
|
entityId: Number,
|
||||||
isButton: {
|
isButton: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@ -73,12 +73,6 @@ const {t} = useI18n({useScope: 'global'})
|
|||||||
|
|
||||||
const tooltipText = computed(() => {
|
const tooltipText = computed(() => {
|
||||||
if (disabled.value) {
|
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') {
|
if (props.entity === 'task' && subscriptionEntity.value === 'project') {
|
||||||
return t('task.subscription.subscribedTaskThroughParentProject')
|
return t('task.subscription.subscribedTaskThroughParentProject')
|
||||||
}
|
}
|
||||||
@ -87,10 +81,6 @@ const tooltipText = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (props.entity) {
|
switch (props.entity) {
|
||||||
case 'namespace':
|
|
||||||
return props.modelValue !== null ?
|
|
||||||
t('task.subscription.subscribedNamespace') :
|
|
||||||
t('task.subscription.notSubscribedNamespace')
|
|
||||||
case 'project':
|
case 'project':
|
||||||
return props.modelValue !== null ?
|
return props.modelValue !== null ?
|
||||||
t('task.subscription.subscribedProject') :
|
t('task.subscription.subscribedProject') :
|
||||||
@ -130,9 +120,6 @@ async function subscribe() {
|
|||||||
|
|
||||||
let message = ''
|
let message = ''
|
||||||
switch (props.entity) {
|
switch (props.entity) {
|
||||||
case 'namespace':
|
|
||||||
message = t('task.subscription.subscribeSuccessNamespace')
|
|
||||||
break
|
|
||||||
case 'project':
|
case 'project':
|
||||||
message = t('task.subscription.subscribeSuccessProject')
|
message = t('task.subscription.subscribeSuccessProject')
|
||||||
break
|
break
|
||||||
@ -153,9 +140,6 @@ async function unsubscribe() {
|
|||||||
|
|
||||||
let message = ''
|
let message = ''
|
||||||
switch (props.entity) {
|
switch (props.entity) {
|
||||||
case 'namespace':
|
|
||||||
message = t('task.subscription.unsubscribeSuccessNamespace')
|
|
||||||
break
|
|
||||||
case 'project':
|
case 'project':
|
||||||
message = t('task.subscription.unsubscribeSuccessProject')
|
message = t('task.subscription.unsubscribeSuccessProject')
|
||||||
break
|
break
|
||||||
|
@ -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>
|
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="{ 'is-loading': projectService.loading, 'is-archived': currentProject.isArchived}"
|
:class="{ 'is-loading': projectService.loading, 'is-archived': currentProject?.isArchived}"
|
||||||
class="loader-container"
|
class="loader-container"
|
||||||
>
|
>
|
||||||
<div class="switch-view-container">
|
<div class="switch-view-container">
|
||||||
@ -45,8 +45,8 @@
|
|||||||
<slot name="header" />
|
<slot name="header" />
|
||||||
</div>
|
</div>
|
||||||
<CustomTransition name="fade">
|
<CustomTransition name="fade">
|
||||||
<Message variant="warning" v-if="currentProject.isArchived" class="mb-4">
|
<Message variant="warning" v-if="currentProject?.isArchived" class="mb-4">
|
||||||
{{ $t('project.archived') }}
|
{{ $t('project.archivedMessage') }}
|
||||||
</Message>
|
</Message>
|
||||||
</CustomTransition>
|
</CustomTransition>
|
||||||
|
|
||||||
@ -98,7 +98,7 @@ const currentProject = computed(() => {
|
|||||||
maxRight: null,
|
maxRight: null,
|
||||||
} : baseStore.currentProject
|
} : 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.
|
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
|
||||||
// This resulted in loading and setting the project multiple times, even when navigating away from it.
|
// This resulted in loading and setting the project multiple times, even when navigating away from it.
|
||||||
@ -118,7 +118,7 @@ watch(
|
|||||||
(
|
(
|
||||||
projectIdToLoad === loadedProjectId.value ||
|
projectIdToLoad === loadedProjectId.value ||
|
||||||
typeof projectIdToLoad === 'undefined' ||
|
typeof projectIdToLoad === 'undefined' ||
|
||||||
projectIdToLoad === currentProject.value.id
|
projectIdToLoad === currentProject.value?.id
|
||||||
)
|
)
|
||||||
&& typeof currentProject.value !== 'undefined' && currentProject.value.maxRight !== null
|
&& 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
|
// 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
|
loadedProjectId.value = 0
|
||||||
const projectFromStore = projectStore.getProjectById(projectData.id)
|
const projectFromStore = projectStore.projects[projectData.id]
|
||||||
if (projectFromStore !== null) {
|
if (projectFromStore) {
|
||||||
baseStore.setBackground(null)
|
baseStore.setBackground(null)
|
||||||
baseStore.setBlurHash(null)
|
baseStore.setBlurHash(null)
|
||||||
baseStore.handleSetCurrentProject({project: projectFromStore})
|
baseStore.handleSetCurrentProject({project: projectFromStore})
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
:class="{'is-visible': background}"
|
:class="{'is-visible': background}"
|
||||||
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
|
: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>
|
<div class="project-title" aria-hidden="true">{{ project.title }}</div>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
@ -165,16 +165,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
</card>
|
</card>
|
||||||
</template>
|
</template>
|
||||||
@ -189,7 +179,6 @@ import {camelCase} from 'camel-case'
|
|||||||
|
|
||||||
import type {ILabel} from '@/modelTypes/ILabel'
|
import type {ILabel} from '@/modelTypes/ILabel'
|
||||||
import type {IUser} from '@/modelTypes/IUser'
|
import type {IUser} from '@/modelTypes/IUser'
|
||||||
import type {INamespace} from '@/modelTypes/INamespace'
|
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
|
||||||
import {useLabelStore} from '@/stores/labels'
|
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 Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||||
import SelectUser from '@/components/input/SelectUser.vue'
|
import SelectUser from '@/components/input/SelectUser.vue'
|
||||||
import SelectProject from '@/components/input/SelectProject.vue'
|
import SelectProject from '@/components/input/SelectProject.vue'
|
||||||
import SelectNamespace from '@/components/input/SelectNamespace.vue'
|
|
||||||
|
|
||||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||||
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
|
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
|
||||||
@ -209,7 +197,6 @@ import {objectToSnakeCase} from '@/helpers/case'
|
|||||||
|
|
||||||
import UserService from '@/services/user'
|
import UserService from '@/services/user'
|
||||||
import ProjectService from '@/services/project'
|
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
|
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
|
||||||
import {getDefaultParams} from '@/composables/useTaskList'
|
import {getDefaultParams} from '@/composables/useTaskList'
|
||||||
@ -240,7 +227,6 @@ const DEFAULT_FILTERS = {
|
|||||||
assignees: '',
|
assignees: '',
|
||||||
labels: '',
|
labels: '',
|
||||||
project_id: '',
|
project_id: '',
|
||||||
namespace: '',
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -265,23 +251,20 @@ const filters = ref({...DEFAULT_FILTERS})
|
|||||||
const services = {
|
const services = {
|
||||||
users: shallowReactive(new UserService()),
|
users: shallowReactive(new UserService()),
|
||||||
projects: shallowReactive(new ProjectService()),
|
projects: shallowReactive(new ProjectService()),
|
||||||
namespace: shallowReactive(new NamespaceService()),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Entities {
|
interface Entities {
|
||||||
users: IUser[]
|
users: IUser[]
|
||||||
labels: ILabel[]
|
labels: ILabel[]
|
||||||
projects: IProject[]
|
projects: IProject[]
|
||||||
namespace: INamespace[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type EntityType = 'users' | 'labels' | 'projects' | 'namespace'
|
type EntityType = 'users' | 'labels' | 'projects'
|
||||||
|
|
||||||
const entities: Entities = reactive({
|
const entities: Entities = reactive({
|
||||||
users: [],
|
users: [],
|
||||||
labels: [],
|
labels: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
namespace: [],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@ -328,7 +311,6 @@ function prepareFilters() {
|
|||||||
prepareDate('reminders')
|
prepareDate('reminders')
|
||||||
prepareRelatedObjectFilter('users', 'assignees')
|
prepareRelatedObjectFilter('users', 'assignees')
|
||||||
prepareRelatedObjectFilter('projects', 'project_id')
|
prepareRelatedObjectFilter('projects', 'project_id')
|
||||||
prepareRelatedObjectFilter('namespace')
|
|
||||||
|
|
||||||
prepareSingleValue('labels')
|
prepareSingleValue('labels')
|
||||||
|
|
||||||
|
@ -72,6 +72,13 @@
|
|||||||
@update:model-value="setSubscriptionInStore"
|
@update:model-value="setSubscriptionInStore"
|
||||||
type="dropdown"
|
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
|
<dropdown-item
|
||||||
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
|
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
|
||||||
icon="trash-alt"
|
icon="trash-alt"
|
||||||
@ -96,17 +103,18 @@ import type {ISubscription} from '@/modelTypes/ISubscription'
|
|||||||
import {isSavedFilter} from '@/services/savedFilter'
|
import {isSavedFilter} from '@/services/savedFilter'
|
||||||
import {useConfigStore} from '@/stores/config'
|
import {useConfigStore} from '@/stores/config'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useNamespaceStore} from '@/stores/namespaces'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
project: {
|
project: {
|
||||||
type: Object as PropType<IProject>,
|
type: Object as PropType<IProject>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
level: {
|
||||||
|
type: Number,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const namespaceStore = useNamespaceStore()
|
|
||||||
const subscription = ref<ISubscription | null>(null)
|
const subscription = ref<ISubscription | null>(null)
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
subscription.value = props.project.subscription ?? null
|
subscription.value = props.project.subscription ?? null
|
||||||
@ -122,6 +130,5 @@ function setSubscriptionInStore(sub: ISubscription) {
|
|||||||
subscription: sub,
|
subscription: sub,
|
||||||
}
|
}
|
||||||
projectStore.setProject(updatedProject)
|
projectStore.setProject(updatedProject)
|
||||||
namespaceStore.setProjectInNamespaceById(updatedProject)
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
@ -61,7 +61,6 @@ import {useRouter} from 'vue-router'
|
|||||||
import TaskService from '@/services/task'
|
import TaskService from '@/services/task'
|
||||||
import TeamService from '@/services/team'
|
import TeamService from '@/services/team'
|
||||||
|
|
||||||
import NamespaceModel from '@/models/namespace'
|
|
||||||
import TeamModel from '@/models/team'
|
import TeamModel from '@/models/team'
|
||||||
import ProjectModel from '@/models/project'
|
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 {useBaseStore} from '@/stores/base'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useNamespaceStore} from '@/stores/namespaces'
|
|
||||||
import {useLabelStore} from '@/stores/labels'
|
import {useLabelStore} from '@/stores/labels'
|
||||||
import {useTaskStore} from '@/stores/tasks'
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
|
|
||||||
@ -81,7 +79,6 @@ import {success} from '@/message'
|
|||||||
|
|
||||||
import type {ITeam} from '@/modelTypes/ITeam'
|
import type {ITeam} from '@/modelTypes/ITeam'
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
import type {INamespace} from '@/modelTypes/INamespace'
|
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
@ -89,7 +86,6 @@ const router = useRouter()
|
|||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const namespaceStore = useNamespaceStore()
|
|
||||||
const labelStore = useLabelStore()
|
const labelStore = useLabelStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
|
|
||||||
@ -105,7 +101,6 @@ enum ACTION_TYPE {
|
|||||||
enum COMMAND_TYPE {
|
enum COMMAND_TYPE {
|
||||||
NEW_TASK = 'newTask',
|
NEW_TASK = 'newTask',
|
||||||
NEW_PROJECT = 'newProject',
|
NEW_PROJECT = 'newProject',
|
||||||
NEW_NAMESPACE = 'newNamespace',
|
|
||||||
NEW_TEAM = 'newTeam',
|
NEW_TEAM = 'newTeam',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,24 +142,15 @@ const foundProjects = computed(() => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const ncache: { [id: ProjectModel['id']]: INamespace } = {}
|
|
||||||
const history = getHistory()
|
const history = getHistory()
|
||||||
const allProjects = [
|
const allProjects = [
|
||||||
...new Set([
|
...new Set([
|
||||||
...history.map((l) => projectStore.getProjectById(l.id)),
|
...history.map((l) => projectStore.projects[l.id]),
|
||||||
...projectStore.searchProject(project),
|
...projectStore.searchProject(project),
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
|
|
||||||
return allProjects.filter((l) => {
|
return allProjects.filter(l => Boolean(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
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// FIXME: use fuzzysearch
|
// FIXME: use fuzzysearch
|
||||||
@ -205,7 +191,6 @@ const results = computed<Result[]>(() => {
|
|||||||
|
|
||||||
const loading = computed(() =>
|
const loading = computed(() =>
|
||||||
taskService.loading ||
|
taskService.loading ||
|
||||||
namespaceStore.isLoading ||
|
|
||||||
projectStore.isLoading ||
|
projectStore.isLoading ||
|
||||||
teamService.loading,
|
teamService.loading,
|
||||||
)
|
)
|
||||||
@ -230,12 +215,6 @@ const commands = computed<{ [key in COMMAND_TYPE]: Command }>(() => ({
|
|||||||
placeholder: t('quickActions.newProject'),
|
placeholder: t('quickActions.newProject'),
|
||||||
action: newProject,
|
action: newProject,
|
||||||
},
|
},
|
||||||
newNamespace: {
|
|
||||||
type: COMMAND_TYPE.NEW_NAMESPACE,
|
|
||||||
title: t('quickActions.cmds.newNamespace'),
|
|
||||||
placeholder: t('quickActions.newNamespace'),
|
|
||||||
action: newNamespace,
|
|
||||||
},
|
|
||||||
newTeam: {
|
newTeam: {
|
||||||
type: COMMAND_TYPE.NEW_TEAM,
|
type: COMMAND_TYPE.NEW_TEAM,
|
||||||
title: t('quickActions.cmds.newTeam'),
|
title: t('quickActions.cmds.newTeam'),
|
||||||
@ -252,7 +231,6 @@ const currentProject = computed(() => Object.keys(baseStore.currentProject).leng
|
|||||||
)
|
)
|
||||||
|
|
||||||
const hintText = computed(() => {
|
const hintText = computed(() => {
|
||||||
let namespace
|
|
||||||
if (selectedCmd.value !== null && currentProject.value !== null) {
|
if (selectedCmd.value !== null && currentProject.value !== null) {
|
||||||
switch (selectedCmd.value.type) {
|
switch (selectedCmd.value.type) {
|
||||||
case COMMAND_TYPE.NEW_TASK:
|
case COMMAND_TYPE.NEW_TASK:
|
||||||
@ -260,12 +238,7 @@ const hintText = computed(() => {
|
|||||||
title: currentProject.value.title,
|
title: currentProject.value.title,
|
||||||
})
|
})
|
||||||
case COMMAND_TYPE.NEW_PROJECT:
|
case COMMAND_TYPE.NEW_PROJECT:
|
||||||
namespace = namespaceStore.getNamespaceById(
|
return t('quickActions.createProject')
|
||||||
currentProject.value.namespaceId,
|
|
||||||
)
|
|
||||||
return t('quickActions.createProject', {
|
|
||||||
title: namespace?.title,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const prefixes =
|
const prefixes =
|
||||||
@ -278,7 +251,7 @@ const availableCmds = computed(() => {
|
|||||||
if (currentProject.value !== null) {
|
if (currentProject.value !== null) {
|
||||||
cmds.push(commands.value.newTask, commands.value.newProject)
|
cmds.push(commands.value.newTask, commands.value.newProject)
|
||||||
}
|
}
|
||||||
cmds.push(commands.value.newNamespace, commands.value.newTeam)
|
cmds.push(commands.value.newTeam)
|
||||||
return cmds
|
return cmds
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -396,7 +369,7 @@ function searchTasks() {
|
|||||||
const r = await taskService.getAll({}, params) as DoAction<ITask>[]
|
const r = await taskService.getAll({}, params) as DoAction<ITask>[]
|
||||||
foundTasks.value = r.map((t) => {
|
foundTasks.value = r.map((t) => {
|
||||||
t.type = ACTION_TYPE.TASK
|
t.type = ACTION_TYPE.TASK
|
||||||
const project = projectStore.getProjectById(t.projectId)
|
const project = projectStore.projects[t.projectId]
|
||||||
if (project !== null) {
|
if (project !== null) {
|
||||||
t.title = `${t.title} (${project.title})`
|
t.title = `${t.title} (${project.title})`
|
||||||
}
|
}
|
||||||
@ -504,21 +477,10 @@ async function newProject() {
|
|||||||
if (currentProject.value === null) {
|
if (currentProject.value === null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const newProject = await projectStore.createProject(new ProjectModel({
|
await projectStore.createProject(new ProjectModel({
|
||||||
title: query.value,
|
title: query.value,
|
||||||
namespaceId: currentProject.value.namespaceId,
|
|
||||||
}))
|
}))
|
||||||
success({ message: t('project.create.createdSuccess')})
|
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() {
|
async function newTeam() {
|
||||||
|
@ -139,10 +139,6 @@ import {ref, reactive, computed, shallowReactive, type Ref} from 'vue'
|
|||||||
import type {PropType} from 'vue'
|
import type {PropType} from 'vue'
|
||||||
import {useI18n} from 'vue-i18n'
|
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 UserProjectService from '@/services/userProject'
|
||||||
import UserProjectModel from '@/models/userProject'
|
import UserProjectModel from '@/models/userProject'
|
||||||
import type {IUserProject} from '@/modelTypes/IUserProject'
|
import type {IUserProject} from '@/modelTypes/IUserProject'
|
||||||
@ -151,10 +147,6 @@ import UserService from '@/services/user'
|
|||||||
import UserModel, { getDisplayName } from '@/models/user'
|
import UserModel, { getDisplayName } from '@/models/user'
|
||||||
import type {IUser} from '@/modelTypes/IUser'
|
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 TeamProjectService from '@/services/teamProject'
|
||||||
import TeamProjectModel from '@/models/teamProject'
|
import TeamProjectModel from '@/models/teamProject'
|
||||||
import type { ITeamProject } from '@/modelTypes/ITeamProject'
|
import type { ITeamProject } from '@/modelTypes/ITeamProject'
|
||||||
@ -170,13 +162,15 @@ import Nothing from '@/components/misc/nothing.vue'
|
|||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
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({
|
const props = defineProps({
|
||||||
type: {
|
type: {
|
||||||
type: String as PropType<'project' | 'namespace'>,
|
type: String as PropType<'project'>,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
shareType: {
|
shareType: {
|
||||||
type: String as PropType<'user' | 'team' | 'namespace'>,
|
type: String as PropType<'user' | 'team'>,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
id: {
|
id: {
|
||||||
@ -191,9 +185,9 @@ const props = defineProps({
|
|||||||
|
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
|
||||||
// This user service is either a userNamespaceService or a userProjectService, depending on the type we are using
|
// This user service is a userProjectService, depending on the type we are using
|
||||||
let stuffService: UserNamespaceService | UserProjectService | TeamProjectService | TeamNamespaceService
|
let stuffService: UserProjectService | TeamProjectService
|
||||||
let stuffModel: IUserNamespace | IUserProject | ITeamProject | ITeamNamespace
|
let stuffModel: IUserProject | ITeamProject
|
||||||
let searchService: UserService | TeamService
|
let searchService: UserService | TeamService
|
||||||
let sharable: Ref<IUser | ITeam>
|
let sharable: Ref<IUser | ITeam>
|
||||||
|
|
||||||
@ -231,10 +225,6 @@ const sharableName = computed(() => {
|
|||||||
return t('project.list.title')
|
return t('project.list.title')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.shareType === 'namespace') {
|
|
||||||
return t('namespace.namespace')
|
|
||||||
}
|
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -247,11 +237,6 @@ if (props.shareType === 'user') {
|
|||||||
if (props.type === 'project') {
|
if (props.type === 'project') {
|
||||||
stuffService = shallowReactive(new UserProjectService())
|
stuffService = shallowReactive(new UserProjectService())
|
||||||
stuffModel = reactive(new UserProjectModel({projectId: props.id}))
|
stuffModel = reactive(new UserProjectModel({projectId: props.id}))
|
||||||
} else if (props.type === 'namespace') {
|
|
||||||
stuffService = shallowReactive(new UserNamespaceService())
|
|
||||||
stuffModel = reactive(new UserNamespaceModel({
|
|
||||||
namespaceId: props.id,
|
|
||||||
}))
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Unknown type: ' + props.type)
|
throw new Error('Unknown type: ' + props.type)
|
||||||
}
|
}
|
||||||
@ -264,11 +249,6 @@ if (props.shareType === 'user') {
|
|||||||
if (props.type === 'project') {
|
if (props.type === 'project') {
|
||||||
stuffService = shallowReactive(new TeamProjectService())
|
stuffService = shallowReactive(new TeamProjectService())
|
||||||
stuffModel = reactive(new TeamProjectModel({projectId: props.id}))
|
stuffModel = reactive(new TeamProjectModel({projectId: props.id}))
|
||||||
} else if (props.type === 'namespace') {
|
|
||||||
stuffService = shallowReactive(new TeamNamespaceService())
|
|
||||||
stuffModel = reactive(new TeamNamespaceModel({
|
|
||||||
namespaceId: props.id,
|
|
||||||
}))
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Unknown type: ' + props.type)
|
throw new Error('Unknown type: ' + props.type)
|
||||||
}
|
}
|
||||||
|
@ -11,8 +11,10 @@
|
|||||||
@search="findProjects"
|
@search="findProjects"
|
||||||
>
|
>
|
||||||
<template #searchResult="{option}">
|
<template #searchResult="{option}">
|
||||||
<span class="project-namespace-title search-result">{{ namespace((option as IProject).namespaceId) }} ></span>
|
<span class="has-text-grey" v-if="projectStore.getAncestors(option).length > 1">
|
||||||
{{ (option as IProject).title }}
|
{{ projectStore.getAncestors(option).filter(p => p.id !== option.id).map(p => getProjectTitle(p)).join(' > ') }} >
|
||||||
|
</span>
|
||||||
|
{{ getProjectTitle(option) }}
|
||||||
</template>
|
</template>
|
||||||
</Multiselect>
|
</Multiselect>
|
||||||
</template>
|
</template>
|
||||||
@ -20,13 +22,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {reactive, ref, watch} from 'vue'
|
import {reactive, ref, watch} from 'vue'
|
||||||
import type {PropType} from 'vue'
|
import type {PropType} from 'vue'
|
||||||
import {useI18n} from 'vue-i18n'
|
|
||||||
|
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
import type {INamespace} from '@/modelTypes/INamespace'
|
|
||||||
|
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useNamespaceStore} from '@/stores/namespaces'
|
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||||
|
|
||||||
import ProjectModel from '@/models/project'
|
import ProjectModel from '@/models/project'
|
||||||
|
|
||||||
@ -40,8 +40,6 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
const {t} = useI18n({useScope: 'global'})
|
|
||||||
|
|
||||||
const project: IProject = reactive(new ProjectModel())
|
const project: IProject = reactive(new ProjectModel())
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -54,7 +52,6 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const namespaceStore = useNamespaceStore()
|
|
||||||
const foundProjects = ref<IProject[]>([])
|
const foundProjects = ref<IProject[]>([])
|
||||||
function findProjects(query: string) {
|
function findProjects(query: string) {
|
||||||
if (query === '') {
|
if (query === '') {
|
||||||
@ -70,17 +67,4 @@ function select(l: IProject | null) {
|
|||||||
Object.assign(project, l)
|
Object.assign(project, l)
|
||||||
emit('update:modelValue', project)
|
emit('update:modelValue', project)
|
||||||
}
|
}
|
||||||
|
|
||||||
function namespace(namespaceId: INamespace['id']) {
|
|
||||||
const namespace = namespaceStore.getNamespaceById(namespaceId)
|
|
||||||
return namespace !== null
|
|
||||||
? namespace.title
|
|
||||||
: t('project.shared')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.project-namespace-title {
|
|
||||||
color: var(--grey-500);
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -46,11 +46,6 @@
|
|||||||
class="different-project"
|
class="different-project"
|
||||||
v-if="task.projectId !== projectId"
|
v-if="task.projectId !== projectId"
|
||||||
>
|
>
|
||||||
<span
|
|
||||||
v-if="task.differentNamespace !== null"
|
|
||||||
v-tooltip="$t('task.relation.differentNamespace')">
|
|
||||||
{{ task.differentNamespace }} >
|
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
v-if="task.differentProject !== null"
|
v-if="task.differentProject !== null"
|
||||||
v-tooltip="$t('task.relation.differentProject')">
|
v-tooltip="$t('task.relation.differentProject')">
|
||||||
@ -101,11 +96,6 @@
|
|||||||
class="different-project"
|
class="different-project"
|
||||||
v-if="t.projectId !== projectId"
|
v-if="t.projectId !== projectId"
|
||||||
>
|
>
|
||||||
<span
|
|
||||||
v-if="t.differentNamespace !== null"
|
|
||||||
v-tooltip="$t('task.relation.differentNamespace')">
|
|
||||||
{{ t.differentNamespace }} >
|
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
v-if="t.differentProject !== null"
|
v-if="t.differentProject !== null"
|
||||||
v-tooltip="$t('task.relation.differentProject')">
|
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 Multiselect from '@/components/input/multiselect.vue'
|
||||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||||
|
|
||||||
import {useNamespaceStore} from '@/stores/namespaces'
|
|
||||||
|
|
||||||
import {error, success} from '@/message'
|
import {error, success} from '@/message'
|
||||||
import {useTaskStore} from '@/stores/tasks'
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
taskId: {
|
taskId: {
|
||||||
@ -196,7 +185,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
const namespaceStore = useNamespaceStore()
|
const projectStore = useProjectStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
|
||||||
@ -230,26 +219,15 @@ async function findTasks(newQuery: string) {
|
|||||||
foundTasks.value = await taskService.getAll({}, {s: newQuery})
|
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[]) {
|
function mapRelatedTasks(tasks: ITask[]) {
|
||||||
return tasks.map(task => {
|
return tasks.map(task => {
|
||||||
// by doing this here once we can save a lot of duplicate calls in the template
|
// by doing this here once we can save a lot of duplicate calls in the template
|
||||||
const {
|
const project = projectStore.projects[task.ProjectId]
|
||||||
project,
|
|
||||||
namespace: taskNamespace,
|
|
||||||
} = getProjectAndNamespaceById(task.projectId) || {project: null, namespace: null}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
differentNamespace:
|
|
||||||
(taskNamespace !== null &&
|
|
||||||
taskNamespace.id !== namespace.value.id &&
|
|
||||||
taskNamespace?.title) || null,
|
|
||||||
differentProject:
|
differentProject:
|
||||||
(project !== null &&
|
(project &&
|
||||||
task.projectId !== props.projectId &&
|
task.projectId !== props.projectId &&
|
||||||
project?.title) || null,
|
project?.title) || null,
|
||||||
}
|
}
|
||||||
|
@ -7,19 +7,19 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ColorBubble
|
<ColorBubble
|
||||||
v-if="showProjectColor && projectColor !== '' && currentProject.id !== task.projectId"
|
v-if="showProjectColor && projectColor !== '' && currentProject?.id !== task.projectId"
|
||||||
:color="projectColor"
|
:color="projectColor"
|
||||||
class="mr-1"
|
class="mr-1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
:to="taskDetailRoute"
|
:to="taskDetailRoute"
|
||||||
:class="{ 'done': task.done, 'show-project': showProject && project !== null}"
|
:class="{ 'done': task.done, 'show-project': showProject && project}"
|
||||||
class="tasktext"
|
class="tasktext"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="showProject && project !== null"
|
v-if="showProject && typeof project !== 'undefined'"
|
||||||
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
|
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
|
||||||
class="task-project"
|
class="task-project"
|
||||||
:class="{'mr-2': task.hexColor !== ''}"
|
:class="{'mr-2': task.hexColor !== ''}"
|
||||||
@ -34,7 +34,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Show any parent tasks to make it clear this task is a sub task of something -->
|
<!-- Show any parent tasks to make it clear this task is a sub task of something -->
|
||||||
<span class="parent-tasks" v-if="typeof task.relatedTasks.parenttask !== 'undefined'">
|
<span class="parent-tasks" v-if="typeof task.relatedTasks?.parenttask !== 'undefined'">
|
||||||
<template v-for="(pt, i) in task.relatedTasks.parenttask">
|
<template v-for="(pt, i) in task.relatedTasks.parenttask">
|
||||||
{{ pt.title }}<template v-if="(i + 1) < task.relatedTasks.parenttask.length">, </template>
|
{{ pt.title }}<template v-if="(i + 1) < task.relatedTasks.parenttask.length">, </template>
|
||||||
</template>
|
</template>
|
||||||
@ -104,7 +104,7 @@
|
|||||||
</progress>
|
</progress>
|
||||||
|
|
||||||
<router-link
|
<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 } }"
|
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
|
||||||
class="task-project"
|
class="task-project"
|
||||||
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
|
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 {success} from '@/message'
|
||||||
|
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useNamespaceStore} from '@/stores/namespaces'
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useTaskStore} from '@/stores/tasks'
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
|
|
||||||
@ -209,10 +208,9 @@ onBeforeUnmount(() => {
|
|||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
const namespaceStore = useNamespaceStore()
|
|
||||||
|
|
||||||
const project = computed(() => projectStore.getProjectById(task.value.projectId))
|
const project = computed(() => projectStore.projects[task.value.projectId])
|
||||||
const projectColor = computed(() => project.value !== null ? project.value.hexColor : '')
|
const projectColor = computed(() => project.value ? project.value?.hexColor : '')
|
||||||
|
|
||||||
const currentProject = computed(() => {
|
const currentProject = computed(() => {
|
||||||
return typeof baseStore.currentProject === 'undefined' ? {
|
return typeof baseStore.currentProject === 'undefined' ? {
|
||||||
@ -257,10 +255,8 @@ function undoDone(checked: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleFavorite() {
|
async function toggleFavorite() {
|
||||||
task.value.isFavorite = !task.value.isFavorite
|
task.value = await taskStore.toggleFavorite(task.value)
|
||||||
task.value = await taskService.update(task.value)
|
|
||||||
emit('task-updated', task.value)
|
emit('task-updated', task.value)
|
||||||
namespaceStore.loadNamespacesIfFavoritesDontExist()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deferDueDate = ref<typeof DeferTask | null>(null)
|
const deferDueDate = ref<typeof DeferTask | null>(null)
|
||||||
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
7
src/helpers/canNestProjectDeeper.ts
Normal file
7
src/helpers/canNestProjectDeeper.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export function canNestProjectDeeper(level: number) {
|
||||||
|
if (level < 2) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return level >= 2 && window.PROJECT_INFINITE_NESTING_ENABLED
|
||||||
|
}
|
@ -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
|
|
||||||
}
|
|
@ -1,9 +1,14 @@
|
|||||||
import {i18n} from '@/i18n'
|
import {i18n} from '@/i18n'
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
|
||||||
export function getProjectTitle(l: IProject) {
|
export function getProjectTitle(project: IProject) {
|
||||||
if (l.id === -1) {
|
if (project.id === -1) {
|
||||||
return i18n.global.t('project.pseudo.favorites.title')
|
return i18n.global.t('project.pseudo.favorites.title')
|
||||||
}
|
}
|
||||||
return l.title
|
|
||||||
|
if (project.title === 'Inbox') {
|
||||||
|
return i18n.global.t('project.inboxTitle')
|
||||||
|
}
|
||||||
|
|
||||||
|
return project.title
|
||||||
}
|
}
|
||||||
|
@ -6,9 +6,7 @@
|
|||||||
"welcomeEvening": "Good Evening {username}!",
|
"welcomeEvening": "Good Evening {username}!",
|
||||||
"lastViewed": "Last viewed",
|
"lastViewed": "Last viewed",
|
||||||
"project": {
|
"project": {
|
||||||
"newText": "You can create a new project for your new tasks:",
|
"importText": "Import your projects and tasks from other services into Vikunja:",
|
||||||
"new": "New project",
|
|
||||||
"importText": "Or import your projects and tasks from other services into Vikunja:",
|
|
||||||
"import": "Import your data into Vikunja"
|
"import": "Import your data into Vikunja"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -143,7 +141,7 @@
|
|||||||
},
|
},
|
||||||
"deletion": {
|
"deletion": {
|
||||||
"title": "Delete your Vikunja Account",
|
"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.",
|
"text2": "To proceed, please enter your password. You will receive an email with further instructions.",
|
||||||
"confirm": "Delete my account",
|
"confirm": "Delete my account",
|
||||||
"requestSuccess": "The request was successful. You'll receive an email with further instructions.",
|
"requestSuccess": "The request was successful. You'll receive an email with further instructions.",
|
||||||
@ -157,7 +155,7 @@
|
|||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"title": "Export your Vikunja data",
|
"title": "Export your Vikunja data",
|
||||||
"description": "You can request a copy of all your Vikunja data. This include Namespaces, Projects, Tasks and everything associated to them. You can import this data in any Vikunja instance through the migration function.",
|
"description": "You can request a copy of all your Vikunja data. This includes Projects, Tasks and everything associated to them. You can import this data in any Vikunja instance through the migration function.",
|
||||||
"descriptionPasswordRequired": "Please enter your password to proceed:",
|
"descriptionPasswordRequired": "Please enter your password to proceed:",
|
||||||
"request": "Request a copy of my Vikunja Data",
|
"request": "Request a copy of my Vikunja Data",
|
||||||
"success": "You've successfully requested your Vikunja Data! We will send you an email once it's ready to download.",
|
"success": "You've successfully requested your Vikunja Data! We will send you an email once it's ready to download.",
|
||||||
@ -165,14 +163,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"project": {
|
"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",
|
"title": "Project Title",
|
||||||
"color": "Color",
|
"color": "Color",
|
||||||
"projects": "Projects",
|
"projects": "Projects",
|
||||||
|
"parent": "Parent Project",
|
||||||
"search": "Type to search for a project…",
|
"search": "Type to search for a project…",
|
||||||
"searchSelect": "Click or press enter to select this project",
|
"searchSelect": "Click or press enter to select this project",
|
||||||
"shared": "Shared Projects",
|
"shared": "Shared Projects",
|
||||||
"noDescriptionAvailable": "No project description is available.",
|
"noDescriptionAvailable": "No project description is available.",
|
||||||
|
"inboxTitle": "Inbox",
|
||||||
"create": {
|
"create": {
|
||||||
"header": "New project",
|
"header": "New project",
|
||||||
"titlePlaceholder": "The project's title goes here…",
|
"titlePlaceholder": "The project's title goes here…",
|
||||||
@ -210,7 +212,7 @@
|
|||||||
"duplicate": {
|
"duplicate": {
|
||||||
"title": "Duplicate this project",
|
"title": "Duplicate this project",
|
||||||
"label": "Duplicate",
|
"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."
|
"success": "The project was successfully duplicated."
|
||||||
},
|
},
|
||||||
"edit": {
|
"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": {
|
"filters": {
|
||||||
"title": "Filters",
|
"title": "Filters",
|
||||||
"clear": "Clear Filters",
|
"clear": "Clear Filters",
|
||||||
@ -403,7 +344,7 @@
|
|||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"title": "New Saved Filter",
|
"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",
|
"action": "Create new saved filter",
|
||||||
"titleRequired": "Please provide a title for the filter."
|
"titleRequired": "Please provide a title for the filter."
|
||||||
},
|
},
|
||||||
@ -677,19 +618,13 @@
|
|||||||
"updated": "Updated"
|
"updated": "Updated"
|
||||||
},
|
},
|
||||||
"subscription": {
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
|
||||||
"subscribe": "Subscribe",
|
"subscribe": "Subscribe",
|
||||||
"unsubscribe": "Unsubscribe",
|
"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",
|
"subscribeSuccessProject": "You are now subscribed to this project",
|
||||||
"unsubscribeSuccessProject": "You are now unsubscribed to this project",
|
"unsubscribeSuccessProject": "You are now unsubscribed to this project",
|
||||||
"subscribeSuccessTask": "You are now subscribed to this task",
|
"subscribeSuccessTask": "You are now subscribed to this task",
|
||||||
@ -766,7 +701,6 @@
|
|||||||
"searchPlaceholder": "Type search for a new task to add as related…",
|
"searchPlaceholder": "Type search for a new task to add as related…",
|
||||||
"createPlaceholder": "Add this as new related task",
|
"createPlaceholder": "Add this as new related task",
|
||||||
"differentProject": "This task belongs to a different project.",
|
"differentProject": "This task belongs to a different project.",
|
||||||
"differentNamespace": "This task belongs to a different namespace.",
|
|
||||||
"noneYet": "No task relations yet.",
|
"noneYet": "No task relations yet.",
|
||||||
"delete": "Delete Task Relation",
|
"delete": "Delete Task Relation",
|
||||||
"deleteText1": "Are you sure you want to delete this task relation?",
|
"deleteText1": "Are you sure you want to delete this task relation?",
|
||||||
@ -851,19 +785,19 @@
|
|||||||
"delete": {
|
"delete": {
|
||||||
"header": "Delete the team",
|
"header": "Delete the team",
|
||||||
"text1": "Are you sure you want to delete this team and all of its members?",
|
"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."
|
"success": "The team was successfully deleted."
|
||||||
},
|
},
|
||||||
"deleteUser": {
|
"deleteUser": {
|
||||||
"header": "Remove a user from the team",
|
"header": "Remove a user from the team",
|
||||||
"text1": "Are you sure you want to remove this 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."
|
"success": "The user was successfully deleted from the team."
|
||||||
},
|
},
|
||||||
"leave": {
|
"leave": {
|
||||||
"title": "Leave team",
|
"title": "Leave team",
|
||||||
"text1": "Are you sure you want to leave this 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."
|
"success": "You have successfully left the team."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -913,9 +847,9 @@
|
|||||||
"title": "Navigation",
|
"title": "Navigation",
|
||||||
"overview": "Navigate to overview",
|
"overview": "Navigate to overview",
|
||||||
"upcoming": "Navigate to upcoming tasks",
|
"upcoming": "Navigate to upcoming tasks",
|
||||||
"namespaces": "Navigate to namespaces & projects",
|
|
||||||
"labels": "Navigate to labels",
|
"labels": "Navigate to labels",
|
||||||
"teams": "Navigate to teams"
|
"teams": "Navigate to teams",
|
||||||
|
"projects": "Navigate to projects"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
@ -930,7 +864,8 @@
|
|||||||
"unarchive": "Un-Archive",
|
"unarchive": "Un-Archive",
|
||||||
"setBackground": "Set background",
|
"setBackground": "Set background",
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
"newProject": "New project"
|
"newProject": "New project",
|
||||||
|
"createProject": "Create project"
|
||||||
},
|
},
|
||||||
"apiConfig": {
|
"apiConfig": {
|
||||||
"url": "Vikunja URL",
|
"url": "Vikunja URL",
|
||||||
@ -949,7 +884,7 @@
|
|||||||
"notification": {
|
"notification": {
|
||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
"none": "You don't have any notifications. Have a nice day!",
|
"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": {
|
"quickActions": {
|
||||||
"commands": "Commands",
|
"commands": "Commands",
|
||||||
@ -960,14 +895,12 @@
|
|||||||
"teams": "Teams",
|
"teams": "Teams",
|
||||||
"newProject": "Enter the title of the new project…",
|
"newProject": "Enter the title of the new project…",
|
||||||
"newTask": "Enter the title of the new task…",
|
"newTask": "Enter the title of the new task…",
|
||||||
"newNamespace": "Enter the title of the new namespace…",
|
|
||||||
"newTeam": "Enter the name of the new team…",
|
"newTeam": "Enter the name of the new team…",
|
||||||
"createTask": "Create a task in the current project ({title})",
|
"createTask": "Create a task in the current project ({title})",
|
||||||
"createProject": "Create a project in the current namespace ({title})",
|
"createProject": "Create a project",
|
||||||
"cmds": {
|
"cmds": {
|
||||||
"newTask": "New task",
|
"newTask": "New task",
|
||||||
"newProject": "New project",
|
"newProject": "New project",
|
||||||
"newNamespace": "New namespace",
|
|
||||||
"newTeam": "New team"
|
"newTeam": "New team"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -1023,16 +956,9 @@
|
|||||||
"4017": "Invalid task filter comparator.",
|
"4017": "Invalid task filter comparator.",
|
||||||
"4018": "Invalid task filter concatenator.",
|
"4018": "Invalid task filter concatenator.",
|
||||||
"4019": "Invalid task filter value.",
|
"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.",
|
"6001": "The team name cannot be empty.",
|
||||||
"6002": "The team does not exist.",
|
"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.",
|
"6005": "The user is already a member of that team.",
|
||||||
"6006": "Cannot delete the last team member.",
|
"6006": "Cannot delete the last team member.",
|
||||||
"6007": "The team does not have access to the project to perform that action.",
|
"6007": "The team does not have access to the project to perform that action.",
|
||||||
|
@ -23,6 +23,7 @@ declare global {
|
|||||||
API_URL: string;
|
API_URL: string;
|
||||||
SENTRY_ENABLED: boolean;
|
SENTRY_ENABLED: boolean;
|
||||||
SENTRY_DSN: string;
|
SENTRY_DSN: string;
|
||||||
|
PROJECT_INFINITE_NESTING_ENABLED: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
|
||||||
}
|
|
@ -2,7 +2,6 @@ import type {IAbstract} from './IAbstract'
|
|||||||
import type {ITask} from './ITask'
|
import type {ITask} from './ITask'
|
||||||
import type {IUser} from './IUser'
|
import type {IUser} from './IUser'
|
||||||
import type {ISubscription} from './ISubscription'
|
import type {ISubscription} from './ISubscription'
|
||||||
import type {INamespace} from './INamespace'
|
|
||||||
|
|
||||||
|
|
||||||
export interface IProject extends IAbstract {
|
export interface IProject extends IAbstract {
|
||||||
@ -11,7 +10,6 @@ export interface IProject extends IAbstract {
|
|||||||
description: string
|
description: string
|
||||||
owner: IUser
|
owner: IUser
|
||||||
tasks: ITask[]
|
tasks: ITask[]
|
||||||
namespaceId: INamespace['id']
|
|
||||||
isArchived: boolean
|
isArchived: boolean
|
||||||
hexColor: string
|
hexColor: string
|
||||||
identifier: string
|
identifier: string
|
||||||
@ -20,6 +18,7 @@ export interface IProject extends IAbstract {
|
|||||||
subscription: ISubscription
|
subscription: ISubscription
|
||||||
position: number
|
position: number
|
||||||
backgroundBlurHash: string
|
backgroundBlurHash: string
|
||||||
|
parentProjectId: number
|
||||||
|
|
||||||
created: Date
|
created: Date
|
||||||
updated: Date
|
updated: Date
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import type {IAbstract} from './IAbstract'
|
import type {IAbstract} from './IAbstract'
|
||||||
import type {IProject} from './IProject'
|
import type {IProject} from './IProject'
|
||||||
import type {INamespace} from './INamespace'
|
|
||||||
|
|
||||||
export interface IProjectDuplicate extends IAbstract {
|
export interface IProjectDuplicate extends IAbstract {
|
||||||
projectId: number
|
projectId: number
|
||||||
namespaceId: INamespace['id']
|
|
||||||
project: IProject
|
project: IProject
|
||||||
|
parentProjectId: IProject['id']
|
||||||
}
|
}
|
@ -1,6 +0,0 @@
|
|||||||
import type {ITeamShareBase} from './ITeamShareBase'
|
|
||||||
import type {INamespace} from './INamespace'
|
|
||||||
|
|
||||||
export interface ITeamNamespace extends ITeamShareBase {
|
|
||||||
namespaceId: INamespace['id']
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
import type {IUserShareBase} from './IUserShareBase'
|
|
||||||
import type {INamespace} from './INamespace'
|
|
||||||
|
|
||||||
export interface IUserNamespace extends IUserShareBase {
|
|
||||||
namespaceId: INamespace['id']
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,7 +6,6 @@ import SubscriptionModel from '@/models/subscription'
|
|||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
import type {IUser} from '@/modelTypes/IUser'
|
import type {IUser} from '@/modelTypes/IUser'
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
import type {INamespace} from '@/modelTypes/INamespace'
|
|
||||||
import type {ISubscription} from '@/modelTypes/ISubscription'
|
import type {ISubscription} from '@/modelTypes/ISubscription'
|
||||||
|
|
||||||
export default class ProjectModel extends AbstractModel<IProject> implements IProject {
|
export default class ProjectModel extends AbstractModel<IProject> implements IProject {
|
||||||
@ -15,7 +14,6 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
|
|||||||
description = ''
|
description = ''
|
||||||
owner: IUser = UserModel
|
owner: IUser = UserModel
|
||||||
tasks: ITask[] = []
|
tasks: ITask[] = []
|
||||||
namespaceId: INamespace['id'] = 0
|
|
||||||
isArchived = false
|
isArchived = false
|
||||||
hexColor = ''
|
hexColor = ''
|
||||||
identifier = ''
|
identifier = ''
|
||||||
@ -24,6 +22,7 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
|
|||||||
subscription: ISubscription = null
|
subscription: ISubscription = null
|
||||||
position = 0
|
position = 0
|
||||||
backgroundBlurHash = ''
|
backgroundBlurHash = ''
|
||||||
|
parentProjectId = 0
|
||||||
|
|
||||||
created: Date = null
|
created: Date = null
|
||||||
updated: 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) {
|
if (typeof this.subscription !== 'undefined' && this.subscription !== null) {
|
||||||
this.subscription = new SubscriptionModel(this.subscription)
|
this.subscription = new SubscriptionModel(this.subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.created = new Date(this.created)
|
this.created = new Date(this.created)
|
||||||
this.updated = new Date(this.updated)
|
this.updated = new Date(this.updated)
|
||||||
}
|
}
|
||||||
|
@ -2,13 +2,12 @@ import AbstractModel from './abstractModel'
|
|||||||
import ProjectModel from './project'
|
import ProjectModel from './project'
|
||||||
|
|
||||||
import type {IProjectDuplicate} from '@/modelTypes/IProjectDuplicate'
|
import type {IProjectDuplicate} from '@/modelTypes/IProjectDuplicate'
|
||||||
import type {INamespace} from '@/modelTypes/INamespace'
|
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
|
||||||
export default class ProjectDuplicateModel extends AbstractModel<IProjectDuplicate> implements IProjectDuplicate {
|
export default class ProjectDuplicateModel extends AbstractModel<IProjectDuplicate> implements IProjectDuplicate {
|
||||||
projectId = 0
|
projectId = 0
|
||||||
namespaceId: INamespace['id'] = 0
|
|
||||||
project: IProject = ProjectModel
|
project: IProject = ProjectModel
|
||||||
|
parentProjectId = 0
|
||||||
|
|
||||||
constructor(data : Partial<IProjectDuplicate>) {
|
constructor(data : Partial<IProjectDuplicate>) {
|
||||||
super()
|
super()
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,7 +6,7 @@ import type {ITeam} from '@/modelTypes/ITeam'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is a base class for common team sharing model.
|
* 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 {
|
export default class TeamShareBaseModel extends AbstractModel<ITeamShareBase> implements ITeamShareBase {
|
||||||
teamId: ITeam['id'] = 0
|
teamId: ITeam['id'] = 0
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -22,7 +22,6 @@ const DataExportDownload = () => import('@/views/user/DataExportDownload.vue')
|
|||||||
// Tasks
|
// Tasks
|
||||||
import UpcomingTasksComponent from '@/views/tasks/ShowTasks.vue'
|
import UpcomingTasksComponent from '@/views/tasks/ShowTasks.vue'
|
||||||
import LinkShareAuthComponent from '@/views/sharing/LinkSharingAuth.vue'
|
import LinkShareAuthComponent from '@/views/sharing/LinkSharingAuth.vue'
|
||||||
const ListNamespaces = () => import('@/views/namespaces/ListNamespaces.vue')
|
|
||||||
const TaskDetailView = () => import('@/views/tasks/TaskDetailView.vue')
|
const TaskDetailView = () => import('@/views/tasks/TaskDetailView.vue')
|
||||||
|
|
||||||
// Team Handling
|
// Team Handling
|
||||||
@ -41,6 +40,7 @@ const ProjectKanban = () => import('@/views/project/ProjectKanban.vue')
|
|||||||
const ProjectInfo = () => import('@/views/project/ProjectInfo.vue')
|
const ProjectInfo = () => import('@/views/project/ProjectInfo.vue')
|
||||||
|
|
||||||
// Project Settings
|
// Project Settings
|
||||||
|
const ListProjects = () => import('@/views/project/ListProjects.vue')
|
||||||
const ProjectSettingEdit = () => import('@/views/project/settings/edit.vue')
|
const ProjectSettingEdit = () => import('@/views/project/settings/edit.vue')
|
||||||
const ProjectSettingBackground = () => import('@/views/project/settings/background.vue')
|
const ProjectSettingBackground = () => import('@/views/project/settings/background.vue')
|
||||||
const ProjectSettingDuplicate = () => import('@/views/project/settings/duplicate.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 ProjectSettingDelete = () => import('@/views/project/settings/delete.vue')
|
||||||
const ProjectSettingArchive = () => import('@/views/project/settings/archive.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
|
// Saved Filters
|
||||||
const FilterNew = () => import('@/views/filters/FilterNew.vue')
|
const FilterNew = () => import('@/views/filters/FilterNew.vue')
|
||||||
const FilterEdit = () => import('@/views/filters/FilterEdit.vue')
|
const FilterEdit = () => import('@/views/filters/FilterEdit.vue')
|
||||||
@ -74,9 +68,6 @@ const UserSettingsTOTPComponent = () => import('@/views/user/settings/TOTP.vue')
|
|||||||
// Project Handling
|
// Project Handling
|
||||||
const NewProjectComponent = () => import('@/views/project/NewProject.vue')
|
const NewProjectComponent = () => import('@/views/project/NewProject.vue')
|
||||||
|
|
||||||
// Namespace Handling
|
|
||||||
const NewNamespaceComponent = () => import('@/views/namespaces/NewNamespace.vue')
|
|
||||||
|
|
||||||
const EditTeamComponent = () => import('@/views/teams/EditTeam.vue')
|
const EditTeamComponent = () => import('@/views/teams/EditTeam.vue')
|
||||||
const NewTeamComponent = () => import('@/views/teams/NewTeam.vue')
|
const NewTeamComponent = () => import('@/views/teams/NewTeam.vue')
|
||||||
|
|
||||||
@ -203,54 +194,6 @@ const router = createRouter({
|
|||||||
name: 'link-share.auth',
|
name: 'link-share.auth',
|
||||||
component: LinkShareAuthComponent,
|
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',
|
path: '/tasks/:id',
|
||||||
name: 'task.detail',
|
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',
|
name: 'project.create',
|
||||||
component: NewProjectComponent,
|
component: NewProjectComponent,
|
||||||
meta: {
|
meta: {
|
||||||
showAsModal: true,
|
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',
|
path: '/projects/:projectId/settings/edit',
|
||||||
name: 'project.settings.edit',
|
name: 'project.settings.edit',
|
||||||
@ -412,7 +369,7 @@ const router = createRouter({
|
|||||||
saveProjectView(to.params.projectId, to.name)
|
saveProjectView(to.params.projectId, to.name)
|
||||||
// Properly set the page title when a task popup is closed
|
// Properly set the page title when a task popup is closed
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const projectFromStore = projectStore.getProjectById(Number(to.params.projectId))
|
const projectFromStore = projectStore.projects[Number(to.params.projectId)]
|
||||||
if(projectFromStore) {
|
if(projectFromStore) {
|
||||||
setTitle(projectFromStore.title)
|
setTitle(projectFromStore.title)
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -7,7 +7,7 @@ import {colorFromHex} from '@/helpers/color/colorFromHex'
|
|||||||
export default class ProjectService extends AbstractService<IProject> {
|
export default class ProjectService extends AbstractService<IProject> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super({
|
super({
|
||||||
create: '/namespaces/{namespaceId}/projects',
|
create: '/projects',
|
||||||
get: '/projects/{id}',
|
get: '/projects/{id}',
|
||||||
getAll: '/projects',
|
getAll: '/projects',
|
||||||
update: '/projects/{id}',
|
update: '/projects/{id}',
|
||||||
|
@ -12,7 +12,7 @@ import AbstractService from '@/services/abstractService'
|
|||||||
import SavedFilterModel from '@/models/savedFilter'
|
import SavedFilterModel from '@/models/savedFilter'
|
||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useNamespaceStore} from '@/stores/namespaces'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
|
||||||
import {objectToSnakeCase, objectToCamelCase} from '@/helpers/case'
|
import {objectToSnakeCase, objectToCamelCase} from '@/helpers/case'
|
||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
@ -40,7 +40,7 @@ export function getSavedFilterIdFromProjectId(projectId: IProject['id']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isSavedFilter(project: IProject) {
|
export function isSavedFilter(project: IProject) {
|
||||||
return getSavedFilterIdFromProjectId(project.id) > 0
|
return getSavedFilterIdFromProjectId(project?.id) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class SavedFilterService extends AbstractService<ISavedFilter> {
|
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']>) {
|
export function useSavedFilter(projectId?: MaybeRef<IProject['id']>) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const {t} = useI18n({useScope:'global'})
|
const {t} = useI18n({useScope:'global'})
|
||||||
const namespaceStore = useNamespaceStore()
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
const filterService = shallowReactive(new SavedFilterService())
|
const filterService = shallowReactive(new SavedFilterService())
|
||||||
|
|
||||||
@ -110,13 +110,13 @@ export function useSavedFilter(projectId?: MaybeRef<IProject['id']>) {
|
|||||||
|
|
||||||
async function createFilter() {
|
async function createFilter() {
|
||||||
filter.value = await filterService.create(filter.value)
|
filter.value = await filterService.create(filter.value)
|
||||||
await namespaceStore.loadNamespaces()
|
await projectStore.loadProjects()
|
||||||
router.push({name: 'project.index', params: {projectId: getProjectId(filter.value)}})
|
router.push({name: 'project.index', params: {projectId: getProjectId(filter.value)}})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveFilter() {
|
async function saveFilter() {
|
||||||
const response = await filterService.update(filter.value)
|
const response = await filterService.update(filter.value)
|
||||||
await namespaceStore.loadNamespaces()
|
await projectStore.loadProjects()
|
||||||
success({message: t('filters.edit.success')})
|
success({message: t('filters.edit.success')})
|
||||||
response.filters = objectToSnakeCase(response.filters)
|
response.filters = objectToSnakeCase(response.filters)
|
||||||
filter.value = response
|
filter.value = response
|
||||||
@ -129,9 +129,9 @@ export function useSavedFilter(projectId?: MaybeRef<IProject['id']>) {
|
|||||||
|
|
||||||
async function deleteFilter() {
|
async function deleteFilter() {
|
||||||
await filterService.delete(filter.value)
|
await filterService.delete(filter.value)
|
||||||
await namespaceStore.loadNamespaces()
|
await projectStore.loadProjects()
|
||||||
success({message: t('filters.delete.success')})
|
success({message: t('filters.delete.success')})
|
||||||
router.push({name: 'namespaces.index'})
|
router.push({name: 'projects.index'})
|
||||||
}
|
}
|
||||||
|
|
||||||
const titleValid = ref(true)
|
const titleValid = ref(true)
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -81,7 +81,7 @@ export const useBaseStore = defineStore('base', () => {
|
|||||||
async function handleSetCurrentProject(
|
async function handleSetCurrentProject(
|
||||||
{project, forceUpdate = false}: {project: IProject | null, forceUpdate?: boolean},
|
{project, forceUpdate = false}: {project: IProject | null, forceUpdate?: boolean},
|
||||||
) {
|
) {
|
||||||
if (project === null) {
|
if (project === null || typeof project === 'undefined') {
|
||||||
setCurrentProject({})
|
setCurrentProject({})
|
||||||
setBackground('')
|
setBackground('')
|
||||||
setBlurHash('')
|
setBlurHash('')
|
||||||
|
@ -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))
|
|
||||||
}
|
|
@ -1,12 +1,14 @@
|
|||||||
import {watch, reactive, shallowReactive, unref, toRefs, readonly, ref, computed} from 'vue'
|
import {watch, reactive, shallowReactive, unref, readonly, ref, computed} from 'vue'
|
||||||
import {acceptHMRUpdate, defineStore} from 'pinia'
|
import {acceptHMRUpdate, defineStore} from 'pinia'
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
|
import {useRouter} from 'vue-router'
|
||||||
|
|
||||||
import ProjectService from '@/services/project'
|
import ProjectService from '@/services/project'
|
||||||
|
import ProjectDuplicateService from '@/services/projectDuplicateService'
|
||||||
|
import ProjectDuplicateModel from '@/models/projectDuplicateModel'
|
||||||
import {setModuleLoading} from '@/stores/helper'
|
import {setModuleLoading} from '@/stores/helper'
|
||||||
import {removeProjectFromHistory} from '@/modules/projectHistory'
|
import {removeProjectFromHistory} from '@/modules/projectHistory'
|
||||||
import {createNewIndexer} from '@/indexes'
|
import {createNewIndexer} from '@/indexes'
|
||||||
import {useNamespaceStore} from './namespaces'
|
|
||||||
|
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
|
||||||
@ -16,9 +18,7 @@ import ProjectModel from '@/models/project'
|
|||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
|
||||||
const {add, remove, search, update} = createNewIndexer('projects', ['title', 'description'])
|
const {remove, search, update} = createNewIndexer('projects', ['title', 'description'])
|
||||||
|
|
||||||
const FavoriteProjectsNamespace = -2
|
|
||||||
|
|
||||||
export interface ProjectState {
|
export interface ProjectState {
|
||||||
[id: IProject['id']]: IProject
|
[id: IProject['id']]: IProject
|
||||||
@ -26,16 +26,22 @@ export interface ProjectState {
|
|||||||
|
|
||||||
export const useProjectStore = defineStore('project', () => {
|
export const useProjectStore = defineStore('project', () => {
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const namespaceStore = useNamespaceStore()
|
const router = useRouter()
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
// The projects are stored as an object which has the project ids as keys.
|
// The projects are stored as an object which has the project ids as keys.
|
||||||
const projects = ref<ProjectState>({})
|
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 getChildProjects = computed(() => {
|
||||||
const getProjectById = computed(() => {
|
return (id: IProject['id']) => projectsArray.value.filter(p => p.parentProjectId === id)
|
||||||
return (id: IProject['id']) => typeof projects.value[id] !== 'undefined' ? projects.value[id] : null
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const findProjectByExactname = computed(() => {
|
const findProjectByExactname = computed(() => {
|
||||||
@ -53,7 +59,7 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
?.filter(value => value > 0)
|
?.filter(value => value > 0)
|
||||||
.map(id => projects.value[id])
|
.map(id => projects.value[id])
|
||||||
.filter(project => project.isArchived === includeArchived)
|
.filter(project => project.isArchived === includeArchived)
|
||||||
|| []
|
|| []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -65,16 +71,15 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
projects.value[project.id] = project
|
projects.value[project.id] = project
|
||||||
update(project)
|
update(project)
|
||||||
|
|
||||||
|
// FIXME: This should be a watcher, but using a watcher instead will sometimes crash browser processes.
|
||||||
|
// Reverted from 31b7c1f217532bf388ba95a03f469508bee46f6a
|
||||||
if (baseStore.currentProject?.id === project.id) {
|
if (baseStore.currentProject?.id === project.id) {
|
||||||
baseStore.setCurrentProject(project)
|
baseStore.setCurrentProject(project)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setProjects(newProjects: IProject[]) {
|
function setProjects(newProjects: IProject[]) {
|
||||||
newProjects.forEach(l => {
|
newProjects.forEach(p => setProject(p))
|
||||||
projects.value[l.id] = l
|
|
||||||
add(l)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeProjectById(project: IProject) {
|
function removeProjectById(project: IProject) {
|
||||||
@ -100,9 +105,11 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const createdProject = await projectService.create(project)
|
const createdProject = await projectService.create(project)
|
||||||
createdProject.namespaceId = project.namespaceId
|
|
||||||
namespaceStore.addProjectToNamespace(createdProject)
|
|
||||||
setProject(createdProject)
|
setProject(createdProject)
|
||||||
|
router.push({
|
||||||
|
name: 'project.index',
|
||||||
|
params: { projectId: createdProject.id },
|
||||||
|
})
|
||||||
return createdProject
|
return createdProject
|
||||||
} finally {
|
} finally {
|
||||||
cancel()
|
cancel()
|
||||||
@ -112,26 +119,14 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
async function updateProject(project: IProject) {
|
async function updateProject(project: IProject) {
|
||||||
const cancel = setModuleLoading(setIsLoading)
|
const cancel = setModuleLoading(setIsLoading)
|
||||||
const projectService = new ProjectService()
|
const projectService = new ProjectService()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await projectService.update(project)
|
const updatedProject = await projectService.update(project)
|
||||||
setProject(project)
|
setProject(project)
|
||||||
namespaceStore.setProjectInNamespaceById(project)
|
|
||||||
|
|
||||||
// the returned project from projectService.update is the same!
|
// 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
|
// in order to not create a manipulation in pinia store we have to create a new copy
|
||||||
const newProject = {
|
return updatedProject
|
||||||
...project,
|
|
||||||
namespaceId: FavoriteProjectsNamespace,
|
|
||||||
}
|
|
||||||
|
|
||||||
namespaceStore.removeProjectFromNamespaceById(newProject)
|
|
||||||
if (project.isFavorite) {
|
|
||||||
namespaceStore.addProjectToNamespace(newProject)
|
|
||||||
}
|
|
||||||
namespaceStore.loadNamespacesIfFavoritesDontExist()
|
|
||||||
namespaceStore.removeFavoritesNamespaceIfEmpty()
|
|
||||||
return newProject
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Reset the project state to the initial one to avoid confusion for the user
|
// Reset the project state to the initial one to avoid confusion for the user
|
||||||
setProject({
|
setProject({
|
||||||
@ -151,7 +146,6 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
try {
|
try {
|
||||||
const response = await projectService.delete(project)
|
const response = await projectService.delete(project)
|
||||||
removeProjectById(project)
|
removeProjectById(project)
|
||||||
namespaceStore.removeProjectFromNamespaceById(project)
|
|
||||||
removeProjectFromHistory({id: project.id})
|
removeProjectFromHistory({id: project.id})
|
||||||
return response
|
return response
|
||||||
} finally {
|
} finally {
|
||||||
@ -159,11 +153,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 {
|
return {
|
||||||
isLoading: readonly(isLoading),
|
isLoading: readonly(isLoading),
|
||||||
projects: readonly(projects),
|
projects: readonly(projects),
|
||||||
|
projectsArray: readonly(projectsArray),
|
||||||
|
notArchivedRootProjects: readonly(notArchivedRootProjects),
|
||||||
|
favoriteProjects: readonly(favoriteProjects),
|
||||||
|
hasProjects: readonly(hasProjects),
|
||||||
|
|
||||||
getProjectById,
|
getChildProjects,
|
||||||
findProjectByExactname,
|
findProjectByExactname,
|
||||||
searchProject,
|
searchProject,
|
||||||
|
|
||||||
@ -171,17 +196,24 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
setProjects,
|
setProjects,
|
||||||
removeProjectById,
|
removeProjectById,
|
||||||
toggleProjectFavorite,
|
toggleProjectFavorite,
|
||||||
|
loadProjects,
|
||||||
createProject,
|
createProject,
|
||||||
updateProject,
|
updateProject,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
|
getAncestors,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export function useProject(projectId: MaybeRef<IProject['id']>) {
|
export function useProject(projectId: MaybeRef<IProject['id']>) {
|
||||||
const projectService = shallowReactive(new ProjectService())
|
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 project: IProject = reactive(new ProjectModel())
|
||||||
|
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
const router = useRouter()
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => unref(projectId),
|
() => unref(projectId),
|
||||||
@ -192,20 +224,34 @@ export function useProject(projectId: MaybeRef<IProject['id']>) {
|
|||||||
{immediate: true},
|
{immediate: true},
|
||||||
)
|
)
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
|
||||||
async function save() {
|
async function save() {
|
||||||
await projectStore.updateProject(project)
|
const updatedProject = await projectStore.updateProject(project)
|
||||||
|
Object.assign(project, updatedProject)
|
||||||
success({message: t('project.edit.success')})
|
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 {
|
return {
|
||||||
isLoading: readonly(isLoading),
|
isLoading: readonly(isLoading),
|
||||||
project,
|
project,
|
||||||
save,
|
save,
|
||||||
|
duplicateProject,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// support hot reloading
|
// support hot reloading
|
||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
import.meta.hot.accept(acceptHMRUpdate(useProjectStore, import.meta.hot))
|
import.meta.hot.accept(acceptHMRUpdate(useProjectStore, import.meta.hot))
|
||||||
}
|
}
|
@ -432,6 +432,17 @@ export const useTaskStore = defineStore('task', () => {
|
|||||||
coverImageAttachmentId: attachment ? attachment.id : 0,
|
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 {
|
return {
|
||||||
tasks,
|
tasks,
|
||||||
@ -453,6 +464,7 @@ export const useTaskStore = defineStore('task', () => {
|
|||||||
setCoverImage,
|
setCoverImage,
|
||||||
findProjectId,
|
findProjectId,
|
||||||
ensureLabelsExist,
|
ensureLabelsExist,
|
||||||
|
toggleFavorite,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -33,3 +33,7 @@ $switch-view-height: 2.69rem;
|
|||||||
|
|
||||||
$navbar-height: 4rem;
|
$navbar-height: 4rem;
|
||||||
$navbar-width: 300px;
|
$navbar-width: 300px;
|
||||||
|
$navbar-padding: 2rem;
|
||||||
|
|
||||||
|
$vikunja-nav-color: var(--grey-700);
|
||||||
|
$vikunja-nav-selected-width: 0.4rem;
|
||||||
|
@ -8,4 +8,5 @@
|
|||||||
@import "link-share";
|
@import "link-share";
|
||||||
@import "loading";
|
@import "loading";
|
||||||
@import "flatpickr";
|
@import "flatpickr";
|
||||||
@import 'helpers';
|
@import 'helpers';
|
||||||
|
@import 'navigation';
|
139
src/styles/theme/navigation.scss
Normal file
139
src/styles/theme/navigation.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -17,22 +17,11 @@
|
|||||||
@taskAdded="updateTaskKey"
|
@taskAdded="updateTaskKey"
|
||||||
class="is-max-width-desktop"
|
class="is-max-width-desktop"
|
||||||
/>
|
/>
|
||||||
<template v-if="!hasTasks && !loading">
|
<template v-if="!hasTasks && !loading && migratorsEnabled">
|
||||||
<template v-if="defaultNamespaceId > 0">
|
<p class="mt-4">
|
||||||
<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">
|
|
||||||
{{ $t('home.project.importText') }}
|
{{ $t('home.project.importText') }}
|
||||||
</p>
|
</p>
|
||||||
<x-button
|
<x-button
|
||||||
v-if="migratorsEnabled"
|
|
||||||
:to="{ name: 'migrate.start' }"
|
:to="{ name: 'migrate.start' }"
|
||||||
:shadow="false">
|
:shadow="false">
|
||||||
{{ $t('home.project.import') }}
|
{{ $t('home.project.import') }}
|
||||||
@ -43,7 +32,7 @@
|
|||||||
<ProjectCardGrid :projects="projectHistory" v-cy="'projectCardGrid'" />
|
<ProjectCardGrid :projects="projectHistory" v-cy="'projectCardGrid'" />
|
||||||
</div>
|
</div>
|
||||||
<ShowTasks
|
<ShowTasks
|
||||||
v-if="hasProjects"
|
v-if="projectStore.hasProjects"
|
||||||
class="show-tasks"
|
class="show-tasks"
|
||||||
:key="showTasksKey"
|
:key="showTasksKey"
|
||||||
/>
|
/>
|
||||||
@ -66,17 +55,14 @@ import {useDaytimeSalutation} from '@/composables/useDaytimeSalutation'
|
|||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useConfigStore} from '@/stores/config'
|
import {useConfigStore} from '@/stores/config'
|
||||||
import {useNamespaceStore} from '@/stores/namespaces'
|
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
import {useTaskStore} from '@/stores/tasks'
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
|
||||||
|
|
||||||
const salutation = useDaytimeSalutation()
|
const salutation = useDaytimeSalutation()
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const namespaceStore = useNamespaceStore()
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
|
|
||||||
@ -87,14 +73,12 @@ const projectHistory = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return getHistory()
|
return getHistory()
|
||||||
.map(l => projectStore.getProjectById(l.id))
|
.map(l => projectStore.projects[l.id])
|
||||||
.filter((l): l is IProject => l !== null)
|
.filter(l => Boolean(l))
|
||||||
})
|
})
|
||||||
|
|
||||||
const migratorsEnabled = computed(() => configStore.availableMigrators?.length > 0)
|
const migratorsEnabled = computed(() => configStore.availableMigrators?.length > 0)
|
||||||
const hasTasks = computed(() => baseStore.hasTasks)
|
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 loading = computed(() => taskStore.isLoading)
|
||||||
const deletionScheduledAt = computed(() => parseDateOrNull(authStore.info?.deletionScheduledAt))
|
const deletionScheduledAt = computed(() => parseDateOrNull(authStore.info?.deletionScheduledAt))
|
||||||
|
|
||||||
|
@ -89,8 +89,8 @@ import {formatDateLong} from '@/helpers/time/formatDate'
|
|||||||
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
|
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
|
||||||
|
|
||||||
import {MIGRATORS} from './migrators'
|
import {MIGRATORS} from './migrators'
|
||||||
import {useNamespaceStore} from '@/stores/namespaces'
|
|
||||||
import {useTitle} from '@/composables/useTitle'
|
import {useTitle} from '@/composables/useTitle'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
|
||||||
const PROGRESS_DOTS_COUNT = 8
|
const PROGRESS_DOTS_COUNT = 8
|
||||||
|
|
||||||
@ -163,8 +163,8 @@ async function migrate() {
|
|||||||
? await migrationFileService.migrate(migrationConfig as File)
|
? await migrationFileService.migrate(migrationConfig as File)
|
||||||
: await migrationService.migrate(migrationConfig as MigrationConfig)
|
: await migrationService.migrate(migrationConfig as MigrationConfig)
|
||||||
message.value = result.message
|
message.value = result.message
|
||||||
const namespaceStore = useNamespaceStore()
|
const projectStore = useProjectStore()
|
||||||
return namespaceStore.loadNamespaces()
|
return projectStore.loadProjects()
|
||||||
} finally {
|
} finally {
|
||||||
isMigrating.value = false
|
isMigrating.value = false
|
||||||
}
|
}
|
||||||
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
95
src/views/project/ListProjects.vue
Normal file
95
src/views/project/ListProjects.vue
Normal 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>
|
@ -1,5 +1,9 @@
|
|||||||
<template>
|
<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">
|
<div class="field">
|
||||||
<label class="label" for="projectTitle">{{ $t('project.title') }}</label>
|
<label class="label" for="projectTitle">{{ $t('project.title') }}</label>
|
||||||
<div
|
<div
|
||||||
@ -22,19 +26,24 @@
|
|||||||
<p class="help is-danger" v-if="showError && project.title === ''">
|
<p class="help is-danger" v-if="showError && project.title === ''">
|
||||||
{{ $t('project.create.addTitleRequired') }}
|
{{ $t('project.create.addTitleRequired') }}
|
||||||
</p>
|
</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">
|
<div class="field">
|
||||||
<label class="label">{{ $t('project.color') }}</label>
|
<label class="label">{{ $t('project.color') }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<color-picker v-model="project.hexColor" />
|
<color-picker v-model="project.hexColor"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</create-edit>
|
</create-edit>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, reactive, shallowReactive} from 'vue'
|
import {ref, reactive, shallowReactive, watch} from 'vue'
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
import {useRouter, useRoute} from 'vue-router'
|
|
||||||
|
|
||||||
import ProjectService from '@/services/project'
|
import ProjectService from '@/services/project'
|
||||||
import ProjectModel from '@/models/project'
|
import ProjectModel from '@/models/project'
|
||||||
@ -44,10 +53,10 @@ import ColorPicker from '@/components/input/ColorPicker.vue'
|
|||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
import {useTitle} from '@/composables/useTitle'
|
import {useTitle} from '@/composables/useTitle'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
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 {t} = useI18n({useScope: 'global'})
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
useTitle(() => t('project.create.header'))
|
useTitle(() => t('project.create.header'))
|
||||||
|
|
||||||
@ -55,6 +64,17 @@ const showError = ref(false)
|
|||||||
const project = reactive(new ProjectModel())
|
const project = reactive(new ProjectModel())
|
||||||
const projectService = shallowReactive(new ProjectService())
|
const projectService = shallowReactive(new ProjectService())
|
||||||
const projectStore = useProjectStore()
|
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() {
|
async function createNewProject() {
|
||||||
if (project.title === '') {
|
if (project.title === '') {
|
||||||
@ -63,12 +83,11 @@ async function createNewProject() {
|
|||||||
}
|
}
|
||||||
showError.value = false
|
showError.value = false
|
||||||
|
|
||||||
project.namespaceId = Number(route.params.namespaceId as string)
|
if (parentProject.value) {
|
||||||
const newProject = await projectStore.createProject(project)
|
project.parentProjectId = parentProject.value.id
|
||||||
await router.push({
|
}
|
||||||
name: 'project.index',
|
|
||||||
params: { projectId: newProject.id },
|
await projectStore.createProject(project)
|
||||||
})
|
success({message: t('project.create.createdSuccess')})
|
||||||
success({message: t('project.create.createdSuccess') })
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
@ -75,7 +75,7 @@ const GanttChart = createAsyncComponent(() => import('@/components/tasks/GanttCh
|
|||||||
const props = defineProps<{route: RouteLocationNormalized}>()
|
const props = defineProps<{route: RouteLocationNormalized}>()
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const canWrite = computed(() => baseStore.currentProject.maxRight > RIGHTS.READ)
|
const canWrite = computed(() => baseStore.currentProject?.maxRight > RIGHTS.READ)
|
||||||
|
|
||||||
const {route} = toRefs(props)
|
const {route} = toRefs(props)
|
||||||
const {
|
const {
|
||||||
|
@ -29,7 +29,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const project = computed(() => projectStore.getProjectById(props.projectId))
|
const project = computed(() => projectStore.projects[props.projectId])
|
||||||
const htmlDescription = computed(() => {
|
const htmlDescription = computed(() => {
|
||||||
const description = project.value?.description || ''
|
const description = project.value?.description || ''
|
||||||
if (description === '') {
|
if (description === '') {
|
||||||
|
@ -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 project = computed(() => baseStore.currentProject)
|
||||||
|
|
||||||
const buckets = computed(() => kanbanStore.buckets)
|
const buckets = computed(() => kanbanStore.buckets)
|
||||||
|
@ -31,7 +31,7 @@ const projectStore = useProjectStore()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
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}))
|
useTitle(() => t('project.archive.title', {project: project.value.title}))
|
||||||
|
|
||||||
async function archiveProject() {
|
async function archiveProject() {
|
||||||
|
@ -108,7 +108,6 @@ import CustomTransition from '@/components/misc/CustomTransition.vue'
|
|||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useNamespaceStore} from '@/stores/namespaces'
|
|
||||||
import {useConfigStore} from '@/stores/config'
|
import {useConfigStore} from '@/stores/config'
|
||||||
|
|
||||||
import BackgroundUnsplashService from '@/services/backgroundUnsplash'
|
import BackgroundUnsplashService from '@/services/backgroundUnsplash'
|
||||||
@ -146,7 +145,6 @@ const debounceNewBackgroundSearch = debounce(newBackgroundSearch, SEARCH_DEBOUNC
|
|||||||
const backgroundUploadService = ref(new BackgroundUploadService())
|
const backgroundUploadService = ref(new BackgroundUploadService())
|
||||||
const projectService = ref(new ProjectService())
|
const projectService = ref(new ProjectService())
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const namespaceStore = useNamespaceStore()
|
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
const unsplashBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('unsplash'))
|
const unsplashBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('unsplash'))
|
||||||
@ -195,7 +193,6 @@ async function setBackground(backgroundId: string) {
|
|||||||
projectId: route.params.projectId,
|
projectId: route.params.projectId,
|
||||||
})
|
})
|
||||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||||
namespaceStore.setProjectInNamespaceById(project)
|
|
||||||
projectStore.setProject(project)
|
projectStore.setProject(project)
|
||||||
success({message: t('project.background.success')})
|
success({message: t('project.background.success')})
|
||||||
}
|
}
|
||||||
@ -211,7 +208,6 @@ async function uploadBackground() {
|
|||||||
backgroundUploadInput.value?.files[0],
|
backgroundUploadInput.value?.files[0],
|
||||||
)
|
)
|
||||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||||
namespaceStore.setProjectInNamespaceById(project)
|
|
||||||
projectStore.setProject(project)
|
projectStore.setProject(project)
|
||||||
success({message: t('project.background.success')})
|
success({message: t('project.background.success')})
|
||||||
}
|
}
|
||||||
@ -219,7 +215,6 @@ async function uploadBackground() {
|
|||||||
async function removeBackground() {
|
async function removeBackground() {
|
||||||
const project = await projectService.value.removeBackground(currentProject.value)
|
const project = await projectService.value.removeBackground(currentProject.value)
|
||||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||||
namespaceStore.setProjectInNamespaceById(project)
|
|
||||||
projectStore.setProject(project)
|
projectStore.setProject(project)
|
||||||
success({message: t('project.background.removeSuccess')})
|
success({message: t('project.background.removeSuccess')})
|
||||||
router.back()
|
router.back()
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
totalTasks > 0 ? $t('project.delete.tasksToDelete', {count: totalTasks}) : $t('project.delete.noTasksToDelete')
|
totalTasks > 0 ? $t('project.delete.tasksToDelete', {count: totalTasks}) : $t('project.delete.noTasksToDelete')
|
||||||
}}
|
}}
|
||||||
</strong>
|
</strong>
|
||||||
<Loading v-else class="is-loading-small"/>
|
<Loading v-else class="is-loading-small" variant="default"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
@ -43,7 +43,7 @@ const router = useRouter()
|
|||||||
|
|
||||||
const totalTasks = ref<number | null>(null)
|
const totalTasks = ref<number | null>(null)
|
||||||
|
|
||||||
const project = computed(() => projectStore.getProjectById(route.params.projectId))
|
const project = computed(() => projectStore.projects[route.params.projectId])
|
||||||
|
|
||||||
watchEffect(
|
watchEffect(
|
||||||
() => {
|
() => {
|
||||||
|
@ -3,73 +3,46 @@
|
|||||||
:title="$t('project.duplicate.title')"
|
:title="$t('project.duplicate.title')"
|
||||||
primary-icon="paste"
|
primary-icon="paste"
|
||||||
:primary-label="$t('project.duplicate.label')"
|
:primary-label="$t('project.duplicate.label')"
|
||||||
@primary="duplicateProject"
|
@primary="duplicate"
|
||||||
:loading="projectDuplicateService.loading"
|
:loading="isLoading"
|
||||||
>
|
>
|
||||||
<p>{{ $t('project.duplicate.text') }}</p>
|
<p>{{ $t('project.duplicate.text') }}</p>
|
||||||
|
<project-search v-model="parentProject"/>
|
||||||
<Multiselect
|
|
||||||
:placeholder="$t('namespace.search')"
|
|
||||||
@search="findNamespaces"
|
|
||||||
:search-results="namespaces"
|
|
||||||
@select="selectNamespace"
|
|
||||||
label="title"
|
|
||||||
:search-delay="10"
|
|
||||||
/>
|
|
||||||
</create-edit>
|
</create-edit>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, shallowReactive} from 'vue'
|
import {ref, watch} from 'vue'
|
||||||
import {useRoute, useRouter} from 'vue-router'
|
import {useRoute} from 'vue-router'
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
import ProjectDuplicateService from '@/services/projectDuplicateService'
|
|
||||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||||
import Multiselect from '@/components/input/multiselect.vue'
|
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
|
||||||
|
|
||||||
import ProjectDuplicateModel from '@/models/projectDuplicateModel'
|
|
||||||
import type {INamespace} from '@/modelTypes/INamespace'
|
|
||||||
|
|
||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
import {useTitle} from '@/composables/useTitle'
|
import {useTitle} from '@/composables/useTitle'
|
||||||
import {useNamespaceSearch} from '@/composables/useNamespaceSearch'
|
import {useProject, useProjectStore} from '@/stores/projects'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
import {useNamespaceStore} from '@/stores/namespaces'
|
|
||||||
|
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
useTitle(() => t('project.duplicate.title'))
|
useTitle(() => t('project.duplicate.title'))
|
||||||
|
|
||||||
const {
|
|
||||||
namespaces,
|
|
||||||
findNamespaces,
|
|
||||||
} = useNamespaceSearch()
|
|
||||||
|
|
||||||
const selectedNamespace = ref<INamespace>()
|
|
||||||
|
|
||||||
function selectNamespace(namespace: INamespace) {
|
|
||||||
selectedNamespace.value = namespace
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const namespaceStore = useNamespaceStore()
|
|
||||||
|
|
||||||
const projectDuplicateService = shallowReactive(new ProjectDuplicateService())
|
const {project, isLoading, duplicateProject} = useProject(route.params.projectId)
|
||||||
|
|
||||||
async function duplicateProject() {
|
const parentProject = ref<IProject | null>(null)
|
||||||
const projectDuplicate = new ProjectDuplicateModel({
|
watch(
|
||||||
// FIXME: should be parameter
|
() => project.parentProjectId,
|
||||||
projectId: route.params.projectId,
|
parentProjectId => {
|
||||||
namespaceId: selectedNamespace.value?.id,
|
parentProject.value = projectStore.projects[parentProjectId]
|
||||||
})
|
},
|
||||||
|
{immediate: true},
|
||||||
|
)
|
||||||
|
|
||||||
const duplicate = await projectDuplicateService.create(projectDuplicate)
|
async function duplicate() {
|
||||||
|
await duplicateProject(parentProject.value.id)
|
||||||
namespaceStore.addProjectToNamespace(duplicate.project)
|
|
||||||
projectStore.setProject(duplicate.project)
|
|
||||||
success({message: t('project.duplicate.success')})
|
success({message: t('project.duplicate.success')})
|
||||||
router.push({name: 'project.index', params: {projectId: duplicate.project.id}})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -42,6 +42,12 @@
|
|||||||
v-model="project.identifier"/>
|
v-model="project.identifier"/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="field">
|
||||||
<label class="label" for="projectdescription">{{ $t('project.edit.description') }}</label>
|
<label class="label" for="projectdescription">{{ $t('project.edit.description') }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
@ -66,21 +72,23 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export default { name: 'project-setting-edit' }
|
export default {name: 'project-setting-edit'}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {PropType} from 'vue'
|
import {watch, ref, type PropType} from 'vue'
|
||||||
import {useRouter} from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
import Editor from '@/components/input/AsyncEditor'
|
import Editor from '@/components/input/AsyncEditor'
|
||||||
import ColorPicker from '@/components/input/ColorPicker.vue'
|
import ColorPicker from '@/components/input/ColorPicker.vue'
|
||||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||||
|
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
|
||||||
|
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useProject} from '@/stores/projects'
|
import {useProject} from '@/stores/projects'
|
||||||
|
|
||||||
import {useTitle} from '@/composables/useTitle'
|
import {useTitle} from '@/composables/useTitle'
|
||||||
@ -93,14 +101,27 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
|
||||||
const {project, save: saveProject, isLoading} = useProject(props.projectId)
|
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}) : '')
|
useTitle(() => project?.title ? t('project.edit.title', {project: project.title}) : '')
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
project.parentProjectId = parentProject.value?.id ?? project.parentProjectId
|
||||||
await saveProject()
|
await saveProject()
|
||||||
await useBaseStore().handleSetCurrentProject({project})
|
await useBaseStore().handleSetCurrentProject({project})
|
||||||
router.back()
|
router.back()
|
||||||
|
@ -13,11 +13,13 @@
|
|||||||
:can-write="canWrite"
|
:can-write="canWrite"
|
||||||
ref="heading"
|
ref="heading"
|
||||||
/>
|
/>
|
||||||
<h6 class="subtitle" v-if="parent && parent.namespace && parent.project">
|
<h6 class="subtitle" v-if="project?.id">
|
||||||
{{ getNamespaceTitle(parent.namespace) }} ›
|
<template v-for="p in projectStore.getAncestors(project)" :key="p.id">
|
||||||
<router-link :to="{ name: 'project.index', params: { projectId: parent.project.id } }">
|
<router-link :to="{ name: 'project.index', params: { projectId: p.id } }">
|
||||||
{{ getProjectTitle(parent.project) }}
|
{{ getProjectTitle(p) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<span class="has-text-grey-light" v-if="p.id !== project?.id"> > </span>
|
||||||
|
</template>
|
||||||
</h6>
|
</h6>
|
||||||
|
|
||||||
<checklist-summary :task="task"/>
|
<checklist-summary :task="task"/>
|
||||||
@ -448,7 +450,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 {useRouter, type RouteLocation} from 'vue-router'
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
import {unrefElement} from '@vueuse/core'
|
import {unrefElement} from '@vueuse/core'
|
||||||
@ -486,12 +488,10 @@ import TaskSubscription from '@/components/misc/subscription.vue'
|
|||||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||||
|
|
||||||
import {uploadFile} from '@/helpers/attachments'
|
import {uploadFile} from '@/helpers/attachments'
|
||||||
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
|
|
||||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||||
import {scrollIntoView} from '@/helpers/scrollIntoView'
|
import {scrollIntoView} from '@/helpers/scrollIntoView'
|
||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useNamespaceStore} from '@/stores/namespaces'
|
|
||||||
import {useAttachmentStore} from '@/stores/attachments'
|
import {useAttachmentStore} from '@/stores/attachments'
|
||||||
import {useTaskStore} from '@/stores/tasks'
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
import {useKanbanStore} from '@/stores/kanban'
|
import {useKanbanStore} from '@/stores/kanban'
|
||||||
@ -500,6 +500,7 @@ import {useTitle} from '@/composables/useTitle'
|
|||||||
|
|
||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
import type {Action as MessageAction} from '@/message'
|
import type {Action as MessageAction} from '@/message'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
taskId: {
|
taskId: {
|
||||||
@ -517,7 +518,7 @@ const router = useRouter()
|
|||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const namespaceStore = useNamespaceStore()
|
const projectStore = useProjectStore()
|
||||||
const attachmentStore = useAttachmentStore()
|
const attachmentStore = useAttachmentStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
const kanbanStore = useKanbanStore()
|
const kanbanStore = useKanbanStore()
|
||||||
@ -538,32 +539,13 @@ const visible = ref(false)
|
|||||||
|
|
||||||
const taskId = toRef(props, 'taskId')
|
const taskId = toRef(props, 'taskId')
|
||||||
|
|
||||||
const parent = computed(() => {
|
const project = computed(() => projectStore.projects[task.projectId])
|
||||||
if (!task.projectId) {
|
watchEffect(() => {
|
||||||
return {
|
baseStore.handleSetCurrentProject({
|
||||||
namespace: null,
|
project: project.value,
|
||||||
project: null,
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!namespaceStore.getProjectAndNamespaceById) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return namespaceStore.getProjectAndNamespaceById(task.projectId)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
|
||||||
parent,
|
|
||||||
(parent) => {
|
|
||||||
const parentProject = parent !== null ? parent.project : null
|
|
||||||
if (parentProject !== null) {
|
|
||||||
baseStore.handleSetCurrentProject({project: parentProject})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{immediate: true},
|
|
||||||
)
|
|
||||||
|
|
||||||
const canWrite = computed(() => (
|
const canWrite = computed(() => (
|
||||||
task.maxRight !== null &&
|
task.maxRight !== null &&
|
||||||
task.maxRight > RIGHTS.READ
|
task.maxRight > RIGHTS.READ
|
||||||
@ -772,10 +754,8 @@ async function changeProject(project: IProject) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleFavorite() {
|
async function toggleFavorite() {
|
||||||
task.isFavorite = !task.isFavorite
|
const newTask = await taskStore.toggleFavorite(task.value)
|
||||||
const newTask = await taskService.update(task)
|
|
||||||
Object.assign(task, newTask)
|
Object.assign(task, newTask)
|
||||||
await namespaceStore.loadNamespacesIfFavoritesDontExist()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setPriority(priority: Priority) {
|
async function setPriority(priority: Priority) {
|
||||||
|
@ -245,7 +245,7 @@ watch(
|
|||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const defaultProject = computed({
|
const defaultProject = computed({
|
||||||
get: () => projectStore.getProjectById(settings.value.defaultProjectId) || undefined,
|
get: () => projectStore.projects[settings.value.defaultProjectId],
|
||||||
set(l) {
|
set(l) {
|
||||||
settings.value.defaultProjectId = l ? l.id : DEFAULT_PROJECT_ID
|
settings.value.defaultProjectId = l ? l.id : DEFAULT_PROJECT_ID
|
||||||
},
|
},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user