feature/projects-all-the-way-down #3323
|
@ -54,6 +54,7 @@ ENV VIKUNJA_LOG_FORMAT main
|
|||
ENV VIKUNJA_API_URL /api/v1
|
||||
ENV VIKUNJA_SENTRY_ENABLED false
|
||||
ENV VIKUNJA_SENTRY_DSN https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480
|
||||
ENV VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED false
|
||||
|
||||
|
||||
COPY docker/injector.sh /docker-entrypoint.d/50-injector.sh
|
||||
COPY docker/ipv6-disable.sh /docker-entrypoint.d/60-ipv6-disable.sh
|
||||
|
|
|
@ -24,4 +24,5 @@ export default defineConfig({
|
|||
},
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
experimentalMemoryManagement: true,
|
||||
})
|
||||
|
|
|
@ -2,7 +2,6 @@ import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {UserProjectFactory} from '../../factories/users_project'
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
|
||||
|
@ -10,7 +9,6 @@ describe('Editor', () => {
|
|||
createFakeUserAndLogin()
|
||||
|
||||
beforeEach(() => {
|
||||
NamespaceFactory.create(1)
|
||||
ProjectFactory.create(1)
|
||||
BucketFactory.create(1)
|
||||
TaskFactory.truncate()
|
||||
|
|
|
@ -8,20 +8,20 @@ describe('The Menu', () => {
|
|||
})
|
||||
|
||||
it('Is visible by default on desktop', () => {
|
||||
cy.get('.namespace-container')
|
||||
cy.get('.menu-container')
|
||||
.should('have.class', 'is-active')
|
||||
})
|
||||
|
||||
it('Can be hidden on desktop', () => {
|
||||
cy.get('button.menu-show-button:visible')
|
||||
.click()
|
||||
cy.get('.namespace-container')
|
||||
cy.get('.menu-container')
|
||||
.should('not.have.class', 'is-active')
|
||||
})
|
||||
|
||||
it('Is hidden by default on mobile', () => {
|
||||
cy.viewport('iphone-8')
|
||||
cy.get('.namespace-container')
|
||||
cy.get('.menu-container')
|
||||
.should('not.have.class', 'is-active')
|
||||
})
|
||||
|
||||
|
@ -29,7 +29,7 @@ describe('The Menu', () => {
|
|||
cy.viewport('iphone-8')
|
||||
cy.get('button.menu-show-button:visible')
|
||||
.click()
|
||||
cy.get('.namespace-container')
|
||||
cy.get('.menu-container')
|
||||
.should('have.class', 'is-active')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,145 +0,0 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Many of the tests in this file still seem to make sense. They should probably be moved to projects now. Many of the tests in this file still seem to make sense. They should probably be moved to projects now.
konrad
commented
Good catch! I've added two tests from namespaces which we don't already test with projects. Good catch! I've added two tests from namespaces which we don't already test with projects.
|
||||
|
||||
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 {NamespaceFactory} from '../../factories/namespace'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
|
||||
export function createProjects() {
|
||||
NamespaceFactory.create(1)
|
||||
const projects = ProjectFactory.create(1, {
|
||||
title: 'First Project'
|
||||
})
|
||||
|
|
|
@ -8,37 +8,30 @@ describe('Project History', () => {
|
|||
prepareProjects()
|
||||
|
||||
it('should show a project history on the home page', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces')
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects*').as('loadProjectArray')
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Rename to Rename to `loadProjectArray` to make the vars better distinguishable.
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
|
||||
|
||||
const projects = ProjectFactory.create(6)
|
||||
|
||||
cy.visit('/')
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadProjectArray')
|
||||
cy.get('body')
|
||||
.should('not.contain', 'Last viewed')
|
||||
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
dpschen
commented
Loading all projects in between is not necessary anymore? Loading all projects in between is not necessary anymore?
konrad
commented
No, the check which project exists will happen when displaying the history. Saving happens in a watcher on the project id in the ProjectWrapper component and thus wont need all projects to be loaded yet. No, the check which project exists will happen when displaying the history. Saving happens in a watcher on the project id in the ProjectWrapper component and thus wont need all projects to be loaded yet.
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[1].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[2].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[3].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[4].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[5].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadProject')
|
||||
|
||||
// cy.visit('/')
|
||||
// cy.wait('@loadNamespaces')
|
||||
// Not using cy.visit here to work around the redirect issue fixed in #1337
|
||||
cy.get('nav.menu.top-menu a')
|
||||
.contains('Overview')
|
||||
|
|
|
@ -58,7 +58,6 @@ describe('Project View Project', () => {
|
|||
})
|
||||
const projects = ProjectFactory.create(2, {
|
||||
owner_id: '{increment}',
|
||||
namespace_id: '{increment}',
|
||||
})
|
||||
cy.visit(`/projects/${projects[1].id}/`)
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
|
||||
describe('Projects', () => {
|
||||
|
@ -10,23 +11,20 @@ describe('Projects', () => {
|
|||
prepareProjects((newProjects) => (projects = newProjects))
|
||||
|
||||
it('Should create a new project', () => {
|
||||
cy.visit('/')
|
||||
cy.get('.namespace-title .dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.namespace-title .dropdown .dropdown-item')
|
||||
.contains('New project')
|
||||
cy.visit('/projects')
|
||||
dpschen
commented
Should we add a test for projects created from the sidebar? Should we add a test for projects created from the sidebar?
konrad
commented
But you can't create a project from the sidebar? But you can't create a project from the sidebar?
dpschen
commented
it was possible in the contextmenu of namespaces it was possible in the contextmenu of namespaces
konrad
commented
Yes, but well namespaces are gone now. We could add an option to the project contextmenu? That won't allow to create new root level projects though. Yes, but well namespaces are gone now. We could add an option to the project contextmenu? That won't allow to create new root level projects though.
|
||||
cy.get('.project-header [data-cy=new-project]')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/projects/new/1')
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Was that a mistake originally? Was that a mistake originally?
konrad
commented
You mean the You mean the `/1`? That was the namespace where the project should be created.
dpschen
commented
I think it should now be the the optional parent project. I think it should now be the the optional parent project.
konrad
commented
TBH I wanted to not add these options when creating or updating a project for now. It is already possible to move a project "under" another, thus making it a child of the other. Like this: TBH I wanted to not add these options when creating or updating a project for now. It is already possible to move a project "under" another, thus making it a child of the other. Like this:
[vikunja-child-2023-03-28_16.37.45.webm](/attachments/b05a1bfa-5471-4555-b355-eec7057053cc)
dpschen
commented
The video is 'not found' The video is 'not found'
konrad
commented
Interesting, it works for me... Interesting, it works for me...
konrad
commented
Added another comment at the bottom Added another comment at the bottom
dpschen
commented
Seems like I also can't paste images to upload anymore Seems like I also can't paste images to upload anymore
dpschen
commented
Status Code of the video is Status Code of the video is `404`
|
||||
.should('contain', '/projects/new')
|
||||
cy.get('.card-header-title')
|
||||
.contains('New project')
|
||||
cy.get('input.input')
|
||||
cy.get('input[name=projectTitle]')
|
||||
.type('New Project')
|
||||
cy.get('.button')
|
||||
.contains('Create')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification', { timeout: 1000 }) // Waiting until the request to create the new project is done
|
||||
cy.get('.global-notification', {timeout: 1000}) // Waiting until the request to create the new project is done
|
||||
.should('contain', 'Success')
|
||||
cy.url()
|
||||
.should('contain', '/projects/')
|
||||
|
@ -56,9 +54,9 @@ describe('Projects', () => {
|
|||
cy.get('.project-title')
|
||||
.should('contain', 'First Project')
|
||||
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Edit')
|
||||
.click()
|
||||
cy.get('#title')
|
||||
|
@ -72,21 +70,21 @@ describe('Projects', () => {
|
|||
cy.get('.project-title')
|
||||
.should('contain', newProjectName)
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child')
|
||||
cy.get('.menu-container .menu-list li:first-child')
|
||||
.should('contain', newProjectName)
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.visit('/')
|
||||
cy.get('.card-content')
|
||||
cy.get('.project-grid')
|
||||
.should('contain', newProjectName)
|
||||
.should('not.contain', projects[0].title)
|
||||
})
|
||||
|
||||
it('Should remove a project', () => {
|
||||
it('Should remove a project when deleting it', () => {
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Delete')
|
||||
.click()
|
||||
cy.url()
|
||||
|
@ -97,15 +95,15 @@ describe('Projects', () => {
|
|||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
|
||||
cy.get('.menu-container .menu-list')
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.location('pathname')
|
||||
.should('equal', '/')
|
||||
})
|
||||
|
||||
|
||||
it('Should archive a project', () => {
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
|
||||
|
||||
cy.get('.project-title-dropdown')
|
||||
.click()
|
||||
cy.get('.project-title-dropdown .dropdown-menu .dropdown-item')
|
||||
|
@ -115,10 +113,59 @@ describe('Projects', () => {
|
|||
.should('contain.text', 'Archive this project')
|
||||
cy.get('.modal-content [data-cy=modalPrimary]')
|
||||
.click()
|
||||
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
|
||||
|
||||
cy.get('.menu-container .menu-list')
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.get('main.app-content')
|
||||
.should('contain.text', 'This project is archived. It is not possible to create new or edit tasks for it.')
|
||||
})
|
||||
|
||||
it('Should show all projects on the projects page', () => {
|
||||
const projects = ProjectFactory.create(10)
|
||||
|
||||
cy.visit('/projects')
|
||||
|
||||
projects.forEach(p => {
|
||||
cy.get('[data-cy="projects-list"]')
|
||||
.should('contain', p.title)
|
||||
})
|
||||
})
|
||||
|
||||
it('Should not show archived projects if the filter is not checked', () => {
|
||||
ProjectFactory.create(1, {
|
||||
id: 2,
|
||||
}, false)
|
||||
ProjectFactory.create(1, {
|
||||
id: 3,
|
||||
is_archived: true,
|
||||
}, false)
|
||||
|
||||
// Initial
|
||||
cy.visit('/projects')
|
||||
cy.get('.project-grid')
|
||||
.should('not.contain', 'Archived')
|
||||
|
||||
// Show archived
|
||||
cy.get('[data-cy="show-archived-check"] label 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 {seed} from '../../support/seed'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
import {updateUserSettings} from '../../support/updateUserSettings'
|
||||
|
||||
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
||||
NamespaceFactory.create(1)
|
||||
const project = ProjectFactory.create()[0]
|
||||
BucketFactory.create(1, {
|
||||
project_id: project.id,
|
||||
|
@ -137,8 +135,7 @@ describe('Home Page Task Overview', () => {
|
|||
cy.visit('/')
|
||||
|
||||
cy.get('.home.app-content .content')
|
||||
.should('contain.text', 'You can create a new project for your new tasks:')
|
||||
.should('contain.text', 'Or import your projects and tasks from other services into Vikunja:')
|
||||
.should('contain.text', 'Import your projects and tasks from other services into Vikunja:')
|
||||
})
|
||||
|
||||
it('Should not show the cta buttons for new project when there are tasks', () => {
|
||||
|
|
|
@ -4,7 +4,6 @@ import {TaskFactory} from '../../factories/task'
|
|||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskCommentFactory} from '../../factories/task_comment'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {UserProjectFactory} from '../../factories/users_project'
|
||||
import {TaskAssigneeFactory} from '../../factories/task_assignee'
|
||||
import {LabelFactory} from '../../factories/labels'
|
||||
|
@ -47,13 +46,11 @@ function uploadAttachmentAndVerify(taskId: number) {
|
|||
describe('Task', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
let namespaces
|
||||
let projects
|
||||
let buckets
|
||||
|
||||
beforeEach(() => {
|
||||
// UserFactory.create(1)
|
||||
namespaces = NamespaceFactory.create(1)
|
||||
projects = ProjectFactory.create(1)
|
||||
buckets = BucketFactory.create(1, {
|
||||
project_id: projects[0].id,
|
||||
|
@ -110,7 +107,7 @@ describe('Task', () => {
|
|||
cy.get('.tasks .task .favorite')
|
||||
.first()
|
||||
.click()
|
||||
cy.get('.menu.namespaces-lists')
|
||||
cy.get('.menu-container')
|
||||
.should('contain', 'Favorites')
|
||||
})
|
||||
|
||||
|
@ -133,7 +130,6 @@ describe('Task', () => {
|
|||
cy.get('.task-view h1.title.task-id')
|
||||
.should('contain', '#1')
|
||||
cy.get('.task-view h6.subtitle')
|
||||
.should('contain', namespaces[0].title)
|
||||
.should('contain', projects[0].title)
|
||||
cy.get('.task-view .details.content.description')
|
||||
.should('contain', tasks[0].description)
|
||||
|
@ -260,7 +256,6 @@ describe('Task', () => {
|
|||
.click()
|
||||
|
||||
cy.get('.task-view h6.subtitle')
|
||||
.should('contain', namespaces[0].title)
|
||||
.should('contain', projects[1].title)
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
|
|
|
@ -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}',
|
||||
title: faker.lorem.words(3),
|
||||
owner_id: 1,
|
||||
namespace_id: 1,
|
||||
created: 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.SENTRY_ENABLED\s*=)\s*.+:\1 ${VIKUNJA_SENTRY_ENABLED}:g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.SENTRY_DSN\s*=)\s*.+:\1 '${VIKUNJA_SENTRY_DSN}':g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.PROJECT_INFINITE_NESTING_ENABLED\s*=)\s*.+:\1 '${VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED}':g" /usr/share/nginx/html/index.html
|
||||
|
||||
date -uIseconds | xargs echo 'info: started at'
|
||||
|
|
|
@ -27,6 +27,9 @@
|
|||
// our sentry instance to notify us of potential problems.
|
||||
window.SENTRY_ENABLED = false
|
||||
window.SENTRY_DSN = 'https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480'
|
||||
// If enabled, allows the user to nest projects infinitely, instead of the default 2 levels.
|
||||
// This setting might change in the future or be removed completely.
|
||||
window.PROJECT_INFINITE_NESTING_ENABLED = false
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
107
src/components/home/ProjectsNavigation.vue
Normal file
|
@ -0,0 +1,107 @@
|
|||
<template>
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
This component is basically a redo of a huge part of what I did with https://kolaente.dev/vikunja/frontend/pulls/2108/files This component is basically a redo of a huge part of what I did with https://kolaente.dev/vikunja/frontend/pulls/2108/files
konrad
commented
Looks like it. Do you want to continue that PR? Given how it is already old and now even more outdated. Looks like it. Do you want to continue that PR? Given how it is already old and now even more outdated.
dpschen
commented
I worked many hours to untangle the CSS there. I hope that we can save that effort somehow. I worked many hours to untangle the CSS there. I hope that we can save that effort somehow.
konrad
commented
From a quick glance over it, it seems like a big part of that was the namespace title styles - which are now gone (since namespaces are gone). From a quick glance over it, it seems like a big part of that was the namespace title styles - which are now gone (since namespaces are gone).
dpschen
commented
Probably the naming was bad. If I remember correctly the NavigationNamespace component was compatible with projects and namespaces. Not recursive though. Will check at some point how to recover good stuff. Probably the naming was bad. If I remember correctly the NavigationNamespace component was compatible with projects and namespaces. Not recursive though. Will check at some point how to recover good stuff.
|
||||
<draggable
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
For a new component: can we use the sortable from vueuse? For a new component: can we use the sortable from vueuse?
konrad
commented
Didn't know about that one, will check it out. Would the goal here be to eventually use it everywhere instead of vuedraggable? Didn't know about that one, will check it out.
Would the goal here be to eventually use it everywhere instead of vuedraggable?
konrad
commented
It looks like this is only available from vueuse 10 (we're on 9) which is not yet released as stable. I think we should wait until that's released and then move everything over. It looks like this is only available from vueuse 10 (we're on 9) which is not yet released as stable. I think we should wait until that's released and then move everything over.
|
||||
v-model="availableProjects"
|
||||
animation="100"
|
||||
ghostClass="ghost"
|
||||
group="projects"
|
||||
@start="() => drag = true"
|
||||
@end="saveProjectPosition"
|
||||
handle=".handle"
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Use Use [`<menu>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/menu) (now I remembered the correct element).
konrad
commented
Done Done
|
||||
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 }
|
||||
]
|
||||
}"
|
||||
>
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Use long var name. I thought for a bit that for Use long var name. I thought for a bit that for `<draggable>` you can define the type of dom node of the child item via `element`.
konrad
commented
Done. Done.
|
||||
<template #item="{element: project}">
|
||||
<ProjectsNavigationItem
|
||||
:project="project"
|
||||
:is-loading="projectUpdating[project.id]"
|
||||
:can-collapse="canCollapse"
|
||||
:level="level"
|
||||
dpschen
commented
Since this block doesn't have a headline it shouldn't be a Since this block doesn't have a headline it shouldn't be a `<section>`. Maybe use `<nav>` instead (nesting is allowed!)
dpschen
commented
Move this whole block in a new Move this whole block in a new `ProjectNavigationItem.vue` component. Reduces also the whole complexity with `childProjects[p.id]` because we can pass only the project.
konrad
commented
Done > Move this whole block in a new ProjectNavigationItem.vue component. Reduces also the whole complexity with childProjects[p.id] because we can pass only the project.
Done
konrad
commented
Shouldn't a Shouldn't a `nav` hold multiple navigation items?
dpschen
commented
Yes! Sry I misread the position, where the
Okay you moved now only the item without the list below inside.
This whole block can then be simplified:
Because we can save the collapsed state inside each item we don't need to manage a list anymore.
> Shouldn't a `nav` hold multiple navigation items?
Yes! Sry I misread the position, where the `<section>` is.
> Done
Okay you moved now only the item without the list below inside.
What i meant was:
- Move the complete `<li>` inside `ProjectsNavigationItem.vue`.
- `ProjectsNavigation.vue` is then used inside ProjectsNavigationItem
This whole block can then be simplified:
```ts
const collapsedProjects = ref<{ [id: IProject['id']]: boolean }>({})
const availableProjects = ref<IProject[]>([])
const childProjects = ref<{ [id: IProject['id']]: boolean }>({})
watch(
() => props.modelValue,
projects => {
availableProjects.value = projects || []
projects?.forEach(p => {
collapsedProjects.value[p.id] = false
childProjects.value[p.id] = projectStore.getChildProjects(p.id)
.sort((a, b) => a.position - b.position)
})
},
{immediate: true},
)
```
Because we can save the collapsed state inside each item we don't need to manage a list anymore.
```ts
const childProjectsOpen = ref(true)
// if getChildProjects returns the list sorted by position by default we wouldn't even need this computed
const childProjects = computed(() => {
const projects = projectStore.getChildProjects(p.id)
return projects.sort((a, b) => a.position - b.position)
})
```
dpschen
commented
So we don't even need the It's totally fine to not group the buttons etc because they are already grouped by the So we don't even need the `<section>` then and can instead use the `<li>`.
It's totally fine to not group the buttons etc because they _are_ already grouped by the `<li>` they are in. The `ProjectsNavigation` component would be the last child insie `ProjectsNavigationItem`
konrad
commented
That makes sense. I've moved most of the logic over, as you suggested.
We actually need this (or another element) because the That makes sense. I've moved most of the logic over, as you suggested.
> So we don't even need the `<section>` then and can instead use the `<li>`.
We actually need this (or another element) because the `section` is a flexbox container for the project title and related buttons. We can't use the `li` as the flexbox container because the ProjectsNavigation for the child projects needs to stay below the project title etc. If it was in the same flexbox container it would get pushed to the right.
|
||||
: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,
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Add types for emit Add types for emit
konrad
commented
Done Done
|
||||
canCollapse?: boolean,
|
||||
level?: number,
|
||||
}>()
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
These options should either contain all dragOptions or be defined inline These options should either contain all dragOptions or be defined inline
konrad
commented
Moved it all inline. Moved it all inline.
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', projects: IProject[]): void
|
||||
}>()
|
||||
|
||||
const drag = ref(false)
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
dpschen
commented
Is this even necessary if we use Is this even necessary if we use `modelValue` instead of `v-model` for the draggable?
konrad
commented
`v-model` is required, using `modelValue` for the draggable component does not work.
|
||||
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Didn't we just remove this condition, so that also one can also adjust settings of favorited lists? Didn't we just remove this condition, so that also one can also adjust settings of favorited lists?
konrad
commented
The condition is already in main: https://kolaente.dev/vikunja/frontend/src/branch/main/src/components/home/navigation.vue#L132 But editing favorites works just fine. This is about every project which actually exists, so no shared filters for example. The condition is already in main: https://kolaente.dev/vikunja/frontend/src/branch/main/src/components/home/navigation.vue#L132
But editing favorites works just fine. This is about every project which actually exists, so no shared filters for example.
dpschen
commented
My bad I confused favorite projects with the 'Favorite' project. My bad I confused favorite projects with the 'Favorite' project.
|
||||
// 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},
|
||||
dpschen marked this conversation as resolved
Outdated
|
||||
)
|
||||
dpschen
commented
Also don't render if there are no child projects. Also don't render if there are no child projects.
konrad
commented
But then it won't be possible to drag a project "under" a parent to make it a child of that parent. But then it won't be possible to drag a project "under" a parent to make it a child of that parent.
|
||||
|
||||
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({
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Should have a default value. When is Should have a default value.
When is `allowDrag === true`? In case this is related to edit rights we should align the variable names.
dpschen
commented
Just saw when it's true. Can we rename to the concrete action? Because the order might also be changed by something else than dragging in the future. How about Just saw when it's true. Can we rename to the concrete action? Because the order might also be changed by something else than dragging in the future. How about `canEditOrder`?
konrad
commented
I think that's a good idea. Renamed it. > Can we rename to the concrete action? Because the order might also be changed by something else than dragging in the future. How about canEditOrder?
I think that's a good idea. Renamed it.
|
||||
...project,
|
||||
dpschen
commented
Danger! This should be handled in the store! Probably it would be best to create a new store method. Something like Danger! This should be handled in the store!
Inline editing of parent project!
Probably it would be best to create a new store method. Something like `setOrder` or `changeOrder`.
konrad
commented
I was able to move the whole thing into the I was able to move the whole thing into the `updateProject` method of the store.
|
||||
position,
|
||||
parentProjectId,
|
||||
})
|
||||
emit('update:modelValue', availableProjects.value)
|
||||
} finally {
|
||||
projectUpdating.value[project.id] = false
|
||||
}
|
||||
}
|
||||
</script>
|
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}"
|
||||
>
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Set from outside, since this id is related to the sorting. Set from outside, since this id is related to the sorting.
konrad
commented
Done Done
|
||||
<div>
|
||||
<BaseButton
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Replace section with We'll add correct semantics here later (e.g. https://www.w3.org/WAI/ARIA/apg/patterns/menubar/examples/menubar-navigation/). Replace section with `<div>`.
We'll add correct semantics here later (e.g. https://www.w3.org/WAI/ARIA/apg/patterns/menubar/examples/menubar-navigation/). `<section>` is not correct though, since there is no headline.
konrad
commented
Done Done
|
||||
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"
|
||||
dpschen
commented
Fix indention Fix indention
dpschen
commented
Pass 'handle class name from parent => separate concerns / source of truth Pass 'handle class name from parent => separate concerns / source of truth
konrad
commented
But the indention is correct? But the indention is correct?
konrad
commented
Can you explain that a little more? > Pass 'handle class name from parent => separate concerns / source of truth
Can you explain that a little more?
dpschen
commented
The The `handle` selector is used in the child. Currently we define it in the parent. We should pass this information down to the child. Might also be via creating a slot in the child where we put in the handle.
|
||||
></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"
|
||||
dpschen
commented
Use Use `Expandable` component for this.
konrad
commented
This seems to completly break the styling. I changed it to match the selectors but it still does not work. Not sure what to do about this.- This seems to completly break the styling. I changed it to match the selectors but it still does not work. Not sure what to do about this.-
dpschen
commented
I created an example how to use this in I created an example how to use this in https://kolaente.dev/vikunja/frontend/commit/51e29af010defc5f6c46f85dbb7311904a7d40e1. I was unsure which parts parts should be dynamically be filled (via the `open` prop) or static (not rendering the `Expandable` at all via `v-if`).
|
||||
>
|
||||
<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'
|
||||
dpschen
commented
Define default types or handle undefined defaults. E.g. Define default types or handle undefined defaults. E.g. `level` might be `undefined`.
Could it be that ts doesn't display errors in the editor for you?
konrad
commented
Added.
Looks like it 🤔 Added.
> Could it be that ts doesn't display errors in the editor for you?
Looks like it 🤔
|
||||
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)
|
||||
dpschen
commented
Simplify:
Simplify:
```ts
const canNestDeeper = computed(() => props.level >= 2 && window.PROJECT_INFINITE_NESTING_ENABLED)
```
konrad
commented
But that would return But that would return `false` for the first two levels?
|
||||
|
||||
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" />
|
||||
|
||||
<div v-if="currentProject.id" class="project-title-wrapper">
|
||||
<div v-if="currentProject?.id" class="project-title-wrapper">
|
||||
<h1 class="project-title">{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
|
||||
</h1>
|
||||
|
||||
|
@ -89,7 +89,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||
const baseStore = useBaseStore()
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
const background = computed(() => baseStore.background)
|
||||
const canWriteCurrentProject = computed(() => baseStore.currentProject.maxRight > Rights.READ)
|
||||
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxRight > Rights.READ)
|
||||
const menuActive = computed(() => baseStore.menuActive)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
|
|
@ -69,6 +69,7 @@ import BaseButton from '@/components/base/BaseButton.vue'
|
|||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
import {useRouteWithModal} from '@/composables/useRouteWithModal'
|
||||
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
|
||||
|
@ -94,14 +95,13 @@ watch(() => route.name as string, (routeName) => {
|
|||
(
|
||||
[
|
||||
'home',
|
||||
'namespace.edit',
|
||||
'teams.index',
|
||||
'teams.edit',
|
||||
'tasks.range',
|
||||
'labels.index',
|
||||
'migrate.start',
|
||||
'migrate.wunderlist',
|
||||
'namespaces.index',
|
||||
'projects.index',
|
||||
].includes(routeName) ||
|
||||
routeName.startsWith('user.settings')
|
||||
)
|
||||
|
@ -116,6 +116,9 @@ useRenewTokenOnFocus()
|
|||
|
||||
const labelStore = useLabelStore()
|
||||
labelStore.loadAllLabels()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
projectStore.loadProjects()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -9,9 +9,9 @@
|
|||
<Logo class="logo" v-if="logoVisible"/>
|
||||
<h1
|
||||
:class="{'m-0': !logoVisible}"
|
||||
:style="{ 'opacity': currentProject.title === '' ? '0': '1' }"
|
||||
:style="{ 'opacity': currentProject?.title === '' ? '0': '1' }"
|
||||
class="title">
|
||||
{{ currentProject.title === '' ? $t('misc.loading') : currentProject.title }}
|
||||
{{ currentProject?.title === '' ? $t('misc.loading') : currentProject?.title }}
|
||||
</h1>
|
||||
<div class="box has-text-left view">
|
||||
<router-view/>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<template>
|
||||
<aside :class="{'is-active': menuActive}" class="namespace-container">
|
||||
<aside :class="{'is-active': baseStore.menuActive}" class="menu-container">
|
||||
<nav class="menu top-menu">
|
||||
<router-link :to="{name: 'home'}" class="logo">
|
||||
<Logo width="164" height="48"/>
|
||||
</router-link>
|
||||
<ul class="menu-list">
|
||||
<menu class="menu-list other-menu-items">
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Something that I don't know that long myself: adding a Something that I don't know that long myself: adding a `<ul>` inside a `<nav>` doesn't give added benefit if we hide the `list-item`, because there are no added semantics. So listing the links is ok.
konrad
commented
So we could get rid of the So we could get rid of the `ul` entierly and only add the links?
dpschen
commented
That was my idea. But wait with doing this. Because when I wrote this I forgot that menu-list adds style the is structure dependant (might even be that the original class is coming from bulma). That was my idea. But wait with doing this. Because when I wrote this I forgot that menu-list adds style the is structure dependant (might even be that the original class is coming from bulma).
dpschen
commented
If Bulma doesn't require you to use If Bulma doesn't require you to use `<ul>` use `<menu>` instead.
dpschen
commented
`menu` still missing here. (you changed it in ProjectsNavigation).
konrad
commented
Done Done
|
||||
<li>
|
||||
<router-link :to="{ name: 'home'}" v-shortcut="'g o'">
|
||||
<span class="menu-item-icon icon">
|
||||
|
@ -22,11 +22,11 @@
|
|||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link :to="{ name: 'namespaces.index'}" v-shortcut="'g n'">
|
||||
<router-link :to="{ name: 'projects.index'}" v-shortcut="'g p'">
|
||||
<span class="menu-item-icon icon">
|
||||
<icon icon="layer-group"/>
|
||||
</span>
|
||||
{{ $t('namespace.title') }}
|
||||
{{ $t('project.projects') }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -45,238 +45,51 @@
|
|||
{{ $t('team.title') }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</menu>
|
||||
</nav>
|
||||
|
||||
<nav class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
|
||||
<template v-for="(n, nk) in namespaces" :key="n.id">
|
||||
<div class="namespace-title" :class="{'has-menu': n.id > 0}">
|
||||
<BaseButton
|
||||
@click="toggleProjects(n.id)"
|
||||
class="menu-label"
|
||||
v-tooltip="namespaceTitles[nk]"
|
||||
>
|
||||
<ColorBubble
|
||||
v-if="n.hexColor !== ''"
|
||||
:color="n.hexColor"
|
||||
class="mr-1"
|
||||
/>
|
||||
<span class="name">{{ namespaceTitles[nk] }}</span>
|
||||
<div
|
||||
class="icon menu-item-icon is-small toggle-lists-icon pl-2"
|
||||
:class="{'active': typeof projectsVisible[n.id] !== 'undefined' ? projectsVisible[n.id] : true}"
|
||||
>
|
||||
<icon icon="chevron-down"/>
|
||||
</div>
|
||||
<span class="count" :class="{'ml-2 mr-0': n.id > 0}">
|
||||
({{ namespaceProjectsCount[nk] }})
|
||||
</span>
|
||||
</BaseButton>
|
||||
<namespace-settings-dropdown class="menu-list-dropdown" :namespace="n" v-if="n.id > 0"/>
|
||||
</div>
|
||||
<!--
|
||||
NOTE: a v-model / computed setter is not possible, since the updateActiveProjects function
|
||||
triggered by the change needs to have access to the current namespace
|
||||
-->
|
||||
<draggable
|
||||
v-if="projectsVisible[n.id] ?? true"
|
||||
v-bind="dragOptions"
|
||||
:modelValue="activeProjects[nk]"
|
||||
@update:modelValue="(projects) => updateActiveProjects(n, projects)"
|
||||
group="namespace-lists"
|
||||
@start="() => drag = true"
|
||||
@end="saveListPosition"
|
||||
handle=".handle"
|
||||
:disabled="n.id < 0 || undefined"
|
||||
tag="ul"
|
||||
item-key="id"
|
||||
:data-namespace-id="n.id"
|
||||
:data-namespace-index="nk"
|
||||
:component-data="{
|
||||
type: 'transition-group',
|
||||
name: !drag ? 'flip-list' : null,
|
||||
class: [
|
||||
'menu-list can-be-hidden',
|
||||
{ 'dragging-disabled': n.id < 0 }
|
||||
]
|
||||
}"
|
||||
>
|
||||
<template #item="{element: l}">
|
||||
<li
|
||||
class="list-menu loader-container is-loading-small"
|
||||
:class="{'is-loading': projectUpdating[l.id]}"
|
||||
>
|
||||
<BaseButton
|
||||
:to="{ name: 'project.index', params: { projectId: l.id} }"
|
||||
class="list-menu-link"
|
||||
:class="{'router-link-exact-active': currentProject.id === l.id}"
|
||||
>
|
||||
<span class="icon menu-item-icon handle">
|
||||
<icon icon="grip-lines"/>
|
||||
</span>
|
||||
<ColorBubble
|
||||
v-if="l.hexColor !== ''"
|
||||
:color="l.hexColor"
|
||||
class="mr-1"
|
||||
/>
|
||||
<span class="list-menu-title">{{ getProjectTitle(l) }}</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="l.id > 0"
|
||||
class="favorite"
|
||||
:class="{'is-favorite': l.isFavorite}"
|
||||
@click="projectStore.toggleProjectFavorite(l)"
|
||||
>
|
||||
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
|
||||
</BaseButton>
|
||||
<ProjectSettingsDropdown class="menu-list-dropdown" :project="l" v-if="l.id > 0">
|
||||
<template #trigger="{toggleOpen}">
|
||||
<BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen">
|
||||
<icon icon="ellipsis-h" class="icon"/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
</ProjectSettingsDropdown>
|
||||
<span class="list-setting-spacer" v-else></span>
|
||||
</li>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
</nav>
|
||||
<Loading
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Move Move `v-if` to front
konrad
commented
Done Done
|
||||
v-if="projectStore.isLoading"
|
||||
variant="small"
|
||||
/>
|
||||
<template v-else>
|
||||
<nav class="menu" v-if="favoriteProjects">
|
||||
<ProjectsNavigation :model-value="favoriteProjects" :can-edit-order="false" :can-collapse="false"/>
|
||||
</nav>
|
||||
|
||||
<nav class="menu">
|
||||
<ProjectsNavigation
|
||||
:model-value="projects"
|
||||
:can-edit-order="true"
|
||||
:can-collapse="true"
|
||||
:level="1"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<PoweredByLink/>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onBeforeMount} from 'vue'
|
||||
import draggable from 'zhyswan-vuedraggable'
|
||||
import type {SortableEvent} from 'sortablejs'
|
||||
import {computed} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
|
||||
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
|
||||
import PoweredByLink from '@/components/home/PoweredByLink.vue'
|
||||
dpschen
commented
Can we put this component inside a Can we put this component inside a `<Suspense>`? Then we can use `await` methods directly and without `onBeforeMount ` hook.
konrad
commented
I've now moved the project navigation into a separate wrapper component so that we can show a loading spinner while projects are loading and still show the other navigation links (overview, labels, etc). I've now moved the project navigation into a separate wrapper component so that we can show a loading spinner while projects are loading and still show the other navigation links (overview, labels, etc).
dpschen
commented
Ok. Will check Ok. Will check
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
|
||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
import ColorBubble from '@/components/misc/colorBubble.vue'
|
||||
import Loading from '@/components/misc/loading.vue'
|
||||
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Remove both computed above and use store + property directly instead. Remove both computed above and use store + property directly instead.
konrad
commented
Done Done
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
This seems like something that the store should export instead as a computed. This seems like something that the store should export instead as a computed.
konrad
commented
That makes sense. That makes sense.
konrad
commented
Changed it. Changed it.
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
|
||||
const drag = ref(false)
|
||||
const dragOptions = {
|
||||
animation: 100,
|
||||
ghostClass: 'ghost',
|
||||
}
|
||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Simplify to
Simplify to
```ts
.sort((a, b) => a.position - b.position)
```
konrad
commented
Done. Done.
|
||||
const baseStore = useBaseStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
const menuActive = computed(() => baseStore.menuActive)
|
||||
const loading = computed(() => namespaceStore.isLoading)
|
||||
|
||||
|
||||
const namespaces = computed(() => {
|
||||
return namespaceStore.namespaces.filter(n => !n.isArchived)
|
||||
})
|
||||
const activeProjects = computed(() => {
|
||||
return namespaces.value.map(({projects}) => {
|
||||
return projects?.filter(item => {
|
||||
return typeof item !== 'undefined' && !item.isArchived
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const namespaceTitles = computed(() => {
|
||||
return namespaces.value.map((namespace) => getNamespaceTitle(namespace))
|
||||
})
|
||||
|
||||
const namespaceProjectsCount = computed(() => {
|
||||
return namespaces.value.map((_, index) => activeProjects.value[index]?.length ?? 0)
|
||||
})
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
function toggleProjects(namespaceId: INamespace['id']) {
|
||||
projectsVisible.value[namespaceId] = !projectsVisible.value[namespaceId]
|
||||
}
|
||||
|
||||
const projectsVisible = ref<{ [id: INamespace['id']]: boolean }>({})
|
||||
// FIXME: async action will be unfinished when component mounts
|
||||
onBeforeMount(async () => {
|
||||
const namespaces = await namespaceStore.loadNamespaces()
|
||||
namespaces.forEach(n => {
|
||||
if (typeof projectsVisible.value[n.id] === 'undefined') {
|
||||
projectsVisible.value[n.id] = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function updateActiveProjects(namespace: INamespace, activeProjects: IProject[]) {
|
||||
// This is a bit hacky: since we do have to filter out the archived items from the list
|
||||
// for vue draggable updating it is not as simple as replacing it.
|
||||
// To work around this, we merge the active projects with the archived ones. Doing so breaks the order
|
||||
// because now all archived projects are sorted after the active ones. This is fine because they are sorted
|
||||
// later when showing them anyway, and it makes the merging happening here a lot easier.
|
||||
const projects = [
|
||||
...activeProjects,
|
||||
...namespace.projects.filter(l => l.isArchived),
|
||||
]
|
||||
|
||||
namespaceStore.setNamespaceById({
|
||||
...namespace,
|
||||
projects,
|
||||
})
|
||||
}
|
||||
|
||||
const projectUpdating = ref<{ [id: INamespace['id']]: boolean }>({})
|
||||
|
||||
async function saveListPosition(e: SortableEvent) {
|
||||
if (!e.newIndex && e.newIndex !== 0) return
|
||||
|
||||
const namespaceId = parseInt(e.to.dataset.namespaceId as string)
|
||||
const newNamespaceIndex = parseInt(e.to.dataset.namespaceIndex as string)
|
||||
|
||||
const projectsActive = activeProjects.value[newNamespaceIndex]
|
||||
// If the project was dragged to the last position, Safari will report e.newIndex as the size of the projectsActive
|
||||
// array instead of using the position. Because the index is wrong in that case, dragging the project will fail.
|
||||
// To work around that we're explicitly checking that case here and decrease the index.
|
||||
const newIndex = e.newIndex === projectsActive.length ? e.newIndex - 1 : e.newIndex
|
||||
|
||||
const project = projectsActive[newIndex]
|
||||
const projectBefore = projectsActive[newIndex - 1] ?? null
|
||||
const projectAfter = projectsActive[newIndex + 1] ?? null
|
||||
projectUpdating.value[project.id] = true
|
||||
|
||||
const position = calculateItemPosition(
|
||||
projectBefore !== null ? projectBefore.position : null,
|
||||
projectAfter !== null ? projectAfter.position : null,
|
||||
)
|
||||
|
||||
try {
|
||||
// create a copy of the project in order to not violate pinia manipulation
|
||||
await projectStore.updateProject({
|
||||
...project,
|
||||
position,
|
||||
namespaceId,
|
||||
})
|
||||
} finally {
|
||||
projectUpdating.value[project.id] = false
|
||||
}
|
||||
}
|
||||
const projects = computed(() => projectStore.notArchivedRootProjects)
|
||||
dpschen
commented
This sorts the returned computed of the store Error in vue developer tools:
This sorts the returned computed of the store
=> sort already in store OR create copy (worse performance)
Error in vue developer tools:
> [Vue warn] Set operation on key "0" failed: target is readonly. [...]
konrad
commented
Now sorting in store, that seems to work (or at least there are no errors now) Now sorting in store, that seems to work (or at least there are no errors now)
|
||||
const favoriteProjects = computed(() => projectStore.favoriteProjects)
|
||||
</script>
|
||||
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Simplify to
Simplify to
```ts
.sort((a, b) => a.position - b.position)
```
konrad
commented
Done. Done.
dpschen
commented
This sorts the returned computed of the store Error in vue developer tools:
This sorts the returned computed of the store
=> sort already in store OR create copy (worse performance)
Error in vue developer tools:
> [Vue warn] Set operation on key "0" failed: target is readonly. [...]
konrad
commented
Fixed as well. Fixed as well.
|
||||
<style lang="scss" scoped>
|
||||
$navbar-padding: 2rem;
|
||||
$vikunja-nav-background: var(--site-background);
|
||||
$vikunja-nav-color: var(--grey-700);
|
||||
$vikunja-nav-selected-width: 0.4rem;
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
|
||||
|
@ -289,8 +102,8 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
}
|
||||
}
|
||||
|
||||
.namespace-container {
|
||||
background: $vikunja-nav-background;
|
||||
.menu-container {
|
||||
background: var(--site-background);
|
||||
color: $vikunja-nav-color;
|
||||
padding: 0 0 1rem;
|
||||
transition: transform $transition-duration ease-in;
|
||||
|
@ -301,6 +114,7 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
transform: translateX(-100%);
|
||||
overflow-x: auto;
|
||||
width: $navbar-width;
|
||||
dpschen
commented
Remove outer margin set from inside the component and add it from outside instead. Remove outer margin set from inside the component and add it from outside instead.
|
||||
margin-top: 1rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
top: 0;
|
||||
|
@ -314,252 +128,24 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
}
|
||||
}
|
||||
|
||||
// these are general menu styles
|
||||
// should be in own components
|
||||
.menu {
|
||||
.menu-label,
|
||||
.menu-list .list-menu-link,
|
||||
.menu-list a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
|
||||
.color-bubble {
|
||||
height: 12px;
|
||||
flex: 0 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
li {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.menu-list-dropdown {
|
||||
opacity: 1;
|
||||
transition: $transition;
|
||||
}
|
||||
|
||||
@media(hover: hover) and (pointer: fine) {
|
||||
.menu-list-dropdown {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .menu-list-dropdown {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.menu-item-icon {
|
||||
color: var(--grey-400);
|
||||
}
|
||||
|
||||
.menu-list-dropdown-trigger {
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.flip-list-move {
|
||||
transition: transform $transition-duration;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: var(--grey-200);
|
||||
|
||||
* {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.list-menu-link,
|
||||
li > a {
|
||||
color: $vikunja-nav-color;
|
||||
padding: 0.75rem .5rem 0.75rem ($navbar-padding * 1.5 - 1.75rem);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
border-radius: 0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border-left: $vikunja-nav-selected-width solid transparent;
|
||||
|
||||
&:hover {
|
||||
border-left: $vikunja-nav-selected-width solid var(--primary);
|
||||
}
|
||||
|
||||
&.router-link-exact-active {
|
||||
color: var(--primary);
|
||||
border-left: $vikunja-nav-selected-width solid var(--primary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 1rem;
|
||||
vertical-align: middle;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
&.router-link-exact-active .icon:not(.handle) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.handle {
|
||||
opacity: 0;
|
||||
transition: opacity $transition;
|
||||
margin-right: .25rem;
|
||||
}
|
||||
|
||||
&:hover .handle {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&:not(.dragging-disabled) .handle {
|
||||
cursor: grab;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-menu {
|
||||
margin-top: math.div($navbar-padding, 2);
|
||||
|
||||
.menu-list {
|
||||
li {
|
||||
font-weight: 600;
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
.list-menu-link,
|
||||
li > a {
|
||||
padding-left: 2rem;
|
||||
display: inline-block;
|
||||
|
||||
.icon {
|
||||
padding-bottom: .25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.namespaces-lists {
|
||||
padding-top: math.div($navbar-padding, 2);
|
||||
|
||||
.menu-label {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
font-family: $vikunja-font;
|
||||
color: $vikunja-nav-color;
|
||||
.top-menu .menu-list {
|
||||
li {
|
||||
font-weight: 600;
|
||||
min-height: 2.5rem;
|
||||
padding-top: 0;
|
||||
padding-left: $navbar-padding;
|
||||
|
||||
overflow: hidden;
|
||||
margin-bottom: 0;
|
||||
flex: 1 1 auto;
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--grey-500);
|
||||
margin-right: .5rem;
|
||||
// align brackets with number
|
||||
font-feature-settings: "case";
|
||||
}
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
This class doesn't isn't used in the template This class doesn't isn't used in the template
konrad
commented
I've renamed it - it is coming from the I've renamed it - it is coming from the `Loader` component.
|
||||
.favorite {
|
||||
margin-left: .25rem;
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 1;
|
||||
.list-menu-link,
|
||||
li > a {
|
||||
padding-left: 2rem;
|
||||
display: inline-block;
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
This changes styles inside the component. If the styles need to be adjusted the component should be changed instead. This changes styles inside the component. If the styles need to be adjusted the component should be changed instead.
konrad
commented
The problem is the component is used in multiple places where this would need different sizes. The way to do this would probably be variants with a prop? The problem is the component is used in multiple places where this would need different sizes. The way to do this would probably be variants with a prop?
dpschen
commented
Correct! The question to ask is also: do we even need so many different sizes or shouldn't they be more unied. For now I guess it's enough to answer this quesion for this usecase here. Correct! The question to ask is also: do we even need so many different sizes or shouldn't they be more unied. For now I guess it's enough to answer this quesion for this usecase here.
konrad
commented
I've now added this as a variant to the component. In doing this I discovered there are more styles and uses of that loader which we should refactor at some point. I would consider that out of scope for this PR though. I've now added this as a variant to the component. In doing this I discovered there are more styles and uses of that loader which we should refactor at some point. I would consider that out of scope for this PR though.
|
||||
|
||||
&.is-favorite {
|
||||
color: var(--warning);
|
||||
opacity: 1;
|
||||
.icon {
|
||||
padding-bottom: .25rem;
|
||||
dpschen marked this conversation as resolved
Outdated
konrad
commented
Done Done
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media(hover: hover) and (pointer: fine) {
|
||||
.list-menu .favorite {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.list-menu:hover .favorite,
|
||||
.favorite.is-favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.list-menu-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.color-bubble {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.is-archived {
|
||||
min-width: 85px;
|
||||
}
|
||||
}
|
||||
|
||||
.namespace-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: $vikunja-nav-color;
|
||||
padding: 0 .25rem;
|
||||
|
||||
.toggle-lists-icon {
|
||||
svg {
|
||||
transition: all $transition;
|
||||
transform: rotate(90deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.active svg {
|
||||
transform: rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .toggle-lists-icon svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:not(.has-menu) .toggle-lists-icon {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-setting-spacer {
|
||||
width: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.namespaces-list.loader-container.is-loading {
|
||||
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem + 1.5rem});
|
||||
.menu + .menu {
|
||||
padding-top: math.div($navbar-padding, 2);
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Unsure if this was this line, but: The space between the logo and the main sidebar nav items got reduced. The indention of the text (including icons) as well. Text indention is fine (because the icons seem to align with logo), vertical spacing is not! Unsure if this was this line, but: The space between the logo and the main sidebar nav items got reduced. The indention of the text (including icons) as well. Text indention is fine (because the icons seem to align with logo), vertical spacing is not!
konrad
commented
Re-added the space to the logo Re-added the space to the logo
|
||||
}
|
||||
</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',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.navigation.namespaces',
|
||||
keys: ['g', 'n'],
|
||||
title: 'keyboardShortcuts.navigation.projects',
|
||||
keys: ['g', 'p'],
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Should we keep the old shortcut and deprecate it as long as it's not used by something else? Should we keep the old shortcut and deprecate it as long as it's not used by something else?
konrad
commented
I think it's fine to "free" it up until its used for something else without explicit deprecation. I think it's fine to "free" it up until its used for something else without explicit deprecation.
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
|
|
|
@ -1,13 +1,21 @@
|
|||
<template>
|
||||
<div class="loader-container is-loading"></div>
|
||||
<div class="loader-container is-loading" :class="{'is-small': variant === 'small'}"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: true,
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const {
|
||||
variant = 'default',
|
||||
} = defineProps<{
|
||||
variant: 'default' | 'small'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.loader-container {
|
||||
height: 100%;
|
||||
|
@ -20,5 +28,18 @@ export default {
|
|||
min-height: 50px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
&.is-small {
|
||||
min-width: 100%;
|
||||
height: 150px;
|
||||
|
||||
&.is-loading::after {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
top: calc(50% - 1.5rem);
|
||||
left: calc(50% - 1.5rem);
|
||||
border-width: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -47,7 +47,7 @@ import {success} from '@/message'
|
|||
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
const props = defineProps({
|
||||
entity: String,
|
||||
entity: String as ISubscription['entity'],
|
||||
entityId: Number,
|
||||
isButton: {
|
||||
type: Boolean,
|
||||
|
@ -73,12 +73,6 @@ const {t} = useI18n({useScope: 'global'})
|
|||
|
||||
const tooltipText = computed(() => {
|
||||
if (disabled.value) {
|
||||
if (props.entity === 'project' && subscriptionEntity.value === 'namespace') {
|
||||
return t('task.subscription.subscribedProjectThroughParentNamespace')
|
||||
}
|
||||
if (props.entity === 'task' && subscriptionEntity.value === 'namespace') {
|
||||
return t('task.subscription.subscribedTaskThroughParentNamespace')
|
||||
}
|
||||
if (props.entity === 'task' && subscriptionEntity.value === 'project') {
|
||||
return t('task.subscription.subscribedTaskThroughParentProject')
|
||||
}
|
||||
|
@ -87,10 +81,6 @@ const tooltipText = computed(() => {
|
|||
}
|
||||
|
||||
switch (props.entity) {
|
||||
case 'namespace':
|
||||
return props.modelValue !== null ?
|
||||
t('task.subscription.subscribedNamespace') :
|
||||
t('task.subscription.notSubscribedNamespace')
|
||||
case 'project':
|
||||
return props.modelValue !== null ?
|
||||
t('task.subscription.subscribedProject') :
|
||||
|
@ -130,9 +120,6 @@ async function subscribe() {
|
|||
|
||||
let message = ''
|
||||
switch (props.entity) {
|
||||
case 'namespace':
|
||||
message = t('task.subscription.subscribeSuccessNamespace')
|
||||
break
|
||||
case 'project':
|
||||
message = t('task.subscription.subscribeSuccessProject')
|
||||
break
|
||||
|
@ -153,9 +140,6 @@ async function unsubscribe() {
|
|||
|
||||
let message = ''
|
||||
switch (props.entity) {
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Add type to props definition: Should be Add type to props definition: Should be `ISubscription['entity']`
konrad
commented
Done. Done.
|
||||
case 'namespace':
|
||||
message = t('task.subscription.unsubscribeSuccessNamespace')
|
||||
break
|
||||
case 'project':
|
||||
message = t('task.subscription.unsubscribeSuccessProject')
|
||||
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>
|
||||
<div
|
||||
:class="{ 'is-loading': projectService.loading, 'is-archived': currentProject.isArchived}"
|
||||
:class="{ 'is-loading': projectService.loading, 'is-archived': currentProject?.isArchived}"
|
||||
class="loader-container"
|
||||
>
|
||||
<div class="switch-view-container">
|
||||
|
@ -45,8 +45,8 @@
|
|||
<slot name="header" />
|
||||
</div>
|
||||
<CustomTransition name="fade">
|
||||
<Message variant="warning" v-if="currentProject.isArchived" class="mb-4">
|
||||
{{ $t('project.archived') }}
|
||||
<Message variant="warning" v-if="currentProject?.isArchived" class="mb-4">
|
||||
{{ $t('project.archivedMessage') }}
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
How about How about `archivedMessage` or `archivedWarning`
konrad
commented
I like I like `archivedMessage`, renamed it.
|
||||
</Message>
|
||||
</CustomTransition>
|
||||
|
||||
|
@ -98,7 +98,7 @@ const currentProject = computed(() => {
|
|||
maxRight: null,
|
||||
} : baseStore.currentProject
|
||||
})
|
||||
useTitle(() => currentProject.value.id ? getProjectTitle(currentProject.value) : '')
|
||||
useTitle(() => currentProject.value?.id ? getProjectTitle(currentProject.value) : '')
|
||||
|
||||
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
|
||||
// This resulted in loading and setting the project multiple times, even when navigating away from it.
|
||||
|
@ -118,7 +118,7 @@ watch(
|
|||
(
|
||||
projectIdToLoad === loadedProjectId.value ||
|
||||
typeof projectIdToLoad === 'undefined' ||
|
||||
projectIdToLoad === currentProject.value.id
|
||||
projectIdToLoad === currentProject.value?.id
|
||||
)
|
||||
&& typeof currentProject.value !== 'undefined' && currentProject.value.maxRight !== null
|
||||
) {
|
||||
|
@ -130,8 +130,8 @@ watch(
|
|||
|
||||
// Set the current project to the one we're about to load so that the title is already shown at the top
|
||||
loadedProjectId.value = 0
|
||||
const projectFromStore = projectStore.getProjectById(projectData.id)
|
||||
if (projectFromStore !== null) {
|
||||
const projectFromStore = projectStore.projects[projectData.id]
|
||||
if (projectFromStore) {
|
||||
baseStore.setBackground(null)
|
||||
baseStore.setBlurHash(null)
|
||||
baseStore.handleSetCurrentProject({project: projectFromStore})
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
:class="{'is-visible': background}"
|
||||
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
|
||||
/>
|
||||
<span v-if="project.isArchived" class="is-archived" >{{ $t('namespace.archived') }}</span>
|
||||
<span v-if="project.isArchived" class="is-archived" >{{ $t('project.archived') }}</span>
|
||||
|
||||
<div class="project-title" aria-hidden="true">{{ project.title }}</div>
|
||||
<BaseButton
|
||||
|
|
|
@ -165,16 +165,6 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('namespace.namespaces') }}</label>
|
||||
<div class="control">
|
||||
<SelectNamespace
|
||||
v-model="entities.namespace"
|
||||
@select="changeMultiselectFilter('namespace', 'namespace')"
|
||||
@remove="changeMultiselectFilter('namespace', 'namespace')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</card>
|
||||
</template>
|
||||
|
@ -189,7 +179,6 @@ import {camelCase} from 'camel-case'
|
|||
|
||||
import type {ILabel} from '@/modelTypes/ILabel'
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
|
@ -201,7 +190,6 @@ import EditLabels from '@/components/tasks/partials/editLabels.vue'
|
|||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import SelectUser from '@/components/input/SelectUser.vue'
|
||||
import SelectProject from '@/components/input/SelectProject.vue'
|
||||
import SelectNamespace from '@/components/input/SelectNamespace.vue'
|
||||
|
||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
|
||||
|
@ -209,7 +197,6 @@ import {objectToSnakeCase} from '@/helpers/case'
|
|||
|
||||
import UserService from '@/services/user'
|
||||
import ProjectService from '@/services/project'
|
||||
import NamespaceService from '@/services/namespace'
|
||||
|
||||
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
|
||||
import {getDefaultParams} from '@/composables/useTaskList'
|
||||
|
@ -240,7 +227,6 @@ const DEFAULT_FILTERS = {
|
|||
assignees: '',
|
||||
labels: '',
|
||||
project_id: '',
|
||||
namespace: '',
|
||||
} as const
|
||||
|
||||
const props = defineProps({
|
||||
|
@ -265,23 +251,20 @@ const filters = ref({...DEFAULT_FILTERS})
|
|||
const services = {
|
||||
users: shallowReactive(new UserService()),
|
||||
projects: shallowReactive(new ProjectService()),
|
||||
namespace: shallowReactive(new NamespaceService()),
|
||||
}
|
||||
|
||||
interface Entities {
|
||||
users: IUser[]
|
||||
labels: ILabel[]
|
||||
projects: IProject[]
|
||||
namespace: INamespace[]
|
||||
}
|
||||
|
||||
type EntityType = 'users' | 'labels' | 'projects' | 'namespace'
|
||||
type EntityType = 'users' | 'labels' | 'projects'
|
||||
|
||||
const entities: Entities = reactive({
|
||||
users: [],
|
||||
labels: [],
|
||||
projects: [],
|
||||
namespace: [],
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
|
@ -328,7 +311,6 @@ function prepareFilters() {
|
|||
prepareDate('reminders')
|
||||
prepareRelatedObjectFilter('users', 'assignees')
|
||||
prepareRelatedObjectFilter('projects', 'project_id')
|
||||
prepareRelatedObjectFilter('namespace')
|
||||
|
||||
prepareSingleValue('labels')
|
||||
|
||||
|
|
|
@ -72,6 +72,13 @@
|
|||
@update:model-value="setSubscriptionInStore"
|
||||
type="dropdown"
|
||||
/>
|
||||
<dropdown-item
|
||||
v-if="level < 2"
|
||||
:to="{ name: 'project.createFromParent', params: { parentProjectId: project.id } }"
|
||||
icon="layer-group"
|
||||
>
|
||||
{{ $t('menu.createProject') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
|
||||
icon="trash-alt"
|
||||
|
@ -96,17 +103,18 @@ import type {ISubscription} from '@/modelTypes/ISubscription'
|
|||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object as PropType<IProject>,
|
||||
required: true,
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
},
|
||||
})
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
const subscription = ref<ISubscription | null>(null)
|
||||
watchEffect(() => {
|
||||
subscription.value = props.project.subscription ?? null
|
||||
|
@ -122,6 +130,5 @@ function setSubscriptionInStore(sub: ISubscription) {
|
|||
subscription: sub,
|
||||
}
|
||||
projectStore.setProject(updatedProject)
|
||||
namespaceStore.setProjectInNamespaceById(updatedProject)
|
||||
}
|
||||
</script>
|
|
@ -61,7 +61,6 @@ import {useRouter} from 'vue-router'
|
|||
import TaskService from '@/services/task'
|
||||
import TeamService from '@/services/team'
|
||||
|
||||
import NamespaceModel from '@/models/namespace'
|
||||
import TeamModel from '@/models/team'
|
||||
import ProjectModel from '@/models/project'
|
||||
|
||||
|
@ -70,7 +69,6 @@ import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
|
|||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
|
@ -81,7 +79,6 @@ import {success} from '@/message'
|
|||
|
||||
import type {ITeam} from '@/modelTypes/ITeam'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
@ -89,7 +86,6 @@ const router = useRouter()
|
|||
|
||||
const baseStore = useBaseStore()
|
||||
const projectStore = useProjectStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
const labelStore = useLabelStore()
|
||||
const taskStore = useTaskStore()
|
||||
|
||||
|
@ -105,7 +101,6 @@ enum ACTION_TYPE {
|
|||
enum COMMAND_TYPE {
|
||||
NEW_TASK = 'newTask',
|
||||
NEW_PROJECT = 'newProject',
|
||||
NEW_NAMESPACE = 'newNamespace',
|
||||
NEW_TEAM = 'newTeam',
|
||||
}
|
||||
|
||||
|
@ -147,24 +142,15 @@ const foundProjects = computed(() => {
|
|||
return []
|
||||
}
|
||||
|
||||
const ncache: { [id: ProjectModel['id']]: INamespace } = {}
|
||||
const history = getHistory()
|
||||
const allProjects = [
|
||||
...new Set([
|
||||
...history.map((l) => projectStore.getProjectById(l.id)),
|
||||
...history.map((l) => projectStore.projects[l.id]),
|
||||
...projectStore.searchProject(project),
|
||||
]),
|
||||
]
|
||||
|
||||
return allProjects.filter((l) => {
|
||||
if (typeof l === 'undefined' || l === null) {
|
||||
return false
|
||||
}
|
||||
if (typeof ncache[l.namespaceId] === 'undefined') {
|
||||
ncache[l.namespaceId] = namespaceStore.getNamespaceById(l.namespaceId)
|
||||
}
|
||||
return !ncache[l.namespaceId].isArchived
|
||||
})
|
||||
return allProjects.filter(l => Boolean(l))
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
If projects are archived they won't show up here automatically? If projects are archived they won't show up here automatically?
konrad
commented
yes. yes.
dpschen
commented
We didn't include archived namespaces if I understand the code correctly. We didn't include archived namespaces if I understand the code correctly.
konrad
commented
That's correct, so now we don't show archived projects. That's correct, so now we don't show archived projects.
dpschen
commented
Okay. I still don't understand where the archived filterting happens now (didn't check in detail) but if you are aware all good :) Okay. I still don't understand where the archived filterting happens now (didn't check in detail) but if you are aware all good :)
|
||||
})
|
||||
|
||||
// FIXME: use fuzzysearch
|
||||
|
@ -205,7 +191,6 @@ const results = computed<Result[]>(() => {
|
|||
|
||||
const loading = computed(() =>
|
||||
taskService.loading ||
|
||||
namespaceStore.isLoading ||
|
||||
projectStore.isLoading ||
|
||||
teamService.loading,
|
||||
)
|
||||
|
@ -230,12 +215,6 @@ const commands = computed<{ [key in COMMAND_TYPE]: Command }>(() => ({
|
|||
placeholder: t('quickActions.newProject'),
|
||||
action: newProject,
|
||||
},
|
||||
newNamespace: {
|
||||
type: COMMAND_TYPE.NEW_NAMESPACE,
|
||||
title: t('quickActions.cmds.newNamespace'),
|
||||
placeholder: t('quickActions.newNamespace'),
|
||||
action: newNamespace,
|
||||
},
|
||||
newTeam: {
|
||||
type: COMMAND_TYPE.NEW_TEAM,
|
||||
title: t('quickActions.cmds.newTeam'),
|
||||
|
@ -252,7 +231,6 @@ const currentProject = computed(() => Object.keys(baseStore.currentProject).leng
|
|||
)
|
||||
|
||||
const hintText = computed(() => {
|
||||
let namespace
|
||||
if (selectedCmd.value !== null && currentProject.value !== null) {
|
||||
switch (selectedCmd.value.type) {
|
||||
case COMMAND_TYPE.NEW_TASK:
|
||||
|
@ -260,12 +238,7 @@ const hintText = computed(() => {
|
|||
title: currentProject.value.title,
|
||||
})
|
||||
case COMMAND_TYPE.NEW_PROJECT:
|
||||
namespace = namespaceStore.getNamespaceById(
|
||||
currentProject.value.namespaceId,
|
||||
)
|
||||
return t('quickActions.createProject', {
|
||||
title: namespace?.title,
|
||||
})
|
||||
return t('quickActions.createProject')
|
||||
}
|
||||
}
|
||||
const prefixes =
|
||||
|
@ -278,7 +251,7 @@ const availableCmds = computed(() => {
|
|||
if (currentProject.value !== null) {
|
||||
cmds.push(commands.value.newTask, commands.value.newProject)
|
||||
}
|
||||
cmds.push(commands.value.newNamespace, commands.value.newTeam)
|
||||
cmds.push(commands.value.newTeam)
|
||||
return cmds
|
||||
})
|
||||
|
||||
|
@ -396,7 +369,7 @@ function searchTasks() {
|
|||
const r = await taskService.getAll({}, params) as DoAction<ITask>[]
|
||||
foundTasks.value = r.map((t) => {
|
||||
t.type = ACTION_TYPE.TASK
|
||||
const project = projectStore.getProjectById(t.projectId)
|
||||
const project = projectStore.projects[t.projectId]
|
||||
if (project !== null) {
|
||||
t.title = `${t.title} (${project.title})`
|
||||
}
|
||||
|
@ -504,21 +477,10 @@ async function newProject() {
|
|||
if (currentProject.value === null) {
|
||||
return
|
||||
}
|
||||
const newProject = await projectStore.createProject(new ProjectModel({
|
||||
await projectStore.createProject(new ProjectModel({
|
||||
title: query.value,
|
||||
namespaceId: currentProject.value.namespaceId,
|
||||
}))
|
||||
success({ message: t('project.create.createdSuccess')})
|
||||
await router.push({
|
||||
name: 'project.index',
|
||||
params: { projectId: newProject.id },
|
||||
})
|
||||
}
|
||||
|
||||
async function newNamespace() {
|
||||
const newNamespace = new NamespaceModel({ title: query.value })
|
||||
await namespaceStore.createNamespace(newNamespace)
|
||||
success({ message: t('namespace.create.success') })
|
||||
}
|
||||
|
||||
async function newTeam() {
|
||||
|
|
|
@ -139,10 +139,6 @@ import {ref, reactive, computed, shallowReactive, type Ref} from 'vue'
|
|||
import type {PropType} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import UserNamespaceService from '@/services/userNamespace'
|
||||
import UserNamespaceModel from '@/models/userNamespace'
|
||||
import type {IUserNamespace} from '@/modelTypes/IUserNamespace'
|
||||
|
||||
import UserProjectService from '@/services/userProject'
|
||||
import UserProjectModel from '@/models/userProject'
|
||||
import type {IUserProject} from '@/modelTypes/IUserProject'
|
||||
|
@ -151,10 +147,6 @@ import UserService from '@/services/user'
|
|||
import UserModel, { getDisplayName } from '@/models/user'
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
|
||||
import TeamNamespaceService from '@/services/teamNamespace'
|
||||
import TeamNamespaceModel from '@/models/teamNamespace'
|
||||
import type { ITeamNamespace } from '@/modelTypes/ITeamNamespace'
|
||||
|
||||
import TeamProjectService from '@/services/teamProject'
|
||||
import TeamProjectModel from '@/models/teamProject'
|
||||
import type { ITeamProject } from '@/modelTypes/ITeamProject'
|
||||
|
@ -170,13 +162,15 @@ import Nothing from '@/components/misc/nothing.vue'
|
|||
import {success} from '@/message'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
// FIXME: I think this whole thing can now only manage user/team sharing for projects? Maybe remove a little generalization?
|
||||
dpschen marked this conversation as resolved
Outdated
konrad
commented
Not sure if we should do that here in this PR - it's narrow enough to do it later. Not sure if we should do that here in this PR - it's narrow enough to do it later.
dpschen
commented
I created a new issue #3326 I created a new issue https://kolaente.dev/vikunja/frontend/issues/3326
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String as PropType<'project' | 'namespace'>,
|
||||
type: String as PropType<'project'>,
|
||||
default: '',
|
||||
},
|
||||
shareType: {
|
||||
type: String as PropType<'user' | 'team' | 'namespace'>,
|
||||
type: String as PropType<'user' | 'team'>,
|
||||
default: '',
|
||||
},
|
||||
id: {
|
||||
|
@ -191,9 +185,9 @@ const props = defineProps({
|
|||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
// This user service is either a userNamespaceService or a userProjectService, depending on the type we are using
|
||||
let stuffService: UserNamespaceService | UserProjectService | TeamProjectService | TeamNamespaceService
|
||||
let stuffModel: IUserNamespace | IUserProject | ITeamProject | ITeamNamespace
|
||||
// This user service is a userProjectService, depending on the type we are using
|
||||
let stuffService: UserProjectService | TeamProjectService
|
||||
let stuffModel: IUserProject | ITeamProject
|
||||
let searchService: UserService | TeamService
|
||||
let sharable: Ref<IUser | ITeam>
|
||||
|
||||
|
@ -231,10 +225,6 @@ const sharableName = computed(() => {
|
|||
return t('project.list.title')
|
||||
}
|
||||
|
||||
if (props.shareType === 'namespace') {
|
||||
return t('namespace.namespace')
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
|
@ -247,11 +237,6 @@ if (props.shareType === 'user') {
|
|||
if (props.type === 'project') {
|
||||
stuffService = shallowReactive(new UserProjectService())
|
||||
stuffModel = reactive(new UserProjectModel({projectId: props.id}))
|
||||
} else if (props.type === 'namespace') {
|
||||
stuffService = shallowReactive(new UserNamespaceService())
|
||||
stuffModel = reactive(new UserNamespaceModel({
|
||||
namespaceId: props.id,
|
||||
}))
|
||||
} else {
|
||||
throw new Error('Unknown type: ' + props.type)
|
||||
}
|
||||
|
@ -264,11 +249,6 @@ if (props.shareType === 'user') {
|
|||
if (props.type === 'project') {
|
||||
stuffService = shallowReactive(new TeamProjectService())
|
||||
stuffModel = reactive(new TeamProjectModel({projectId: props.id}))
|
||||
} else if (props.type === 'namespace') {
|
||||
stuffService = shallowReactive(new TeamNamespaceService())
|
||||
stuffModel = reactive(new TeamNamespaceModel({
|
||||
namespaceId: props.id,
|
||||
}))
|
||||
} else {
|
||||
throw new Error('Unknown type: ' + props.type)
|
||||
}
|
||||
|
|
|
@ -11,8 +11,10 @@
|
|||
@search="findProjects"
|
||||
>
|
||||
<template #searchResult="{option}">
|
||||
<span class="project-namespace-title search-result">{{ namespace((option as IProject).namespaceId) }} ></span>
|
||||
{{ (option as IProject).title }}
|
||||
<span class="has-text-grey" v-if="projectStore.getAncestors(option).length > 1">
|
||||
{{ projectStore.getAncestors(option).filter(p => p.id !== option.id).map(p => getProjectTitle(p)).join(' > ') }} >
|
||||
</span>
|
||||
{{ getProjectTitle(option) }}
|
||||
</template>
|
||||
</Multiselect>
|
||||
</template>
|
||||
|
@ -20,13 +22,11 @@
|
|||
<script lang="ts" setup>
|
||||
import {reactive, ref, watch} from 'vue'
|
||||
import type {PropType} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||
|
||||
import ProjectModel from '@/models/project'
|
||||
|
||||
|
@ -40,8 +40,6 @@ const props = defineProps({
|
|||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const project: IProject = reactive(new ProjectModel())
|
||||
|
||||
watch(
|
||||
|
@ -54,7 +52,6 @@ watch(
|
|||
)
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
const foundProjects = ref<IProject[]>([])
|
||||
function findProjects(query: string) {
|
||||
if (query === '') {
|
||||
|
@ -70,17 +67,4 @@ function select(l: IProject | null) {
|
|||
Object.assign(project, l)
|
||||
emit('update:modelValue', project)
|
||||
}
|
||||
|
||||
function namespace(namespaceId: INamespace['id']) {
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
This seems like a relevant info. I think we shouldn't remove this feature. This seems like a relevant info. I think we shouldn't remove this feature.
konrad
commented
What do you mean? What do you mean?
dpschen
commented
We should show the parent project here now instead. We should show the parent project here now instead.
konrad
commented
Now doing that. Now doing that.
dpschen
commented
I didn't have a chance yet to check the actual UI, but I realised that the parent might be 'wrong' sometimes. Or at least in the future if there are third level nested projects it might not always be the same as what one would have expected. Because historically the namespace would be shown. The latter is now the 'root ancestor'. I didn't have a chance yet to check the actual UI, but I realised that the parent might be 'wrong' sometimes. Or at least in the future if there are third level nested projects it might not always be the same as what one would have expected. Because historically the namespace would be shown. The latter is now the 'root ancestor'.
konrad
commented
The way I implemented it, it will climb to the top of the tree and show each project. So if the hierarchy is something like first > second > third > task (task is a task in "third"), it will literally show "first > second > third" on the task detail view, while allowing to click on each project. The way I implemented it, it will climb to the top of the tree and show each project. So if the hierarchy is something like first > second > third > task (task is a task in "third"), it will literally show "first > second > third" on the task detail view, while allowing to click on each project.
dpschen
commented
That makes sense. Hopefully there is enough space though. That makes sense. Hopefully there is enough space though.
|
||||
const namespace = namespaceStore.getNamespaceById(namespaceId)
|
||||
return namespace !== null
|
||||
? namespace.title
|
||||
: t('project.shared')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-namespace-title {
|
||||
color: var(--grey-500);
|
||||
}
|
||||
</style>
|
|
@ -46,11 +46,6 @@
|
|||
class="different-project"
|
||||
v-if="task.projectId !== projectId"
|
||||
>
|
||||
<span
|
||||
v-if="task.differentNamespace !== null"
|
||||
v-tooltip="$t('task.relation.differentNamespace')">
|
||||
{{ task.differentNamespace }} >
|
||||
</span>
|
||||
<span
|
||||
v-if="task.differentProject !== null"
|
||||
v-tooltip="$t('task.relation.differentProject')">
|
||||
|
@ -101,11 +96,6 @@
|
|||
class="different-project"
|
||||
v-if="t.projectId !== projectId"
|
||||
>
|
||||
<span
|
||||
v-if="t.differentNamespace !== null"
|
||||
v-tooltip="$t('task.relation.differentNamespace')">
|
||||
{{ t.differentNamespace }} >
|
||||
</span>
|
||||
<span
|
||||
v-if="t.differentProject !== null"
|
||||
v-tooltip="$t('task.relation.differentProject')">
|
||||
|
@ -168,10 +158,9 @@ import BaseButton from '@/components/base/BaseButton.vue'
|
|||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
|
||||
import {error, success} from '@/message'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const props = defineProps({
|
||||
taskId: {
|
||||
|
@ -196,7 +185,7 @@ const props = defineProps({
|
|||
})
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
const projectStore = useProjectStore()
|
||||
const route = useRoute()
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
|
@ -230,26 +219,15 @@ async function findTasks(newQuery: string) {
|
|||
foundTasks.value = await taskService.getAll({}, {s: newQuery})
|
||||
}
|
||||
|
||||
const getProjectAndNamespaceById = (projectId: number) => namespaceStore.getProjectAndNamespaceById(projectId, true)
|
||||
|
||||
const namespace = computed(() => getProjectAndNamespaceById(props.projectId)?.namespace)
|
||||
|
||||
function mapRelatedTasks(tasks: ITask[]) {
|
||||
return tasks.map(task => {
|
||||
// by doing this here once we can save a lot of duplicate calls in the template
|
||||
const {
|
||||
project,
|
||||
namespace: taskNamespace,
|
||||
} = getProjectAndNamespaceById(task.projectId) || {project: null, namespace: null}
|
||||
const project = projectStore.projects[task.ProjectId]
|
||||
|
||||
return {
|
||||
...task,
|
||||
differentNamespace:
|
||||
(taskNamespace !== null &&
|
||||
taskNamespace.id !== namespace.value.id &&
|
||||
taskNamespace?.title) || null,
|
||||
differentProject:
|
||||
(project !== null &&
|
||||
(project &&
|
||||
task.projectId !== props.projectId &&
|
||||
project?.title) || null,
|
||||
}
|
||||
|
|
|
@ -7,19 +7,19 @@
|
|||
/>
|
||||
|
||||
<ColorBubble
|
||||
v-if="showProjectColor && projectColor !== '' && currentProject.id !== task.projectId"
|
||||
v-if="showProjectColor && projectColor !== '' && currentProject?.id !== task.projectId"
|
||||
:color="projectColor"
|
||||
class="mr-1"
|
||||
/>
|
||||
|
||||
<router-link
|
||||
:to="taskDetailRoute"
|
||||
:class="{ 'done': task.done, 'show-project': showProject && project !== null}"
|
||||
:class="{ 'done': task.done, 'show-project': showProject && project}"
|
||||
class="tasktext"
|
||||
>
|
||||
<span>
|
||||
<router-link
|
||||
v-if="showProject && project !== null"
|
||||
v-if="showProject && typeof project !== 'undefined'"
|
||||
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
|
||||
class="task-project"
|
||||
:class="{'mr-2': task.hexColor !== ''}"
|
||||
|
@ -34,7 +34,7 @@
|
|||
/>
|
||||
|
||||
<!-- 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">
|
||||
{{ pt.title }}<template v-if="(i + 1) < task.relatedTasks.parenttask.length">, </template>
|
||||
</template>
|
||||
|
@ -104,7 +104,7 @@
|
|||
</progress>
|
||||
|
||||
<router-link
|
||||
v-if="!showProject && currentProject.id !== task.projectId && project !== null"
|
||||
v-if="!showProject && currentProject?.id !== task.projectId && project"
|
||||
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
|
||||
class="task-project"
|
||||
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
|
||||
|
@ -149,7 +149,6 @@ import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatD
|
|||
import {success} from '@/message'
|
||||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
|
@ -209,10 +208,9 @@ onBeforeUnmount(() => {
|
|||
const baseStore = useBaseStore()
|
||||
const projectStore = useProjectStore()
|
||||
const taskStore = useTaskStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
|
||||
const project = computed(() => projectStore.getProjectById(task.value.projectId))
|
||||
const projectColor = computed(() => project.value !== null ? project.value.hexColor : '')
|
||||
const project = computed(() => projectStore.projects[task.value.projectId])
|
||||
const projectColor = computed(() => project.value ? project.value?.hexColor : '')
|
||||
|
||||
const currentProject = computed(() => {
|
||||
return typeof baseStore.currentProject === 'undefined' ? {
|
||||
|
@ -257,10 +255,8 @@ function undoDone(checked: boolean) {
|
|||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
task.value.isFavorite = !task.value.isFavorite
|
||||
task.value = await taskService.update(task.value)
|
||||
task.value = await taskStore.toggleFavorite(task.value)
|
||||
emit('task-updated', task.value)
|
||||
namespaceStore.loadNamespacesIfFavoritesDontExist()
|
||||
}
|
||||
|
||||
const deferDueDate = ref<typeof DeferTask | null>(null)
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import {ref, computed} from 'vue'
|
||||
dpschen
commented
This whole functionality is really nice for projects as well. Why not use it there => This whole functionality is really nice for projects as well. Why not use it there => `useProjectSearch`
konrad
commented
The project store has a The project store has a `projectSearch` method which does something similar. Or do you have something specific in mind?
|
||||
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
|
@ -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 type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
export function getProjectTitle(l: IProject) {
|
||||
if (l.id === -1) {
|
||||
export function getProjectTitle(project: IProject) {
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Use long var names. Use long var names.
konrad
commented
Done. Done.
|
||||
if (project.id === -1) {
|
||||
return i18n.global.t('project.pseudo.favorites.title')
|
||||
}
|
||||
return l.title
|
||||
|
||||
if (project.title === 'Inbox') {
|
||||
return i18n.global.t('project.inboxTitle')
|
||||
}
|
||||
|
||||
return project.title
|
||||
}
|
||||
|
|
|
@ -6,9 +6,7 @@
|
|||
"welcomeEvening": "Good Evening {username}!",
|
||||
"lastViewed": "Last viewed",
|
||||
"project": {
|
||||
"newText": "You can create a new project for your new tasks:",
|
||||
"new": "New project",
|
||||
"importText": "Or import your projects and tasks from other services into Vikunja:",
|
||||
"importText": "Import your projects and tasks from other services into Vikunja:",
|
||||
"import": "Import your data into Vikunja"
|
||||
}
|
||||
},
|
||||
|
@ -143,7 +141,7 @@
|
|||
},
|
||||
"deletion": {
|
||||
"title": "Delete your Vikunja Account",
|
||||
"text1": "The deletion of your account is permanent and cannot be undone. We will delete all your namespaces, projects, tasks and everything associated with it.",
|
||||
"text1": "The deletion of your account is permanent and cannot be undone. We will delete all your projects, tasks and everything associated with it.",
|
||||
"text2": "To proceed, please enter your password. You will receive an email with further instructions.",
|
||||
"confirm": "Delete my account",
|
||||
"requestSuccess": "The request was successful. You'll receive an email with further instructions.",
|
||||
|
@ -157,7 +155,7 @@
|
|||
},
|
||||
"export": {
|
||||
"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:",
|
||||
"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.",
|
||||
|
@ -165,14 +163,18 @@
|
|||
}
|
||||
},
|
||||
"project": {
|
||||
"archived": "This project is archived. It is not possible to create new or edit tasks for it.",
|
||||
"archivedMessage": "This project is archived. It is not possible to create new or edit tasks for it.",
|
||||
"archived": "Archived",
|
||||
"showArchived": "Show Archived",
|
||||
"title": "Project Title",
|
||||
"color": "Color",
|
||||
"projects": "Projects",
|
||||
"parent": "Parent Project",
|
||||
"search": "Type to search for a project…",
|
||||
"searchSelect": "Click or press enter to select this project",
|
||||
"shared": "Shared Projects",
|
||||
"noDescriptionAvailable": "No project description is available.",
|
||||
"inboxTitle": "Inbox",
|
||||
"create": {
|
||||
"header": "New project",
|
||||
"titlePlaceholder": "The project's title goes here…",
|
||||
|
@ -210,7 +212,7 @@
|
|||
"duplicate": {
|
||||
"title": "Duplicate this project",
|
||||
"label": "Duplicate",
|
||||
"text": "Select a namespace which should hold the duplicated project:",
|
||||
"text": "Select a parent project which should hold the duplicated project:",
|
||||
"success": "The project was successfully duplicated."
|
||||
},
|
||||
"edit": {
|
||||
|
@ -321,67 +323,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"namespace": {
|
||||
"title": "Namespaces & Projects",
|
||||
"namespace": "Namespace",
|
||||
"showArchived": "Show Archived",
|
||||
"noneAvailable": "You don't have any namespaces right now.",
|
||||
"unarchive": "Un-Archive",
|
||||
"archived": "Archived",
|
||||
"noProjects": "This namespace does not contain any projects.",
|
||||
"createProject": "Create a new project in this namespace.",
|
||||
"namespaces": "Namespaces",
|
||||
"search": "Type to search for a namespace…",
|
||||
"create": {
|
||||
"title": "New namespace",
|
||||
"titleRequired": "Please specify a title.",
|
||||
"explanation": "A namespace is a collection of projects you can share and use to organize your projects with. In fact, every project belongs to a namespace.",
|
||||
"tooltip": "What's a namespace?",
|
||||
"success": "The namespace was successfully created."
|
||||
},
|
||||
"archive": {
|
||||
"titleArchive": "Archive \"{namespace}\"",
|
||||
"titleUnarchive": "Un-Archive \"{namespace}\"",
|
||||
"archiveText": "You won't be able to edit this namespace or create new projects until you un-archive it. This will also archive all projects in this namespace.",
|
||||
"unarchiveText": "You will be able to create new projects or edit it.",
|
||||
"success": "The namespace was successfully archived.",
|
||||
"unarchiveSuccess": "The namespace was successfully un-archived.",
|
||||
"description": "If a namespace is archived, you cannot create new projects or edit it."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete \"{namespace}\"",
|
||||
"text1": "Are you sure you want to delete this namespace and all of its contents?",
|
||||
"text2": "This includes all projects and tasks and CANNOT BE UNDONE!",
|
||||
"success": "The namespace was successfully deleted."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit \"{namespace}\"",
|
||||
"success": "The namespace was successfully updated."
|
||||
},
|
||||
"share": {
|
||||
"title": "Share \"{namespace}\""
|
||||
},
|
||||
"attributes": {
|
||||
"title": "Namespace Title",
|
||||
"titlePlaceholder": "The namespace title goes here…",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "The namespaces description goes here…",
|
||||
"color": "Color",
|
||||
"archived": "Is Archived",
|
||||
"isArchived": "This namespace is archived"
|
||||
},
|
||||
"pseudo": {
|
||||
"sharedProjects": {
|
||||
"title": "Shared Projects"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Favorites"
|
||||
},
|
||||
"savedFilters": {
|
||||
"title": "Filters"
|
||||
}
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filters",
|
||||
"clear": "Clear Filters",
|
||||
|
@ -403,7 +344,7 @@
|
|||
},
|
||||
"create": {
|
||||
"title": "New Saved Filter",
|
||||
"description": "A saved filter is a virtual project which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
|
||||
"description": "A saved filter is a virtual project which is computed from a set of filters each time it is accessed.",
|
||||
"action": "Create new saved filter",
|
||||
"titleRequired": "Please provide a title for the filter."
|
||||
},
|
||||
|
@ -677,19 +618,13 @@
|
|||
"updated": "Updated"
|
||||
},
|
||||
"subscription": {
|
||||
"subscribedProjectThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this project through its namespace.",
|
||||
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
|
||||
"subscribedTaskThroughParentProject": "You can't unsubscribe here because you are subscribed to this task through its project.",
|
||||
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
|
||||
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
|
||||
"subscribedProject": "You are currently subscribed to this project and will receive notifications for changes.",
|
||||
"notSubscribedProject": "You are not subscribed to this project and won't receive notifications for changes.",
|
||||
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
|
||||
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
|
||||
"subscribe": "Subscribe",
|
||||
"unsubscribe": "Unsubscribe",
|
||||
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
|
||||
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
|
||||
"subscribeSuccessProject": "You are now subscribed to this project",
|
||||
"unsubscribeSuccessProject": "You are now unsubscribed to this project",
|
||||
"subscribeSuccessTask": "You are now subscribed to this task",
|
||||
|
@ -766,7 +701,6 @@
|
|||
"searchPlaceholder": "Type search for a new task to add as related…",
|
||||
"createPlaceholder": "Add this as new related task",
|
||||
"differentProject": "This task belongs to a different project.",
|
||||
"differentNamespace": "This task belongs to a different namespace.",
|
||||
"noneYet": "No task relations yet.",
|
||||
"delete": "Delete Task Relation",
|
||||
"deleteText1": "Are you sure you want to delete this task relation?",
|
||||
|
@ -851,19 +785,19 @@
|
|||
"delete": {
|
||||
"header": "Delete the team",
|
||||
"text1": "Are you sure you want to delete this team and all of its members?",
|
||||
"text2": "All team members will lose access to projects and namespaces shared with this team. This CANNOT BE UNDONE!",
|
||||
"text2": "All team members will lose access to projects shared with this team. This CANNOT BE UNDONE!",
|
||||
"success": "The team was successfully deleted."
|
||||
},
|
||||
"deleteUser": {
|
||||
"header": "Remove a user from the team",
|
||||
"text1": "Are you sure you want to remove this user from the team?",
|
||||
"text2": "They will lose access to all projects and namespaces this team has access to. This CANNOT BE UNDONE!",
|
||||
"text2": "They will lose access to all projects this team has access to. This CANNOT BE UNDONE!",
|
||||
"success": "The user was successfully deleted from the team."
|
||||
},
|
||||
"leave": {
|
||||
"title": "Leave team",
|
||||
"text1": "Are you sure you want to leave this team?",
|
||||
"text2": "You will lose access to all projects and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
|
||||
"text2": "You will lose access to all projects this team has access to. If you change your mind you'll need a team admin to add you again.",
|
||||
"success": "You have successfully left the team."
|
||||
}
|
||||
},
|
||||
|
@ -913,9 +847,9 @@
|
|||
"title": "Navigation",
|
||||
"overview": "Navigate to overview",
|
||||
"upcoming": "Navigate to upcoming tasks",
|
||||
"namespaces": "Navigate to namespaces & projects",
|
||||
"labels": "Navigate to labels",
|
||||
"teams": "Navigate to teams"
|
||||
"teams": "Navigate to teams",
|
||||
"projects": "Navigate to projects"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
|
@ -930,7 +864,8 @@
|
|||
"unarchive": "Un-Archive",
|
||||
"setBackground": "Set background",
|
||||
"share": "Share",
|
||||
"newProject": "New project"
|
||||
"newProject": "New project",
|
||||
"createProject": "Create project"
|
||||
},
|
||||
"apiConfig": {
|
||||
"url": "Vikunja URL",
|
||||
|
@ -949,7 +884,7 @@
|
|||
"notification": {
|
||||
"title": "Notifications",
|
||||
"none": "You don't have any notifications. Have a nice day!",
|
||||
"explainer": "Notifications will appear here when actions on namespaces, projects or tasks you subscribed to happen."
|
||||
"explainer": "Notifications will appear here when actions projects or tasks you subscribed to happen."
|
||||
},
|
||||
"quickActions": {
|
||||
"commands": "Commands",
|
||||
|
@ -960,14 +895,12 @@
|
|||
"teams": "Teams",
|
||||
"newProject": "Enter the title of the new project…",
|
||||
"newTask": "Enter the title of the new task…",
|
||||
"newNamespace": "Enter the title of the new namespace…",
|
||||
"newTeam": "Enter the name of the new team…",
|
||||
"createTask": "Create a task in the current project ({title})",
|
||||
"createProject": "Create a project in the current namespace ({title})",
|
||||
"createProject": "Create a project",
|
||||
"cmds": {
|
||||
"newTask": "New task",
|
||||
"newProject": "New project",
|
||||
"newNamespace": "New namespace",
|
||||
"newTeam": "New team"
|
||||
}
|
||||
},
|
||||
|
@ -1023,16 +956,9 @@
|
|||
"4017": "Invalid task filter comparator.",
|
||||
"4018": "Invalid task filter concatenator.",
|
||||
"4019": "Invalid task filter value.",
|
||||
"5001": "The namespace does not exist.",
|
||||
"5003": "You do not have access to the specified namespace.",
|
||||
"5006": "The namespace name cannot be empty.",
|
||||
"5009": "You need to have namespace read access to perform that action.",
|
||||
"5010": "This team does not have access to that namespace.",
|
||||
"5011": "This user has already access to that namespace.",
|
||||
"5012": "The namespace is archived and can therefore only be accessed read only.",
|
||||
"6001": "The team name cannot be empty.",
|
||||
"6002": "The team does not exist.",
|
||||
"6004": "The team already has access to that namespace or project.",
|
||||
"6004": "The team already has access to that project.",
|
||||
"6005": "The user is already a member of that team.",
|
||||
"6006": "Cannot delete the last team member.",
|
||||
"6007": "The team does not have access to the project to perform that action.",
|
||||
|
|
|
@ -23,6 +23,7 @@ declare global {
|
|||
API_URL: string;
|
||||
SENTRY_ENABLED: boolean;
|
||||
SENTRY_DSN: string;
|
||||
PROJECT_INFINITE_NESTING_ENABLED: boolean;
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
`PROJECT_INFINITE_NESTING_ENABLED`
konrad
commented
Done. Done.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {IUser} from './IUser'
|
||||
import type {ISubscription} from './ISubscription'
|
||||
import type {INamespace} from './INamespace'
|
||||
|
||||
|
||||
export interface IProject extends IAbstract {
|
||||
|
@ -11,7 +10,6 @@ export interface IProject extends IAbstract {
|
|||
description: string
|
||||
owner: IUser
|
||||
tasks: ITask[]
|
||||
namespaceId: INamespace['id']
|
||||
isArchived: boolean
|
||||
hexColor: string
|
||||
identifier: string
|
||||
|
@ -20,6 +18,7 @@ export interface IProject extends IAbstract {
|
|||
subscription: ISubscription
|
||||
position: number
|
||||
backgroundBlurHash: string
|
||||
parentProjectId: number
|
||||
dpschen
commented
This is really error prone. In the moment the project including child projects is coming from the backend we should replace this with an array of This is really error prone. In the moment the project including child projects is coming from the backend we should replace this with an array of `childProjects` instead. All projects should be included in a flat id-`Map` inside the store.
Having hierarchies just adds complexity everywhere. If a component wants to use a list it can get it directly from the store.
konrad
commented
What's that? The projects in > we should replace this with an array of `childProjects` instead.
What's that? The projects in `childProjects` are just regular projects.
dpschen
commented
Coming from the api – yes I know. But using them like this in the frontend – not a good pattern. I know we did this similar in other places already. But here we this is the first time for projects. Because of that I would like to prevent the introduction of this pattern here. So when we get the regular projects child array from the api we replace them with their ids instead and add the child projects to the general projects list in the store. That general list is a list of all projects – including all child projects. If we just want the root projects we can create a filter for projects that don't have a parent. Coming from the api – yes I know. But using them like this in the frontend – not a good pattern. I know we did this similar in other places already. But here we this is the first time for projects. Because of that I would like to prevent the introduction of this pattern here. So when we get the regular projects child array from the api we replace them with their ids instead and add the child projects to the general projects list in the store. That general list is a list of __all__ projects – including all child projects. If we just want the root projects we can create a filter for projects that don't have a parent.
dpschen
commented
Adding to this – of course we have to reverse this when we send stuff back to the api. By the way for all of this zod is really helpfull… Adding to this – of course we have to reverse this when we send stuff back to the api. By the way for all of this zod is really helpfull…
konrad
commented
We already save all projects in the store, regardless of whether they are a child or not. The navigation then starts with a filtered list of that. Maybe we could just ignore We already save all projects in the store, regardless of whether they are a child or not. The navigation then starts with a filtered list of that.
Maybe we could just ignore `childProjects` completely and only use `parentProjectId`? And then build the list of child projects dynamically when needed? Not sure about the performance implications here.
dpschen
commented
This is basically the idea I have only in the reverse direction. It would be better to have the id of the child though because than we don't have to iterate through all projects everytime we want to find all childs. Instead we can get via the id which should be much faster. I wouldn't ignore the childProjects because the data inside would need to be manually synced. Instead that property should not exist in the frontend data model. > Maybe we could just ignore childProjects completely and only use parentProjectId? And then build the list of child projects dynamically when needed? Not sure about the performance implications here.
This is basically the idea I have only in the reverse direction. It would be better to have the id of the child though because than we don't have to iterate through all projects everytime we want to find all childs. Instead we can get via the id which should be much faster.
I wouldn't ignore the childProjects because the data inside would need to be manually synced. Instead that property should not exist in the frontend data model.
dpschen
commented
See normalizr. The utility is unmaintained but the examples are still valid. Since our store is flux inspired this applies to use as well. See [normalizr](https://github.com/paularmstrong/normalizr/tree/a213bbc6e7bf328cdd46f61a3367b952dc5f30da). The utility is unmaintained but the examples are still valid. Since our store is flux inspired this applies to use as well.
konrad
commented
The api only uses the > I wouldn't ignore the childProjects because the data inside would need to be manually synced. Instead that property should not exist in the frontend data model.
The api only uses the `childProjects` property when returning a response with all projects. It won't use it to update the parent <-> child relation.
konrad
commented
Makes sense, I wonder how good that would work with the dragging and dropping of projects though. > It would be better to have the id of the child though because than we don't have to iterate through all projects everytime we want to find all childs. Instead we can get via the id which should be much faster.
Makes sense, I wonder how good that would work with the dragging and dropping of projects though.
dpschen
commented
Okay that means that we could simply return ids of the childProjects via a new property (e.g.)
Why would that be a problem? > The api only uses the `childProjects` property when returning a response with all projects. It won't use it to update the parent <-> child relation.
Okay that means that we could simply return ids of the childProjects via a new property (e.g.) `childProjectIds`? That would make that step from the API obsolete.
> I wonder how good that would work with the dragging and dropping of projects though.
Why would that be a problem?
dpschen
commented
Note that the store still could provide a Note that the store still could provide a `getChildProjects` computed that would return a function where you can pass in the id of a project and would get a reactive list of child projects.
konrad
commented
I think we should be able to, yes. Would you add that as a new property to the Project Model and then map it out in the constructor?
Not really, since we need to fetch all children anyway. > Okay that means that we could simply return ids of the childProjects via a new property (e.g.) childProjectIds?
I think we should be able to, yes.
Would you add that as a new property to the Project Model and then map it out in the constructor?
> That would make that step from the API obsolete.
Not really, since we need to fetch all children anyway.
konrad
commented
Should that include the children of children (of children... and so on) as well? > Note that the store still could provide a getChildProjects computed that would return a function where you can pass in the id of a project and would get a reactive list of child projects.
Should that include the children of children (of children... and so on) as well?
konrad
commented
I started moving this from the current implementation to one where the store only has a flat map and does not store the children directly. It works for the basics, but I could not get any version of drag n' drop to work with that. Not sure what I did wrong. One problem is the api returns the projects already in the child projects structure. This makes it easy to handle it as such when parsing the data from the api. Another issue I have with that approach is how it requires a new ref in the projects navigation component, which holds all children for each project of the current tree. That's the same as a property of the Here's what I did:
I started moving this from the current implementation to one where the store only has a flat map and does not store the children directly. It works for the basics, but I could not get any version of drag n' drop to work with that. Not sure what I did wrong.
One problem is the api returns the projects already in the child projects structure. This makes it easy to handle it as such when parsing the data from the api.
Another issue I have with that approach is how it requires a new ref in the projects navigation component, which holds all children for each project of the current tree. That's the same as a property of the `Projects` model, but feels a lot hackier.
Here's what I did:
```patch
Index: src/modelTypes/IProject.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/modelTypes/IProject.ts b/src/modelTypes/IProject.ts
--- a/src/modelTypes/IProject.ts (revision 0d0b3c0ca7ac1a1894a4c49091f7b138df4f9818)
+++ b/src/modelTypes/IProject.ts (date 1680095041514)
@@ -18,7 +18,8 @@
subscription: ISubscription
position: number
backgroundBlurHash: string
- childProjects: IProject[] | null
+ // childProjects: IProject[] | null
+ childProjectIds: number[]
parentProjectId: number
created: Date
Index: src/components/home/ProjectsNavigationWrapper.vue
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/components/home/ProjectsNavigationWrapper.vue b/src/components/home/ProjectsNavigationWrapper.vue
--- a/src/components/home/ProjectsNavigationWrapper.vue (revision 0d0b3c0ca7ac1a1894a4c49091f7b138df4f9818)
+++ b/src/components/home/ProjectsNavigationWrapper.vue (date 1680095818765)
@@ -24,7 +24,6 @@
.filter(p => !p.isArchived && p.isFavorite)
.map(p => ({
...p,
- childProjects: [],
}))
.sort((a, b) => a.position - b.position))
</script>
Index: src/components/home/ProjectsNavigation.vue
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/components/home/ProjectsNavigation.vue b/src/components/home/ProjectsNavigation.vue
--- a/src/components/home/ProjectsNavigation.vue (revision 0d0b3c0ca7ac1a1894a4c49091f7b138df4f9818)
+++ b/src/components/home/ProjectsNavigation.vue (date 1680095997699)
@@ -26,7 +26,7 @@
>
<section>
<BaseButton
- v-if="p.childProjects.length > 0"
+ v-if="childProjects[p.id].length > 0"
@click="collapsedProjects[p.id] = !collapsedProjects[p.id]"
class="collapse-project-button"
>
@@ -67,7 +67,7 @@
</section>
<ProjectsNavigation
v-if="!collapsedProjects[p.id]"
- v-model="p.childProjects"
+ v-model="childProjects[p.id]"
:can-edit-order="true"
/>
</li>
@@ -114,11 +114,15 @@
// TODO: child projects
const collapsedProjects = ref<{ [id: IProject['id']]: boolean }>({})
const availableProjects = ref<IProject[]>([])
+const childProjects = ref<{ [id: IProject['id']]: boolean }>({})
watch(
() => props.modelValue,
projects => {
availableProjects.value = projects
- projects.forEach(p => collapsedProjects.value[p.id] = false)
+ projects.forEach(p => {
+ collapsedProjects.value[p.id] = false
+ childProjects.value[p.id] = projectStore.getChildProjects(p.id)
+ })
},
{immediate: true},
)
@@ -149,8 +153,8 @@
if (project.parentProjectId !== parentProjectId && project.parentProjectId > 0) {
const parentProject = projectStore.getProjectById(project.parentProjectId)
- const childProjectIndex = parentProject.childProjects.findIndex(p => p.id === project.id)
- parentProject.childProjects.splice(childProjectIndex, 1)
+ const childProjectIndex = parentProject.childProjectIds.findIndex(pId => pId === project.id)
+ parentProject.childProjectIds.splice(childProjectIndex, 1)
}
try {
Index: src/stores/projects.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/stores/projects.ts b/src/stores/projects.ts
--- a/src/stores/projects.ts (revision 0d0b3c0ca7ac1a1894a4c49091f7b138df4f9818)
+++ b/src/stores/projects.ts (date 1680096142590)
@@ -34,6 +34,9 @@
const getProjectById = computed(() => {
return (id: IProject['id']) => typeof projects.value[id] !== 'undefined' ? projects.value[id] : null
})
+ const getChildProjects = computed(() => {
+ return (id: IProject['id']) => projectsArray.value.filter(p => p.parentProjectId === id)
+ })
const findProjectByExactname = computed(() => {
return (name: string) => {
@@ -67,24 +70,24 @@
}
if (updateChildren) {
- project.childProjects?.forEach(p => setProject(p))
+ project.childProjects?.forEach(p => setProject(new ProjectModel(p)))
}
// if the project is a child project, we need to also set it in the parent
if (project.parentProjectId) {
const parent = projects.value[project.parentProjectId]
let foundProject = false
- parent.childProjects = parent.childProjects?.map(p => {
+ parent.childProjectIds = parent.childProjectIds?.forEach(p => {
if (p.id === project.id) {
foundProject = true
- return project
}
-
- return p
})
// If we hit this, the project now has a new parent project which it did not have before
if (!foundProject) {
- parent.childProjects.push(project)
+ if (!parent.childProjectIds) {
+ parent.childProjectIds = []
+ }
+ parent.childProjectIds.push(project.id)
}
setProject(parent, false)
}
@@ -197,6 +200,7 @@
hasProjects: readonly(hasProjects),
getProjectById,
+ getChildProjects,
findProjectByExactname,
searchProject,
Index: src/models/project.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/models/project.ts b/src/models/project.ts
--- a/src/models/project.ts (revision 0d0b3c0ca7ac1a1894a4c49091f7b138df4f9818)
+++ b/src/models/project.ts (date 1680096142588)
@@ -22,7 +22,7 @@
subscription: ISubscription = null
position = 0
backgroundBlurHash = ''
- childProjects = []
+ childProjectIds = []
parentProjectId = 0
created: Date = null
@@ -47,7 +47,8 @@
this.subscription = new SubscriptionModel(this.subscription)
}
- this.childProjects = this.childProjects.map(p => new ProjectModel(p))
+ // debugger
+ this.childProjectIds = this.childProjects?.map(p => p.id) || []
this.created = new Date(this.created)
this.updated = new Date(this.updated)
konrad
commented
I got something working! See There are a few cases where the performance is really bad but I didn't manage to reproduce that reliably (let alone profile it). I got something working! See 2557b182dde8f40f4be903e65c0485d46c5a185a
There are a few cases where the performance is really bad but I didn't manage to reproduce that reliably (let alone profile it).
|
||||
|
||||
created: Date
|
||||
updated: Date
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import type {IAbstract} from './IAbstract'
|
||||
import type {IProject} from './IProject'
|
||||
import type {INamespace} from './INamespace'
|
||||
|
||||
export interface IProjectDuplicate extends IAbstract {
|
||||
projectId: number
|
||||
namespaceId: INamespace['id']
|
||||
project: IProject
|
||||
parentProjectId: IProject['id']
|
||||
dpschen marked this conversation as resolved
Outdated
konrad
commented
Done. Done.
|
||||
}
|
|
@ -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 {IUser} from '@/modelTypes/IUser'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
import type {ISubscription} from '@/modelTypes/ISubscription'
|
||||
|
||||
export default class ProjectModel extends AbstractModel<IProject> implements IProject {
|
||||
|
@ -15,7 +14,6 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
|
|||
description = ''
|
||||
owner: IUser = UserModel
|
||||
tasks: ITask[] = []
|
||||
namespaceId: INamespace['id'] = 0
|
||||
isArchived = false
|
||||
hexColor = ''
|
||||
identifier = ''
|
||||
|
@ -24,6 +22,7 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
|
|||
subscription: ISubscription = null
|
||||
position = 0
|
||||
backgroundBlurHash = ''
|
||||
parentProjectId = 0
|
||||
|
||||
dpschen
commented
Coming from the recent discussion: wouldn't it be better if this was Coming from the recent discussion: wouldn't it be better if this was `undefined` or `null`?
konrad
commented
You mean to make it clear if this was not set, since it will be overridden with 0 by the api anyway? You mean to make it clear if this was not set, since it will be overridden with 0 by the api anyway?
dpschen
commented
Okay, wasn't aware of this. So the EDIT: Okay, wasn't aware of this. So the `0` here is coming from the golang format. I think we should use the equivalent in js for the frontend, similar to how we use camelCase for variable names.
**EDIT:**
Keeping this `unresolved` because I still want to check something.
|
||||
created: Date = null
|
||||
updated: Date = null
|
||||
|
@ -46,7 +45,7 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
|
|||
if (typeof this.subscription !== 'undefined' && this.subscription !== null) {
|
||||
this.subscription = new SubscriptionModel(this.subscription)
|
||||
}
|
||||
|
||||
|
||||
this.created = new Date(this.created)
|
||||
this.updated = new Date(this.updated)
|
||||
dpschen
commented
If we receive child projects like this from the server: Do we have that info somewhere else? If we receive child projects like this from the server: Do we have that info somewhere else?
konrad
commented
We don't actually receive them like this anymore, so this is obsolete, and I've removed it. Each project has a parent project id. To get all child projects we need to iterate over them and return all projects with a parent project id of the project we're interested in. We don't need to that anywhere so I think it's fine to leave at that. We don't actually receive them like this anymore, so this is obsolete, and I've removed it.
Each project has a parent project id. To get all child projects we need to iterate over them and return all projects with a parent project id of the project we're interested in. We don't need to that anywhere so I think it's fine to leave at that.
|
||||
}
|
||||
|
|
|
@ -2,13 +2,12 @@ import AbstractModel from './abstractModel'
|
|||
import ProjectModel from './project'
|
||||
|
||||
import type {IProjectDuplicate} from '@/modelTypes/IProjectDuplicate'
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
export default class ProjectDuplicateModel extends AbstractModel<IProjectDuplicate> implements IProjectDuplicate {
|
||||
projectId = 0
|
||||
namespaceId: INamespace['id'] = 0
|
||||
project: IProject = ProjectModel
|
||||
parentProjectId = 0
|
||||
|
||||
constructor(data : Partial<IProjectDuplicate>) {
|
||||
super()
|
||||
|
|
|
@ -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.
|
||||
* It is extended in a way so it can be used for namespaces as well for projects.
|
||||
* It is extended in a way, so it can be used for projects.
|
||||
*/
|
||||
export default class TeamShareBaseModel extends AbstractModel<ITeamShareBase> implements ITeamShareBase {
|
||||
teamId: ITeam['id'] = 0
|
||||
|
|
|
@ -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 {
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
`UserShareBaseModel` is now only used for `userProjects`. Unify?
konrad
commented
Yeah I think we should, but let's combine that with #3326 Yeah I think we should, but let's combine that with https://kolaente.dev/vikunja/frontend/issues/3326
|
||||
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
|
||||
import UpcomingTasksComponent from '@/views/tasks/ShowTasks.vue'
|
||||
import LinkShareAuthComponent from '@/views/sharing/LinkSharingAuth.vue'
|
||||
const ListNamespaces = () => import('@/views/namespaces/ListNamespaces.vue')
|
||||
const TaskDetailView = () => import('@/views/tasks/TaskDetailView.vue')
|
||||
|
||||
// Team Handling
|
||||
|
@ -41,6 +40,7 @@ const ProjectKanban = () => import('@/views/project/ProjectKanban.vue')
|
|||
const ProjectInfo = () => import('@/views/project/ProjectInfo.vue')
|
||||
|
||||
// Project Settings
|
||||
const ListProjects = () => import('@/views/project/ListProjects.vue')
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
What is a general project listing for? Aren't all projects listed in the sidebar? Why do we then still need it => added functionality? What is a general project listing for? Aren't all projects listed in the sidebar? Why do we then still need it => added functionality?
konrad
commented
Archived projects are not listed in the sidebar. And we need to put the button to create a new project somewhere as well. This replaces the namespace overview but instead just shows all projects. Archived projects are not listed in the sidebar. And we need to put the button to create a new project somewhere as well.
This replaces the namespace overview but instead just shows all projects.
dpschen
commented
Regarding new projects: Archived projects => makes somehow sense. I don't like that we have a bit of a duplication with the sidebar this way, but I don't have a better either. Regarding new projects:
Shouldn't there be a button at the top or bottom of the list in the sidebar?
Archived projects => makes somehow sense. I don't like that we have a bit of a duplication with the sidebar this way, but I don't have a better either.
konrad
commented
I don't really have a good idea on how to add one without making it look too clumsy. I think for now it's fine to have it only on the projects overview page. > Shouldn't there be a button at the top or bottom of the list in the sidebar?
I don't really have a good idea on how to add one without making it look too clumsy. I think for now it's fine to have it only on the projects overview page.
dpschen
commented
A) I had an idea for the future how this could be solved: If we let the user 'open the sidebar' meaning the sidebar 'expands to the full screen width' (doesn't mean it has to be animated; I mean this more figuratively), then the ListProjects view could be exactly that view. So what we have is already close to this. What I'm missing are indicators in the UI, that this is what happens. Maybe there is a simpler solution to an animation. One example that I can think of is to expand the background color of the sidebar for this specific view to the full screen width. B) The EDIT: Issue: #3338 C) **A)** I had an idea for the future how this could be solved:
If we let the user 'open the sidebar' meaning the sidebar 'expands to the full screen width' (doesn't mean it has to be animated; I mean this more figuratively), then the ListProjects view could be exactly that view.
So what we have is already close to this. What I'm missing are indicators in the UI, that this is what happens. Maybe there is a simpler solution to an animation. One example that I can think of is to expand the background color of the sidebar for this specific view to the full screen width.
-----------
**B)** The ~~ListProjects~~ `ProjectsNavigation` component, or at least parts of it could be used to show subprojects inside of projects.
**EDIT:** Issue: https://kolaente.dev/vikunja/frontend/issues/3338
-----------
**C)** `ListProjects` should share a most of its logic with `ProjectsNavigation`, because why shouldn't a user be able to sort the order there as well?
konrad
commented
Which one? The sidebar?
Right now it will show all projects, including child projects. But it won't show the hierarchy. Because of that, I'm not sure if we should allow reordering in its current state, simply because the hierarchy is not clearly visible. > then the ListProjects view could be exactly that view.
Which one? The sidebar?
> The ListProjects component, or at least parts of it could be used to show subprojects inside of projects.
Right now it will show all projects, including child projects. But it won't show the hierarchy. Because of that, I'm not sure if we should allow reordering in its current state, simply because the hierarchy is not clearly visible.
dpschen
commented
The view that 'expands to the full screen width'.
Okay. I'll create a new issue, because I think this is something where the ux could profit when it's solved. We don't have to solve it immediately though. EDIT: Issue: #3337 > > then the ListProjects view could be exactly that view.
>
> Which one? The sidebar?
The view that 'expands to the full screen width'.
> Right now it will show all projects, including child projects. But it won't show the hierarchy. Because of that, I'm not sure if we should allow reordering in its current state, simply because the hierarchy is not clearly visible.
Okay. I'll create a new issue, because I think this is something where the ux could profit when it's solved. We don't have to solve it immediately though.
**EDIT:** Issue: https://kolaente.dev/vikunja/frontend/issues/3337
|
||||
const ProjectSettingEdit = () => import('@/views/project/settings/edit.vue')
|
||||
const ProjectSettingBackground = () => import('@/views/project/settings/background.vue')
|
||||
const ProjectSettingDuplicate = () => import('@/views/project/settings/duplicate.vue')
|
||||
|
@ -48,12 +48,6 @@ const ProjectSettingShare = () => import('@/views/project/settings/share.vue')
|
|||
const ProjectSettingDelete = () => import('@/views/project/settings/delete.vue')
|
||||
const ProjectSettingArchive = () => import('@/views/project/settings/archive.vue')
|
||||
|
||||
// Namespace Settings
|
||||
const NamespaceSettingEdit = () => import('@/views/namespaces/settings/edit.vue')
|
||||
const NamespaceSettingShare = () => import('@/views/namespaces/settings/share.vue')
|
||||
const NamespaceSettingArchive = () => import('@/views/namespaces/settings/archive.vue')
|
||||
const NamespaceSettingDelete = () => import('@/views/namespaces/settings/delete.vue')
|
||||
|
||||
// Saved Filters
|
||||
const FilterNew = () => import('@/views/filters/FilterNew.vue')
|
||||
const FilterEdit = () => import('@/views/filters/FilterEdit.vue')
|
||||
|
@ -74,9 +68,6 @@ const UserSettingsTOTPComponent = () => import('@/views/user/settings/TOTP.vue')
|
|||
// Project Handling
|
||||
const NewProjectComponent = () => import('@/views/project/NewProject.vue')
|
||||
|
||||
// Namespace Handling
|
||||
const NewNamespaceComponent = () => import('@/views/namespaces/NewNamespace.vue')
|
||||
|
||||
const EditTeamComponent = () => import('@/views/teams/EditTeam.vue')
|
||||
const NewTeamComponent = () => import('@/views/teams/NewTeam.vue')
|
||||
|
||||
|
@ -203,54 +194,6 @@ const router = createRouter({
|
|||
name: 'link-share.auth',
|
||||
component: LinkShareAuthComponent,
|
||||
},
|
||||
{
|
||||
dpschen
commented
We should create redirects for the old routes, so that links stay intact. We should create redirects for the old routes, so that links stay intact.
|
||||
path: '/namespaces',
|
||||
name: 'namespaces.index',
|
||||
component: ListNamespaces,
|
||||
},
|
||||
{
|
||||
path: '/namespaces/new',
|
||||
name: 'namespace.create',
|
||||
component: NewNamespaceComponent,
|
||||
meta: {
|
||||
showAsModal: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/namespaces/:id/settings/edit',
|
||||
name: 'namespace.settings.edit',
|
||||
component: NamespaceSettingEdit,
|
||||
meta: {
|
||||
showAsModal: true,
|
||||
},
|
||||
props: route => ({ namespaceId: Number(route.params.id as string) }),
|
||||
},
|
||||
{
|
||||
path: '/namespaces/:namespaceId/settings/share',
|
||||
name: 'namespace.settings.share',
|
||||
component: NamespaceSettingShare,
|
||||
meta: {
|
||||
showAsModal: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/namespaces/:id/settings/archive',
|
||||
name: 'namespace.settings.archive',
|
||||
component: NamespaceSettingArchive,
|
||||
meta: {
|
||||
showAsModal: true,
|
||||
},
|
||||
props: route => ({ namespaceId: parseInt(route.params.id as string) }),
|
||||
},
|
||||
{
|
||||
path: '/namespaces/:id/settings/delete',
|
||||
name: 'namespace.settings.delete',
|
||||
component: NamespaceSettingDelete,
|
||||
meta: {
|
||||
showAsModal: true,
|
||||
},
|
||||
props: route => ({ namespaceId: Number(route.params.id as string) }),
|
||||
},
|
||||
{
|
||||
path: '/tasks/:id',
|
||||
name: 'task.detail',
|
||||
|
@ -282,13 +225,27 @@ const router = createRouter({
|
|||
},
|
||||
},
|
||||
{
|
||||
path: '/projects/new/:namespaceId/',
|
||||
path: '/projects',
|
||||
name: 'projects.index',
|
||||
component: ListProjects,
|
||||
},
|
||||
{
|
||||
path: '/projects/new',
|
||||
name: 'project.create',
|
||||
component: NewProjectComponent,
|
||||
meta: {
|
||||
showAsModal: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/projects/:parentProjectId/new',
|
||||
name: 'project.createFromParent',
|
||||
component: NewProjectComponent,
|
||||
props: route => ({ parentProjectId: Number(route.params.parentProjectId as string) }),
|
||||
meta: {
|
||||
showAsModal: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/projects/:projectId/settings/edit',
|
||||
name: 'project.settings.edit',
|
||||
|
@ -412,7 +369,7 @@ const router = createRouter({
|
|||
saveProjectView(to.params.projectId, to.name)
|
||||
// Properly set the page title when a task popup is closed
|
||||
const projectStore = useProjectStore()
|
||||
const projectFromStore = projectStore.getProjectById(Number(to.params.projectId))
|
||||
const projectFromStore = projectStore.projects[Number(to.params.projectId)]
|
||||
if(projectFromStore) {
|
||||
setTitle(projectFromStore.title)
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
constructor() {
|
||||
super({
|
||||
create: '/namespaces/{namespaceId}/projects',
|
||||
create: '/projects',
|
||||
get: '/projects/{id}',
|
||||
getAll: '/projects',
|
||||
update: '/projects/{id}',
|
||||
|
|
|
@ -12,7 +12,7 @@ import AbstractService from '@/services/abstractService'
|
|||
import SavedFilterModel from '@/models/savedFilter'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
import {objectToSnakeCase, objectToCamelCase} from '@/helpers/case'
|
||||
import {success} from '@/message'
|
||||
|
@ -40,7 +40,7 @@ export function getSavedFilterIdFromProjectId(projectId: IProject['id']) {
|
|||
}
|
||||
|
||||
export function isSavedFilter(project: IProject) {
|
||||
return getSavedFilterIdFromProjectId(project.id) > 0
|
||||
return getSavedFilterIdFromProjectId(project?.id) > 0
|
||||
}
|
||||
|
||||
export default class SavedFilterService extends AbstractService<ISavedFilter> {
|
||||
|
@ -81,7 +81,7 @@ export default class SavedFilterService extends AbstractService<ISavedFilter> {
|
|||
export function useSavedFilter(projectId?: MaybeRef<IProject['id']>) {
|
||||
const router = useRouter()
|
||||
const {t} = useI18n({useScope:'global'})
|
||||
const namespaceStore = useNamespaceStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const filterService = shallowReactive(new SavedFilterService())
|
||||
|
||||
|
@ -110,13 +110,13 @@ export function useSavedFilter(projectId?: MaybeRef<IProject['id']>) {
|
|||
|
||||
async function createFilter() {
|
||||
filter.value = await filterService.create(filter.value)
|
||||
await namespaceStore.loadNamespaces()
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Shouldn't we load the projects here now instead? Shouldn't we load the projects here now instead?
konrad
commented
Good point, added. Good point, added.
|
||||
await projectStore.loadProjects()
|
||||
router.push({name: 'project.index', params: {projectId: getProjectId(filter.value)}})
|
||||
}
|
||||
|
||||
async function saveFilter() {
|
||||
const response = await filterService.update(filter.value)
|
||||
await namespaceStore.loadNamespaces()
|
||||
dpschen marked this conversation as resolved
Outdated
konrad
commented
Added. Added.
|
||||
await projectStore.loadProjects()
|
||||
success({message: t('filters.edit.success')})
|
||||
response.filters = objectToSnakeCase(response.filters)
|
||||
filter.value = response
|
||||
|
@ -129,9 +129,9 @@ export function useSavedFilter(projectId?: MaybeRef<IProject['id']>) {
|
|||
|
||||
async function deleteFilter() {
|
||||
await filterService.delete(filter.value)
|
||||
await namespaceStore.loadNamespaces()
|
||||
dpschen marked this conversation as resolved
Outdated
konrad
commented
Added Added
|
||||
await projectStore.loadProjects()
|
||||
success({message: t('filters.delete.success')})
|
||||
router.push({name: 'namespaces.index'})
|
||||
router.push({name: 'projects.index'})
|
||||
}
|
||||
|
||||
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(
|
||||
{project, forceUpdate = false}: {project: IProject | null, forceUpdate?: boolean},
|
||||
) {
|
||||
if (project === null) {
|
||||
if (project === null || typeof project === 'undefined') {
|
||||
setCurrentProject({})
|
||||
setBackground('')
|
||||
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 {useI18n} from 'vue-i18n'
|
||||
import {useRouter} from 'vue-router'
|
||||
|
||||
import ProjectService from '@/services/project'
|
||||
import ProjectDuplicateService from '@/services/projectDuplicateService'
|
||||
import ProjectDuplicateModel from '@/models/projectDuplicateModel'
|
||||
import {setModuleLoading} from '@/stores/helper'
|
||||
import {removeProjectFromHistory} from '@/modules/projectHistory'
|
||||
import {createNewIndexer} from '@/indexes'
|
||||
import {useNamespaceStore} from './namespaces'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
|
@ -16,9 +18,7 @@ import ProjectModel from '@/models/project'
|
|||
import {success} from '@/message'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
const {add, remove, search, update} = createNewIndexer('projects', ['title', 'description'])
|
||||
|
||||
const FavoriteProjectsNamespace = -2
|
||||
const {remove, search, update} = createNewIndexer('projects', ['title', 'description'])
|
||||
|
||||
export interface ProjectState {
|
||||
[id: IProject['id']]: IProject
|
||||
|
@ -26,16 +26,22 @@ export interface ProjectState {
|
|||
|
||||
export const useProjectStore = defineStore('project', () => {
|
||||
const baseStore = useBaseStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
const router = useRouter()
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
// The projects are stored as an object which has the project ids as keys.
|
||||
const projects = ref<ProjectState>({})
|
||||
const projectsArray = computed(() => Object.values(projects.value)
|
||||
.sort((a, b) => a.position - b.position))
|
||||
const notArchivedRootProjects = computed(() => projectsArray.value
|
||||
dpschen
commented
Since Afaik there is no way around something like:
Since `projects` is of type object (defined by its type) this shouldn't work because `!!{} === true`.
Even if it would be undefined or null sometimes this should use `Boolean(projects.value)` for clarity instead.
Afaik there is no way around something like:
```ts
computed(() => Boolean(projectsArray.value.length))
```
konrad
commented
Fixed. Fixed.
|
||||
.filter(p => p.parentProjectId === 0 && !p.isArchived))
|
||||
const favoriteProjects = computed(() => projectsArray.value
|
||||
dpschen
commented
This computed seems really unnecessary. Reason: We can achieve the same (and faster) by using:
This computed seems really unnecessary. Reason: We can achieve the same (and faster) by using: `projects.value[id]`. Since `projects` is exported we should replace uses of this computed. We might need to create new simple computeds where used. Depending on usecase something like
```ts
const myProject = computed(() => projects.value[myProjectId.value])
```
konrad
commented
We've actually been using computed for most uses of the store computed anyway. I've changed it to use the We've actually been using computed for most uses of the store computed anyway. I've changed it to use the `projects` property of the store directly.
|
||||
.filter(p => !p.isArchived && p.isFavorite))
|
||||
const hasProjects = computed(() => projectsArray.value.length > 0)
|
||||
|
||||
|
||||
const getProjectById = computed(() => {
|
||||
return (id: IProject['id']) => typeof projects.value[id] !== 'undefined' ? projects.value[id] : null
|
||||
const getChildProjects = computed(() => {
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
The fallback is unnecessary OR the type of the projects ref is wrong. Because The fallback is unnecessary OR the type of the projects ref is wrong. Because `Object.values` on an object should always return an array.
konrad
commented
The type is correct. I've removed the fallback. The type is correct. I've removed the fallback.
dpschen
commented
The new type on the computed is wrong. The computed returns a function that returns But that should be automatically inferred by the returned function. If it is not inferreed, annotate the returned function instead and remove the type annotation of the computed. The new type on the computed is wrong. The computed returns a function that returns `IProject[]`. So the correct type is probably `(id: IProject['id']) => IProject[]`.
But that should be automatically inferred by the returned function. If it is not inferreed, annotate the returned function instead and remove the type annotation of the computed.
konrad
commented
Should be fixed now. Should be fixed now.
|
||||
return (id: IProject['id']) => projectsArray.value.filter(p => p.parentProjectId === id)
|
||||
})
|
||||
|
||||
const findProjectByExactname = computed(() => {
|
||||
|
@ -53,7 +59,7 @@ export const useProjectStore = defineStore('project', () => {
|
|||
?.filter(value => value > 0)
|
||||
.map(id => projects.value[id])
|
||||
.filter(project => project.isArchived === includeArchived)
|
||||
|| []
|
||||
|| []
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -65,16 +71,15 @@ export const useProjectStore = defineStore('project', () => {
|
|||
projects.value[project.id] = project
|
||||
update(project)
|
||||
|
||||
dpschen
commented
See comment about having the data as sub projects from earlier. See comment about having the data as sub projects from earlier.
|
||||
// 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) {
|
||||
dpschen
commented
The three lines above should be called via a deep watcher on the current project. Not as a side effect. The three lines above should be called via a deep watcher on the current project. Not as a side effect.
konrad
commented
You mean setting the current project in the base store? You mean setting the current project in the base store?
dpschen
commented
I mean:
It might be even better to make the currentProject a computed based on the store instead and only setting it via id (unsure here how to handle projects that are not saved yet) I mean:
1) remove this sideeffect of the `setProject` function:
```
if (baseStore.currentProject?.id === project.id) {
baseStore.setCurrentProject(project)
}
```
2) Instead add a watcher on the project that has the id of the current project.
```ts
watchEffect(() => baseStore.setCurrentProject(projects.value[baseStore.currentProject.id])
)
```
It might be even better to make the currentProject a computed based on the store instead and only setting it via id (unsure here how to handle projects that are not saved yet)
konrad
commented
I've now changed it to use a watcher.
That sounds like a good idea, but let's push that to another PR. I've now changed it to use a watcher.
> It might be even better to make the currentProject a computed based on the store instead and only setting it via id (unsure here how to handle projects that are not saved yet)
That sounds like a good idea, but let's push that to another PR.
konrad
commented
I've now changed it to use a watcher.
That sounds like a good idea, but let's push that to another PR. I've now changed it to use a watcher.
> It might be even better to make the currentProject a computed based on the store instead and only setting it via id (unsure here how to handle projects that are not saved yet)
That sounds like a good idea, but let's push that to another PR.
|
||||
baseStore.setCurrentProject(project)
|
||||
}
|
||||
}
|
||||
|
||||
function setProjects(newProjects: IProject[]) {
|
||||
newProjects.forEach(l => {
|
||||
projects.value[l.id] = l
|
||||
add(l)
|
||||
})
|
||||
newProjects.forEach(p => setProject(p))
|
||||
}
|
||||
|
||||
function removeProjectById(project: IProject) {
|
||||
|
@ -100,9 +105,11 @@ export const useProjectStore = defineStore('project', () => {
|
|||
|
||||
try {
|
||||
const createdProject = await projectService.create(project)
|
||||
createdProject.namespaceId = project.namespaceId
|
||||
dpschen
commented
If I set an id for If I set an id for `parentID` in the passed project: will this update the parents childProjects?
konrad
commented
It did not. While checking this, I discovered how we're not using child ids anywhere any more (including in the api) so I've removed all traces of them. It did not. While checking this, I discovered how we're not using child ids anywhere any more (including in the api) so I've removed all traces of them.
|
||||
namespaceStore.addProjectToNamespace(createdProject)
|
||||
setProject(createdProject)
|
||||
router.push({
|
||||
name: 'project.index',
|
||||
params: { projectId: createdProject.id },
|
||||
})
|
||||
return createdProject
|
||||
} finally {
|
||||
cancel()
|
||||
|
@ -112,26 +119,14 @@ export const useProjectStore = defineStore('project', () => {
|
|||
async function updateProject(project: IProject) {
|
||||
const cancel = setModuleLoading(setIsLoading)
|
||||
const projectService = new ProjectService()
|
||||
|
||||
|
||||
try {
|
||||
await projectService.update(project)
|
||||
const updatedProject = await projectService.update(project)
|
||||
setProject(project)
|
||||
namespaceStore.setProjectInNamespaceById(project)
|
||||
|
||||
// the returned project from projectService.update is the same!
|
||||
// in order to not create a manipulation in pinia store we have to create a new copy
|
||||
const newProject = {
|
||||
...project,
|
||||
namespaceId: FavoriteProjectsNamespace,
|
||||
}
|
||||
|
||||
namespaceStore.removeProjectFromNamespaceById(newProject)
|
||||
if (project.isFavorite) {
|
||||
namespaceStore.addProjectToNamespace(newProject)
|
||||
}
|
||||
namespaceStore.loadNamespacesIfFavoritesDontExist()
|
||||
namespaceStore.removeFavoritesNamespaceIfEmpty()
|
||||
return newProject
|
||||
return updatedProject
|
||||
} catch (e) {
|
||||
// Reset the project state to the initial one to avoid confusion for the user
|
||||
setProject({
|
||||
|
@ -151,7 +146,6 @@ export const useProjectStore = defineStore('project', () => {
|
|||
try {
|
||||
const response = await projectService.delete(project)
|
||||
removeProjectById(project)
|
||||
namespaceStore.removeProjectFromNamespaceById(project)
|
||||
removeProjectFromHistory({id: project.id})
|
||||
return response
|
||||
} finally {
|
||||
|
@ -159,11 +153,42 @@ export const useProjectStore = defineStore('project', () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
const cancel = setModuleLoading(setIsLoading)
|
||||
|
||||
dpschen
commented
Unsure: we might want to return the return value of setProjects here instead. Because maybe the projects will be modfied while being set. So it will be better to return from that method OR filter and return the projects with the id from the store object. Unsure: we might want to return the return value of setProjects here instead. Because maybe the projects will be modfied while being set. So it will be better to return from that method OR filter and return the projects with the id from the store object.
konrad
commented
But These methods don't modify anything. I think it's fine to leave it like it is. But `setProjects` does not return anything? Neither does `setProject`.
These methods don't modify anything. I think it's fine to leave it like it is.
|
||||
const projectService = new ProjectService()
|
||||
try {
|
||||
const loadedProjects = await projectService.getAll({}, {is_archived: true}) as IProject[]
|
||||
projects.value = {}
|
||||
setProjects(loadedProjects)
|
||||
|
||||
dpschen
commented
When I read this first I thought that this implies that one project could have several parents. If I got it correct that's wrong. So how about When I read this first I thought that this implies that one project could have several parents. If I got it correct that's wrong. So how about `getAncestors` instead?
konrad
commented
I like that. Changed it. I like that. Changed it.
|
||||
return loadedProjects
|
||||
} finally {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
function getAncestors(project: IProject): IProject[] {
|
||||
if (!project?.parentProjectId) {
|
||||
return [project]
|
||||
}
|
||||
|
||||
const parentProject = projects.value[project.parentProjectId]
|
||||
return [
|
||||
...getAncestors(parentProject),
|
||||
project,
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading: readonly(isLoading),
|
||||
projects: readonly(projects),
|
||||
projectsArray: readonly(projectsArray),
|
||||
notArchivedRootProjects: readonly(notArchivedRootProjects),
|
||||
favoriteProjects: readonly(favoriteProjects),
|
||||
hasProjects: readonly(hasProjects),
|
||||
|
||||
getProjectById,
|
||||
getChildProjects,
|
||||
findProjectByExactname,
|
||||
searchProject,
|
||||
|
||||
|
@ -171,17 +196,24 @@ export const useProjectStore = defineStore('project', () => {
|
|||
setProjects,
|
||||
removeProjectById,
|
||||
toggleProjectFavorite,
|
||||
loadProjects,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
getAncestors,
|
||||
}
|
||||
})
|
||||
|
||||
export function useProject(projectId: MaybeRef<IProject['id']>) {
|
||||
const projectService = shallowReactive(new ProjectService())
|
||||
const {loading: isLoading} = toRefs(projectService)
|
||||
const projectDuplicateService = shallowReactive(new ProjectDuplicateService())
|
||||
|
||||
const isLoading = computed(() => projectService.loading || projectDuplicateService.loading)
|
||||
const project: IProject = reactive(new ProjectModel())
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const router = useRouter()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
watch(
|
||||
dpschen
commented
If the parent should be editable the parent should use If the parent should be editable the parent should use `useProject` itself. I our target here is to make the parentProjectId updateable: why do we need to export an extra object, since the parentProjectId is already part of the project? If not: could we use a computed getter / setter here instead?
konrad
commented
I've moved it out of the composable. It's only used in one place anyway... I've moved it out of the composable. It's only used in one place anyway...
|
||||
() => unref(projectId),
|
||||
|
@ -192,20 +224,34 @@ export function useProject(projectId: MaybeRef<IProject['id']>) {
|
|||
{immediate: true},
|
||||
)
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
async function save() {
|
||||
await projectStore.updateProject(project)
|
||||
const updatedProject = await projectStore.updateProject(project)
|
||||
Object.assign(project, updatedProject)
|
||||
success({message: t('project.edit.success')})
|
||||
dpschen
commented
This changes project before calling update. was that intended? What happens if This changes project before calling update. was that intended? What happens if `parentProject.value.id` is undefined?
konrad
commented
It was not intended. I've changed it now so that it checks it before and provides a proper fallback. It was not intended. I've changed it now so that it checks it before and provides a proper fallback.
|
||||
}
|
||||
|
||||
async function duplicateProject(parentProjectId: IProject['id']) {
|
||||
const projectDuplicate = new ProjectDuplicateModel({
|
||||
projectId: unref(projectId),
|
||||
parentProjectId,
|
||||
})
|
||||
|
||||
const duplicate = await projectDuplicateService.create(projectDuplicate)
|
||||
|
||||
projectStore.setProject(duplicate.project)
|
||||
success({message: t('project.duplicate.success')})
|
||||
router.push({name: 'project.index', params: {projectId: duplicate.project.id}})
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading: readonly(isLoading),
|
||||
project,
|
||||
save,
|
||||
duplicateProject,
|
||||
}
|
||||
}
|
||||
|
||||
// support hot reloading
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useProjectStore, import.meta.hot))
|
||||
import.meta.hot.accept(acceptHMRUpdate(useProjectStore, import.meta.hot))
|
||||
}
|
|
@ -432,6 +432,17 @@ export const useTaskStore = defineStore('task', () => {
|
|||
coverImageAttachmentId: attachment ? attachment.id : 0,
|
||||
})
|
||||
}
|
||||
|
||||
async function toggleFavorite(task: ITask) {
|
||||
const taskService = new TaskService()
|
||||
task.isFavorite = !task.isFavorite
|
||||
task = await taskService.update(task)
|
||||
|
||||
// reloading the projects list so that the Favorites project shows up or is hidden when there are (or are not) favorite tasks
|
||||
await projectStore.loadProjects()
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
|
@ -453,6 +464,7 @@ export const useTaskStore = defineStore('task', () => {
|
|||
setCoverImage,
|
||||
findProjectId,
|
||||
ensureLabelsExist,
|
||||
toggleFavorite,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -33,3 +33,7 @@ $switch-view-height: 2.69rem;
|
|||
|
||||
$navbar-height: 4rem;
|
||||
$navbar-width: 300px;
|
||||
$navbar-padding: 2rem;
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
These were meant as component local variables. If we define them now as global we probably should replace them with css variables instead. These were meant as component local variables.
If we define them now as global we probably should replace them with css variables instead.
konrad
commented
I think it's fine to use it as a scss variable since it will never change at runtime. I think it's fine to use it as a scss variable since it will never change at runtime.
|
||||
|
||||
$vikunja-nav-color: var(--grey-700);
|
||||
$vikunja-nav-selected-width: 0.4rem;
|
||||
|
|
|
@ -8,4 +8,5 @@
|
|||
@import "link-share";
|
||||
@import "loading";
|
||||
@import "flatpickr";
|
||||
@import 'helpers';
|
||||
@import 'helpers';
|
||||
@import 'navigation';
|
||||
dpschen
commented
Since these styles are only used in the navigation component: import them only there Since these styles are only used in the navigation component: import them only there
konrad
commented
This only works if the imported styles are not scoped to the navigation component. I could still import them in a non-scoped This only works if the imported styles are not scoped to the navigation component. I could still import them in a non-scoped `style` tag in that component but I guess it's probably better to refactor this whole thing at some point so that it uses individual components instead of one global style sheet (not in this PR)
|
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,
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
`menu-label` isn't used anymore.
konrad
commented
Removed. Removed.
|
||||
.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"
|
||||
class="is-max-width-desktop"
|
||||
/>
|
||||
<template v-if="!hasTasks && !loading">
|
||||
<template v-if="defaultNamespaceId > 0">
|
||||
<p class="mt-4">{{ $t('home.project.newText') }}</p>
|
||||
<x-button
|
||||
dpschen
commented
Why did we remove this button? Why did we remove this button?
konrad
commented
Because it is related to having a namespace and no projects. With the new changes, there will always be at least one project for the user, which means this would never be shown anyway. Because it is related to having a namespace and no projects. With the new changes, there will always be at least one project for the user, which means this would never be shown anyway.
|
||||
:to="{ name: 'project.create', params: { namespaceId: defaultNamespaceId } }"
|
||||
:shadow="false"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ $t('home.project.new') }}
|
||||
</x-button>
|
||||
</template>
|
||||
<p class="mt-4" v-if="migratorsEnabled">
|
||||
<template v-if="!hasTasks && !loading && migratorsEnabled">
|
||||
<p class="mt-4">
|
||||
{{ $t('home.project.importText') }}
|
||||
</p>
|
||||
<x-button
|
||||
v-if="migratorsEnabled"
|
||||
:to="{ name: 'migrate.start' }"
|
||||
:shadow="false">
|
||||
{{ $t('home.project.import') }}
|
||||
|
@ -43,7 +32,7 @@
|
|||
<ProjectCardGrid :projects="projectHistory" v-cy="'projectCardGrid'" />
|
||||
</div>
|
||||
<ShowTasks
|
||||
v-if="hasProjects"
|
||||
v-if="projectStore.hasProjects"
|
||||
class="show-tasks"
|
||||
:key="showTasksKey"
|
||||
/>
|
||||
|
@ -66,17 +55,14 @@ import {useDaytimeSalutation} from '@/composables/useDaytimeSalutation'
|
|||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const salutation = useDaytimeSalutation()
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
const projectStore = useProjectStore()
|
||||
const taskStore = useTaskStore()
|
||||
|
||||
|
@ -87,14 +73,12 @@ const projectHistory = computed(() => {
|
|||
}
|
||||
|
||||
return getHistory()
|
||||
.map(l => projectStore.getProjectById(l.id))
|
||||
.filter((l): l is IProject => l !== null)
|
||||
.map(l => projectStore.projects[l.id])
|
||||
.filter(l => Boolean(l))
|
||||
})
|
||||
|
||||
const migratorsEnabled = computed(() => configStore.availableMigrators?.length > 0)
|
||||
const hasTasks = computed(() => baseStore.hasTasks)
|
||||
const defaultNamespaceId = computed(() => namespaceStore.namespaces?.[0]?.id || 0)
|
||||
const hasProjects = computed(() => namespaceStore.namespaces?.[0]?.projects.length > 0)
|
||||
const loading = computed(() => taskStore.isLoading)
|
||||
const deletionScheduledAt = computed(() => parseDateOrNull(authStore.info?.deletionScheduledAt))
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
This should be a computed of the store, like This should be a computed of the store, like `hasTasks`.
Shouldn't we check the length here (`projectStore.projects?.length`)?
konrad
commented
`projects` is an object, so checking the length of it won't work.
konrad
commented
Moved it to the store. Moved it to the store.
dpschen
commented
Then Then `Object.keys?.length`
konrad
commented
Wouldn't that cause a call to > Then `Object.keys?.length`
Wouldn't that cause a call to `Object.keys`? I think my solution is faster.
konrad
commented
Checked it: https://jsbench.me/hslfseq93y/1 Using Checked it: https://jsbench.me/hslfseq93y/1
Using `Object.keys` really is slower, and by a lot.
dpschen
commented
True. Using a Map with size would probably be the perfect solution here. But that is something for another time. True. Using a Map with size would probably be the perfect solution here. But that is something for another time.
dpschen
commented
Don't wrap computed in computed. use Don't wrap computed in computed. use `projectStore.hasProjects` directly
konrad
commented
Done Done
|
||||
|
||||
|
|
|
@ -89,8 +89,8 @@ import {formatDateLong} from '@/helpers/time/formatDate'
|
|||
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
|
||||
|
||||
import {MIGRATORS} from './migrators'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const PROGRESS_DOTS_COUNT = 8
|
||||
|
||||
|
@ -163,8 +163,8 @@ async function migrate() {
|
|||
? await migrationFileService.migrate(migrationConfig as File)
|
||||
: await migrationService.migrate(migrationConfig as MigrationConfig)
|
||||
message.value = result.message
|
||||
const namespaceStore = useNamespaceStore()
|
||||
return namespaceStore.loadNamespaces()
|
||||
const projectStore = useProjectStore()
|
||||
return projectStore.loadProjects()
|
||||
} finally {
|
||||
isMigrating.value = false
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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>
|
||||
dpschen
commented
Why is this commented out? Why is this commented out?
konrad
commented
Because when I started this PR I was unsure if I might want to bring back pieces of it. Then I just forgot to remove it (done now). Because when I started this PR I was unsure if I might want to bring back pieces of it. Then I just forgot to remove it (done now).
|
||||
|
||||
<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;
|
||||
dpschen
commented
Create a new exported computed from the store Create a new exported computed from the store `archivedProjects`. We can switch between the normal projectsArray and the archived computed when returning here.
konrad
commented
But this shouldn't show only archived projects but archived and unarchived. Having a computed called But this shouldn't show _only_ archived projects but archived and unarchived. Having a computed called `archivedProjects` would sound like it only shows archived projects which does not really make sense.
dpschen
commented
Isn't that what
This way we don't have to filter it if we don't need to. Isn't that what `projectsArray` already is (list of archived and unarchived projects)?
```ts
const projects = computed(() => showArchived.value
? projectStore.projectsArray
: projectStore.projectsArray.filter(({isArchived}) => !isArchived)
})
```
This way we don't have to filter it if we don't need to.
konrad
commented
Good catch, I've now changed it. Good catch, I've now changed it.
|
||||
}
|
||||
</style>
|
|
@ -1,5 +1,9 @@
|
|||
<template>
|
||||
<create-edit :title="$t('project.create.header')" @create="createNewProject()" :primary-disabled="project.title === ''">
|
||||
<create-edit
|
||||
:title="$t('project.create.header')"
|
||||
@create="createNewProject()"
|
||||
:primary-disabled="project.title === ''"
|
||||
>
|
||||
<div class="field">
|
||||
<label class="label" for="projectTitle">{{ $t('project.title') }}</label>
|
||||
<div
|
||||
|
@ -22,19 +26,24 @@
|
|||
<p class="help is-danger" v-if="showError && project.title === ''">
|
||||
{{ $t('project.create.addTitleRequired') }}
|
||||
</p>
|
||||
<div class="field" v-if="projectStore.hasProjects">
|
||||
<label class="label">{{ $t('project.parent') }}</label>
|
||||
<div class="control">
|
||||
<project-search v-model="parentProject"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('project.color') }}</label>
|
||||
<div class="control">
|
||||
<color-picker v-model="project.hexColor" />
|
||||
<color-picker v-model="project.hexColor"/>
|
||||
</div>
|
||||
</div>
|
||||
</create-edit>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive, shallowReactive} from 'vue'
|
||||
import {ref, reactive, shallowReactive, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useRouter, useRoute} from 'vue-router'
|
||||
|
||||
import ProjectService from '@/services/project'
|
||||
import ProjectModel from '@/models/project'
|
||||
|
@ -44,10 +53,10 @@ import ColorPicker from '@/components/input/ColorPicker.vue'
|
|||
import {success} from '@/message'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
useTitle(() => t('project.create.header'))
|
||||
|
||||
|
@ -55,6 +64,17 @@ const showError = ref(false)
|
|||
const project = reactive(new ProjectModel())
|
||||
const projectService = shallowReactive(new ProjectService())
|
||||
const projectStore = useProjectStore()
|
||||
dpschen
commented
Use a computed getter / setter for parent project via Use a computed getter / setter for parent project via `project.id`.
konrad
commented
But this is only used to store the selection of the new parent project? That will never change from anywhere other than the project search component. But this is only used to store the selection of the new parent project? That will never change from anywhere other than the project search component.
dpschen
commented
See this comment: #3323 (comment) See this comment: https://kolaente.dev/vikunja/frontend/pulls/3323#issuecomment-49061
dpschen
commented
Main point is: source of truth should be url. And url will set parentId inside the Main point is: source of truth should be url. And url will set parentId inside the `project` reactive. The `parentProject` should be a computed that is filled based on that id. If the id changes (everything else shouldn't change) that id will be updated inside the `project` reactive. I guess it would also be nice to replace / update the url as well but that was not my main point here. Edit: see also this https://kolaente.dev/vikunja/frontend/pulls/3323#issuecomment-48860
|
||||
const parentProject = ref<IProject | null>(null)
|
||||
dpschen
commented
Add immediate watcher from route. Watch for parentProjectId. Add immediate watcher from route. Watch for parentProjectId.
konrad
commented
But the route will never have a project id? (or parent project id for that matter) The route is always But the route will never have a project id? (or parent project id for that matter)
The route is always `/projects/new` and it's not possible to create a project from the context menu of an existing project.
dpschen
commented
We do remove functionality here if we disallow this. Because with namespaces it was possible to say in which namespace I wanted to create a new list via a url parameter. Since root projects are the pendant of the old namespaces we should keep the functionality to add new projects inside others directly. > But the route will never have a project id? (or parent project id for that matter)
>
> The route is always `/projects/new` and it's not possible to create a project from the context menu of an existing project.
We __do__ remove functionality here if we disallow this.
Because with namespaces it was possible to say in which namespace I wanted to create a new list via a url parameter. Since root projects are the pendant of the old namespaces we should keep the functionality to add new projects inside others directly.
konrad
commented
I've now added this. It may not be the most optimal version but I think it's good enough. I've now added this. It may not be the most optimal version but I think it's good enough.
|
||||
|
||||
const props = defineProps<{
|
||||
parentProjectId?: number,
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.parentProjectId,
|
||||
() => parentProject.value = projectStore.projects[props.parentProjectId],
|
||||
dpschen
commented
If we have the computed getter / setter this shouldn't be necessary If we have the computed getter / setter this shouldn't be necessary
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
async function createNewProject() {
|
||||
if (project.title === '') {
|
||||
dpschen
commented
Push to new project route in store. Push to new project route in store.
konrad
commented
Done. Done.
|
||||
|
@ -63,12 +83,11 @@ async function createNewProject() {
|
|||
}
|
||||
showError.value = false
|
||||
|
||||
project.namespaceId = Number(route.params.namespaceId as string)
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
If we create a project inside another one doesn't it still make sense to pass the parents id down? That was the idea of that parameter. If we create a project inside another one doesn't it still make sense to pass the parents id down? That was the idea of that parameter.
konrad
commented
See #3323 (comment) See https://kolaente.dev/vikunja/frontend/pulls/3323#issuecomment-47666
konrad
commented
It's now possible to set a parent project when creating or editing a project as well. It's now possible to set a parent project when creating or editing a project as well.
|
||||
const newProject = await projectStore.createProject(project)
|
||||
await router.push({
|
||||
name: 'project.index',
|
||||
params: { projectId: newProject.id },
|
||||
})
|
||||
success({message: t('project.create.createdSuccess') })
|
||||
if (parentProject.value) {
|
||||
project.parentProjectId = parentProject.value.id
|
||||
}
|
||||
|
||||
await projectStore.createProject(project)
|
||||
success({message: t('project.create.createdSuccess')})
|
||||
}
|
||||
</script>
|
|
@ -75,7 +75,7 @@ const GanttChart = createAsyncComponent(() => import('@/components/tasks/GanttCh
|
|||
const props = defineProps<{route: RouteLocationNormalized}>()
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const canWrite = computed(() => baseStore.currentProject.maxRight > RIGHTS.READ)
|
||||
const canWrite = computed(() => baseStore.currentProject?.maxRight > RIGHTS.READ)
|
||||
|
||||
const {route} = toRefs(props)
|
||||
const {
|
||||
|
|
|
@ -29,7 +29,7 @@ const props = defineProps({
|
|||
})
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const project = computed(() => projectStore.getProjectById(props.projectId))
|
||||
const project = computed(() => projectStore.projects[props.projectId])
|
||||
const htmlDescription = computed(() => {
|
||||
const description = project.value?.description || ''
|
||||
if (description === '') {
|
||||
|
|
|
@ -330,7 +330,7 @@ const bucketDraggableComponentData = computed(() => ({
|
|||
],
|
||||
}))
|
||||
|
||||
const canWrite = computed(() => baseStore.currentProject.maxRight > Rights.READ)
|
||||
const canWrite = computed(() => baseStore.currentProject?.maxRight > Rights.READ)
|
||||
const project = computed(() => baseStore.currentProject)
|
||||
|
||||
const buckets = computed(() => kanbanStore.buckets)
|
||||
|
|
|
@ -31,7 +31,7 @@ const projectStore = useProjectStore()
|
|||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const project = computed(() => projectStore.getProjectById(route.params.projectId))
|
||||
const project = computed(() => projectStore.projects[route.params.projectId])
|
||||
useTitle(() => t('project.archive.title', {project: project.value.title}))
|
||||
|
||||
async function archiveProject() {
|
||||
|
|
|
@ -108,7 +108,6 @@ import CustomTransition from '@/components/misc/CustomTransition.vue'
|
|||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
import BackgroundUnsplashService from '@/services/backgroundUnsplash'
|
||||
|
@ -146,7 +145,6 @@ const debounceNewBackgroundSearch = debounce(newBackgroundSearch, SEARCH_DEBOUNC
|
|||
const backgroundUploadService = ref(new BackgroundUploadService())
|
||||
const projectService = ref(new ProjectService())
|
||||
const projectStore = useProjectStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const unsplashBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('unsplash'))
|
||||
|
@ -195,7 +193,6 @@ async function setBackground(backgroundId: string) {
|
|||
projectId: route.params.projectId,
|
||||
})
|
||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||
namespaceStore.setProjectInNamespaceById(project)
|
||||
projectStore.setProject(project)
|
||||
success({message: t('project.background.success')})
|
||||
}
|
||||
|
@ -211,7 +208,6 @@ async function uploadBackground() {
|
|||
backgroundUploadInput.value?.files[0],
|
||||
)
|
||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||
namespaceStore.setProjectInNamespaceById(project)
|
||||
projectStore.setProject(project)
|
||||
success({message: t('project.background.success')})
|
||||
}
|
||||
|
@ -219,7 +215,6 @@ async function uploadBackground() {
|
|||
async function removeBackground() {
|
||||
const project = await projectService.value.removeBackground(currentProject.value)
|
||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||
namespaceStore.setProjectInNamespaceById(project)
|
||||
projectStore.setProject(project)
|
||||
success({message: t('project.background.removeSuccess')})
|
||||
router.back()
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
totalTasks > 0 ? $t('project.delete.tasksToDelete', {count: totalTasks}) : $t('project.delete.noTasksToDelete')
|
||||
}}
|
||||
</strong>
|
||||
<Loading v-else class="is-loading-small"/>
|
||||
<Loading v-else class="is-loading-small" variant="default"/>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
|
@ -43,7 +43,7 @@ const router = useRouter()
|
|||
|
||||
const totalTasks = ref<number | null>(null)
|
||||
|
||||
const project = computed(() => projectStore.getProjectById(route.params.projectId))
|
||||
const project = computed(() => projectStore.projects[route.params.projectId])
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
|
|
|
@ -3,73 +3,46 @@
|
|||
:title="$t('project.duplicate.title')"
|
||||
primary-icon="paste"
|
||||
:primary-label="$t('project.duplicate.label')"
|
||||
@primary="duplicateProject"
|
||||
:loading="projectDuplicateService.loading"
|
||||
@primary="duplicate"
|
||||
:loading="isLoading"
|
||||
>
|
||||
<p>{{ $t('project.duplicate.text') }}</p>
|
||||
|
||||
<Multiselect
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
This should be replaced by a project selector in order to select the new parent project. This should be replaced by a project selector in order to select the new parent project.
konrad
commented
See #3323 (comment) See https://kolaente.dev/vikunja/frontend/pulls/3323#issuecomment-47666
dpschen
commented
I don't think that in the case of duplication dragging inside the sidebar does replace that. Dragging on mobile also isn't working very well currently and has generally accessability issues. I don't think that in the case of duplication dragging inside the sidebar does replace that. Dragging on mobile also isn't working very well currently and has generally accessability issues.
konrad
commented
I've now added a project select when duplicating I've now added a project select when duplicating
|
||||
:placeholder="$t('namespace.search')"
|
||||
@search="findNamespaces"
|
||||
:search-results="namespaces"
|
||||
@select="selectNamespace"
|
||||
label="title"
|
||||
:search-delay="10"
|
||||
/>
|
||||
<project-search v-model="parentProject"/>
|
||||
</create-edit>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {ref, watch} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import ProjectDuplicateService from '@/services/projectDuplicateService'
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
import ProjectDuplicateModel from '@/models/projectDuplicateModel'
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
|
||||
|
||||
import {success} from '@/message'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useNamespaceSearch} from '@/composables/useNamespaceSearch'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
import {useProject, useProjectStore} from '@/stores/projects'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => t('project.duplicate.title'))
|
||||
|
||||
const {
|
||||
namespaces,
|
||||
findNamespaces,
|
||||
} = useNamespaceSearch()
|
||||
|
||||
const selectedNamespace = ref<INamespace>()
|
||||
|
||||
function selectNamespace(namespace: INamespace) {
|
||||
selectedNamespace.value = namespace
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projectStore = useProjectStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
|
||||
const projectDuplicateService = shallowReactive(new ProjectDuplicateService())
|
||||
const {project, isLoading, duplicateProject} = useProject(route.params.projectId)
|
||||
|
||||
async function duplicateProject() {
|
||||
const projectDuplicate = new ProjectDuplicateModel({
|
||||
// FIXME: should be parameter
|
||||
projectId: route.params.projectId,
|
||||
namespaceId: selectedNamespace.value?.id,
|
||||
})
|
||||
const parentProject = ref<IProject | null>(null)
|
||||
watch(
|
||||
() => project.parentProjectId,
|
||||
parentProjectId => {
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Use Use `useProject` to get current project here and add `duplicateProject` method.
konrad
commented
Done. Done.
|
||||
parentProject.value = projectStore.projects[parentProjectId]
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const duplicate = await projectDuplicateService.create(projectDuplicate)
|
||||
|
||||
namespaceStore.addProjectToNamespace(duplicate.project)
|
||||
projectStore.setProject(duplicate.project)
|
||||
async function duplicate() {
|
||||
await duplicateProject(parentProject.value.id)
|
||||
success({message: t('project.duplicate.success')})
|
||||
router.push({name: 'project.index', params: {projectId: duplicate.project.id}})
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -42,6 +42,12 @@
|
|||
v-model="project.identifier"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('project.parent') }}</label>
|
||||
<div class="control">
|
||||
<project-search v-model="parentProject"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="projectdescription">{{ $t('project.edit.description') }}</label>
|
||||
<div class="control">
|
||||
|
@ -66,21 +72,23 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'project-setting-edit' }
|
||||
export default {name: 'project-setting-edit'}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {PropType} from 'vue'
|
||||
import {watch, ref, type PropType} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import Editor from '@/components/input/AsyncEditor'
|
||||
import ColorPicker from '@/components/input/ColorPicker.vue'
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useProject} from '@/stores/projects'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
@ -93,14 +101,27 @@ const props = defineProps({
|
|||
})
|
||||
|
||||
const router = useRouter()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Use computed getter / setter to set parentProjectId of project instead. Use computed getter / setter to set parentProjectId of project instead.
konrad
commented
I've changed this since moving the parent project logic out of the composable. I've changed this since moving the parent project logic out of the composable.
|
||||
|
||||
const {project, save: saveProject, isLoading} = useProject(props.projectId)
|
||||
|
||||
const parentProject = ref<IProject | null>(null)
|
||||
watch(
|
||||
() => project.parentProjectId,
|
||||
projectId => {
|
||||
if (project.parentProjectId) {
|
||||
parentProject.value = projectStore.projects[project.parentProjectId]
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
useTitle(() => project?.title ? t('project.edit.title', {project: project.title}) : '')
|
||||
|
||||
async function save() {
|
||||
project.parentProjectId = parentProject.value?.id ?? project.parentProjectId
|
||||
await saveProject()
|
||||
await useBaseStore().handleSetCurrentProject({project})
|
||||
router.back()
|
||||
|
|
|
@ -13,11 +13,13 @@
|
|||
:can-write="canWrite"
|
||||
ref="heading"
|
||||
/>
|
||||
<h6 class="subtitle" v-if="parent && parent.namespace && parent.project">
|
||||
{{ getNamespaceTitle(parent.namespace) }} ›
|
||||
dpschen
commented
Shouldn't we still show the parent here? Shouldn't we still show the parent here?
konrad
commented
I've changed it now so that is shows this: (each project is clickable individually) For a hierarchy like this: I've changed it now so that is shows this:
![Screenshot_20230328_174428.png](/attachments/35babb55-b48a-49bf-a595-3fcc28da12dc)
(each project is clickable individually)
For a hierarchy like this:
![Screenshot_20230328_174646.png](/attachments/070d677e-d199-45ee-af6b-c0a6b3196681)
dpschen
commented
Can't see these images either Can't see these images either
konrad
commented
This starts to feel like a gitea bug... This starts to feel like a gitea bug...
|
||||
<router-link :to="{ name: 'project.index', params: { projectId: parent.project.id } }">
|
||||
{{ getProjectTitle(parent.project) }}
|
||||
</router-link>
|
||||
<h6 class="subtitle" v-if="project?.id">
|
||||
<template v-for="p in projectStore.getAncestors(project)" :key="p.id">
|
||||
dpschen
commented
This won't update dynamically. Should we change the This won't update dynamically. Should we change the `getParentProjects` to a computed `parentProjects` that automatically updates instead?
konrad
commented
I tried to change it but it fails with I tried to change it but it fails with `getAncestors is not a function`. Any idea?
konrad
commented
Actually this does update dynamically when the project changes. I've had a task open, moved the project to another parent project and it updated instantly. Actually this does update dynamically when the project changes. I've had a task open, moved the project to another parent project and it updated instantly.
|
||||
<router-link :to="{ name: 'project.index', params: { projectId: p.id } }">
|
||||
{{ getProjectTitle(p) }}
|
||||
</router-link>
|
||||
<span class="has-text-grey-light" v-if="p.id !== project?.id"> > </span>
|
||||
dpschen
commented
Don't end with the error sign. Only use it in between two ancestors or as separator to task title if the latter is direclty next to it. Don't end with the error sign. Only use it in between two ancestors or as separator to task title if the latter is direclty next to it.
konrad
commented
But that's what this does? But that's what this does?
|
||||
</template>
|
||||
</h6>
|
||||
|
||||
<checklist-summary :task="task"/>
|
||||
|
@ -448,7 +450,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, reactive, toRef, shallowReactive, computed, watch, nextTick, type PropType} from 'vue'
|
||||
import {ref, reactive, toRef, shallowReactive, computed, watch, watchEffect, nextTick, type PropType} from 'vue'
|
||||
import {useRouter, type RouteLocation} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {unrefElement} from '@vueuse/core'
|
||||
|
@ -486,12 +488,10 @@ import TaskSubscription from '@/components/misc/subscription.vue'
|
|||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
import {uploadFile} from '@/helpers/attachments'
|
||||
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
|
||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||
import {scrollIntoView} from '@/helpers/scrollIntoView'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
import {useAttachmentStore} from '@/stores/attachments'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
import {useKanbanStore} from '@/stores/kanban'
|
||||
|
@ -500,6 +500,7 @@ import {useTitle} from '@/composables/useTitle'
|
|||
|
||||
import {success} from '@/message'
|
||||
import type {Action as MessageAction} from '@/message'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const props = defineProps({
|
||||
taskId: {
|
||||
|
@ -517,7 +518,7 @@ const router = useRouter()
|
|||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
const projectStore = useProjectStore()
|
||||
const attachmentStore = useAttachmentStore()
|
||||
const taskStore = useTaskStore()
|
||||
const kanbanStore = useKanbanStore()
|
||||
|
@ -538,32 +539,13 @@ const visible = ref(false)
|
|||
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
`getProjectById` returns `undefined` if there is no project with that id. So why not use that directly?
konrad
commented
Done Done
|
||||
const taskId = toRef(props, 'taskId')
|
||||
|
||||
const parent = computed(() => {
|
||||
if (!task.projectId) {
|
||||
return {
|
||||
namespace: null,
|
||||
project: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (!namespaceStore.getProjectAndNamespaceById) {
|
||||
return null
|
||||
}
|
||||
|
||||
return namespaceStore.getProjectAndNamespaceById(task.projectId)
|
||||
const project = computed(() => projectStore.projects[task.projectId])
|
||||
watchEffect(() => {
|
||||
baseStore.handleSetCurrentProject({
|
||||
project: project.value,
|
||||
})
|
||||
})
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Very bad pattern. A side effect of a computed can lead to various weird issues. Instead there should be a watcher! Why not use
__Very__ bad pattern.
A side effect of a computed can lead to various weird issues. Instead there should be a watcher! Why not use `getProjectById` directly?
```ts
const project = computed(() => projectStore.getProjectById(task.projectId)})
watchEffect(() => {
baseStore.handleSetCurrentProject({
project: project.value
})
})
```
konrad
commented
I don't even know why I moved that into the computed instead of leaving the watcher as it was before. Changed it back now. I don't even know why I moved that into the computed instead of leaving the watcher as it was before. Changed it back now.
|
||||
|
||||
watch(
|
||||
parent,
|
||||
(parent) => {
|
||||
const parentProject = parent !== null ? parent.project : null
|
||||
if (parentProject !== null) {
|
||||
baseStore.handleSetCurrentProject({project: parentProject})
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const canWrite = computed(() => (
|
||||
task.maxRight !== null &&
|
||||
task.maxRight > RIGHTS.READ
|
||||
|
@ -772,10 +754,8 @@ async function changeProject(project: IProject) {
|
|||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
task.isFavorite = !task.isFavorite
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
It seems like this should be replaced with what you wrote above:
It seems like this should be replaced with what you wrote above:
```ts
await projectStore.loadProjects() // reloading the projects list so that the Favorites project shows up or is hidden when there are (or are not) favorite tasks
```
dpschen
commented
Maybe it would be better to create a Maybe it would be better to create a `toggleFavorite` method of the task store instead, that includes that call so we don't forget it in other occasions when we change the favorite of a task.
konrad
commented
I thought of that as well, but completely forgot to check the task detail view. I've now created that method and replaced its usages. > Maybe it would be better to create a toggleFavorite method of the task store instead, that includes that call so we don't forget it in other occasions when we change the favorite of a task.
I thought of that as well, but completely forgot to check the task detail view. I've now created that method and replaced its usages.
dpschen
commented
You forgot to await the new store method > I've now created that method and replaced its usages.
You forgot to await the new store method
konrad
commented
whoops, fixed now. whoops, fixed now.
|
||||
const newTask = await taskService.update(task)
|
||||
const newTask = await taskStore.toggleFavorite(task.value)
|
||||
Object.assign(task, newTask)
|
||||
await namespaceStore.loadNamespacesIfFavoritesDontExist()
|
||||
}
|
||||
|
||||
async function setPriority(priority: Priority) {
|
||||
|
|
|
@ -245,7 +245,7 @@ watch(
|
|||
|
||||
const projectStore = useProjectStore()
|
||||
const defaultProject = computed({
|
||||
get: () => projectStore.getProjectById(settings.value.defaultProjectId) || undefined,
|
||||
get: () => projectStore.projects[settings.value.defaultProjectId],
|
||||
set(l) {
|
||||
settings.value.defaultProjectId = l ? l.id : DEFAULT_PROJECT_ID
|
||||
},
|
||||
|
|
Add types to
env.d.ts
Even though this env variable only works in Docker and is translated to a window. variable at runtime? We don't do this for the other variables either...