diff --git a/Dockerfile b/Dockerfile index 50de2fa5a..a1a40eab4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/cypress.config.ts b/cypress.config.ts index 16f7ab8ef..79baa0e15 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -24,4 +24,5 @@ export default defineConfig({ }, viewportWidth: 1600, viewportHeight: 900, + experimentalMemoryManagement: true, }) diff --git a/cypress/e2e/misc/editor.spec.ts b/cypress/e2e/misc/editor.spec.ts index ac71d3859..0a1b64294 100644 --- a/cypress/e2e/misc/editor.spec.ts +++ b/cypress/e2e/misc/editor.spec.ts @@ -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() diff --git a/cypress/e2e/misc/menu.spec.ts b/cypress/e2e/misc/menu.spec.ts index bb7b3d774..cb37fc7f0 100644 --- a/cypress/e2e/misc/menu.spec.ts +++ b/cypress/e2e/misc/menu.spec.ts @@ -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') }) }) diff --git a/cypress/e2e/project/namespaces.spec.ts b/cypress/e2e/project/namespaces.spec.ts deleted file mode 100644 index 6ce0d8837..000000000 --- a/cypress/e2e/project/namespaces.spec.ts +++ /dev/null @@ -1,145 +0,0 @@ -import {createFakeUserAndLogin} from '../../support/authenticateUser' - -import {ProjectFactory} from '../../factories/project' -import {NamespaceFactory} from '../../factories/namespace' - -describe('Namepaces', () => { - createFakeUserAndLogin() - - let namespaces - - beforeEach(() => { - namespaces = NamespaceFactory.create(1) - ProjectFactory.create(1) - }) - - it('Should be all there', () => { - cy.visit('/namespaces') - cy.get('[data-cy="namespace-title"]') - .should('contain', namespaces[0].title) - }) - - it('Should create a new Namespace', () => { - const newNamespaceTitle = 'New Namespace' - - cy.visit('/namespaces') - cy.get('[data-cy="new-namespace"]') - .should('contain', 'New namespace') - .click() - - cy.url() - .should('contain', '/namespaces/new') - cy.get('.card-header-title') - .should('contain', 'New namespace') - cy.get('input.input') - .type(newNamespaceTitle) - cy.get('.button') - .contains('Create') - .click() - - cy.get('.global-notification') - .should('contain', 'Success') - cy.get('.namespace-container') - .should('contain', newNamespaceTitle) - cy.url() - .should('contain', '/namespaces') - }) - - it('Should rename the namespace all places', () => { - const newNamespaces = NamespaceFactory.create(5) - const newNamespaceName = 'New namespace name' - - cy.visit('/namespaces') - - cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`) - .click() - cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content') - .contains('Edit') - .click() - cy.url() - .should('contain', '/settings/edit') - cy.get('#namespacetext') - .invoke('val') - .should('equal', newNamespaces[0].title) // wait until the namespace data is loaded - cy.get('#namespacetext') - .type(`{selectall}${newNamespaceName}`) - cy.get('footer.card-footer .button') - .contains('Save') - .click() - - cy.get('.global-notification', { timeout: 1000 }) - .should('contain', 'Success') - cy.get('.namespace-container .menu.namespaces-lists') - .should('contain', newNamespaceName) - .should('not.contain', newNamespaces[0].title) - cy.get('[data-cy="namespaces-list"]') - .should('contain', newNamespaceName) - .should('not.contain', newNamespaces[0].title) - }) - - it('Should remove a namespace when deleting it', () => { - const newNamespaces = NamespaceFactory.create(5) - - cy.visit('/') - - cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`) - .click() - cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content') - .contains('Delete') - .click() - cy.url() - .should('contain', '/settings/delete') - cy.get('[data-cy="modalPrimary"]') - .contains('Do it') - .click() - - cy.get('.global-notification') - .should('contain', 'Success') - cy.get('.namespace-container .menu.namespaces-lists') - .should('not.contain', newNamespaces[0].title) - }) - - it('Should not show archived projects & namespaces if the filter is not checked', () => { - const n = NamespaceFactory.create(1, { - id: 2, - is_archived: true, - }, false) - ProjectFactory.create(1, { - id: 2, - namespace_id: n[0].id, - }, false) - - ProjectFactory.create(1, { - id: 3, - is_archived: true, - }, false) - - // Initial - cy.visit('/namespaces') - cy.get('.namespace') - .should('not.contain', 'Archived') - - // Show archived - cy.get('[data-cy="show-archived-check"] .fancycheckbox__content') - .should('be.visible') - .click() - cy.get('[data-cy="show-archived-check"] input') - .should('be.checked') - cy.get('.namespace') - .should('contain', 'Archived') - - // Don't show archived - cy.get('[data-cy="show-archived-check"] .fancycheckbox__content') - .should('be.visible') - .click() - cy.get('[data-cy="show-archived-check"] input') - .should('not.be.checked') - - // Second time visiting after unchecking - cy.visit('/namespaces') - cy.get('[data-cy="show-archived-check"] input') - .should('not.be.checked') - cy.get('.namespace') - .should('not.contain', 'Archived') - }) -}) diff --git a/cypress/e2e/project/prepareProjects.ts b/cypress/e2e/project/prepareProjects.ts index b3b8952f8..ea7a1b01d 100644 --- a/cypress/e2e/project/prepareProjects.ts +++ b/cypress/e2e/project/prepareProjects.ts @@ -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' }) diff --git a/cypress/e2e/project/project-history.spec.ts b/cypress/e2e/project/project-history.spec.ts index 91ab258c1..0dadca8c7 100644 --- a/cypress/e2e/project/project-history.spec.ts +++ b/cypress/e2e/project/project-history.spec.ts @@ -8,37 +8,30 @@ describe('Project History', () => { prepareProjects() it('should show a project history on the home page', () => { - cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces') + cy.intercept(Cypress.env('API_URL') + '/projects*').as('loadProjectArray') cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject') const projects = ProjectFactory.create(6) cy.visit('/') - cy.wait('@loadNamespaces') + cy.wait('@loadProjectArray') cy.get('body') .should('not.contain', 'Last viewed') cy.visit(`/projects/${projects[0].id}`) - cy.wait('@loadNamespaces') cy.wait('@loadProject') cy.visit(`/projects/${projects[1].id}`) - cy.wait('@loadNamespaces') cy.wait('@loadProject') cy.visit(`/projects/${projects[2].id}`) - cy.wait('@loadNamespaces') cy.wait('@loadProject') cy.visit(`/projects/${projects[3].id}`) - cy.wait('@loadNamespaces') cy.wait('@loadProject') cy.visit(`/projects/${projects[4].id}`) - cy.wait('@loadNamespaces') cy.wait('@loadProject') cy.visit(`/projects/${projects[5].id}`) - cy.wait('@loadNamespaces') cy.wait('@loadProject') // cy.visit('/') - // cy.wait('@loadNamespaces') // Not using cy.visit here to work around the redirect issue fixed in #1337 cy.get('nav.menu.top-menu a') .contains('Overview') diff --git a/cypress/e2e/project/project-view-list.spec.ts b/cypress/e2e/project/project-view-list.spec.ts index ea4ecd1e7..c325f6824 100644 --- a/cypress/e2e/project/project-view-list.spec.ts +++ b/cypress/e2e/project/project-view-list.spec.ts @@ -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}/`) diff --git a/cypress/e2e/project/project.spec.ts b/cypress/e2e/project/project.spec.ts index b89bd9397..a5ef6cabc 100644 --- a/cypress/e2e/project/project.spec.ts +++ b/cypress/e2e/project/project.spec.ts @@ -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') + cy.get('.project-header [data-cy=new-project]') .click() cy.url() - .should('contain', '/projects/new/1') + .should('contain', '/projects/new') cy.get('.card-header-title') .contains('New project') - cy.get('input.input') + 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') + }) }) diff --git a/cypress/e2e/task/overview.spec.ts b/cypress/e2e/task/overview.spec.ts index 6103f7a31..342134b23 100644 --- a/cypress/e2e/task/overview.spec.ts +++ b/cypress/e2e/task/overview.spec.ts @@ -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', () => { diff --git a/cypress/e2e/task/task.spec.ts b/cypress/e2e/task/task.spec.ts index 9724bc510..cbde5f270 100644 --- a/cypress/e2e/task/task.spec.ts +++ b/cypress/e2e/task/task.spec.ts @@ -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') diff --git a/cypress/factories/namespace.ts b/cypress/factories/namespace.ts deleted file mode 100644 index 964faff14..000000000 --- a/cypress/factories/namespace.ts +++ /dev/null @@ -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(), - } - } -} diff --git a/cypress/factories/project.ts b/cypress/factories/project.ts index 6de5d48fc..8068027cb 100644 --- a/cypress/factories/project.ts +++ b/cypress/factories/project.ts @@ -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(), } diff --git a/docker/injector.sh b/docker/injector.sh index 1ce7f3a4a..66b16d02f 100755 --- a/docker/injector.sh +++ b/docker/injector.sh @@ -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' diff --git a/index.html b/index.html index a400eff88..b9818934b 100644 --- a/index.html +++ b/index.html @@ -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 diff --git a/src/components/home/ProjectsNavigation.vue b/src/components/home/ProjectsNavigation.vue new file mode 100644 index 000000000..8c78bafdd --- /dev/null +++ b/src/components/home/ProjectsNavigation.vue @@ -0,0 +1,107 @@ + + + diff --git a/src/components/home/ProjectsNavigationItem.vue b/src/components/home/ProjectsNavigationItem.vue new file mode 100644 index 000000000..e899290e2 --- /dev/null +++ b/src/components/home/ProjectsNavigationItem.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/src/components/home/TheNavigation.vue b/src/components/home/TheNavigation.vue index a79bd795c..36be81c01 100644 --- a/src/components/home/TheNavigation.vue +++ b/src/components/home/TheNavigation.vue @@ -7,7 +7,7 @@ -
+

{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}

@@ -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() diff --git a/src/components/home/contentAuth.vue b/src/components/home/contentAuth.vue index 4ee1f2e8b..31750963c 100644 --- a/src/components/home/contentAuth.vue +++ b/src/components/home/contentAuth.vue @@ -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() diff --git a/src/components/input/SelectNamespace.vue b/src/components/input/SelectNamespace.vue deleted file mode 100644 index e6bbfc31d..000000000 --- a/src/components/input/SelectNamespace.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - \ No newline at end of file diff --git a/src/components/misc/keyboard-shortcuts/shortcuts.ts b/src/components/misc/keyboard-shortcuts/shortcuts.ts index 4b19e5bfb..0ea93fc5b 100644 --- a/src/components/misc/keyboard-shortcuts/shortcuts.ts +++ b/src/components/misc/keyboard-shortcuts/shortcuts.ts @@ -44,8 +44,8 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [ combination: 'then', }, { - title: 'keyboardShortcuts.navigation.namespaces', - keys: ['g', 'n'], + title: 'keyboardShortcuts.navigation.projects', + keys: ['g', 'p'], combination: 'then', }, { diff --git a/src/components/misc/loading.vue b/src/components/misc/loading.vue index 9607ca3cf..bf368b719 100644 --- a/src/components/misc/loading.vue +++ b/src/components/misc/loading.vue @@ -1,13 +1,21 @@ + + \ No newline at end of file diff --git a/src/components/misc/subscription.vue b/src/components/misc/subscription.vue index ed32171be..1ca72d9cf 100644 --- a/src/components/misc/subscription.vue +++ b/src/components/misc/subscription.vue @@ -47,7 +47,7 @@ import {success} from '@/message' import type { IconProp } from '@fortawesome/fontawesome-svg-core' const props = defineProps({ - entity: String, + entity: String as ISubscription['entity'], entityId: Number, isButton: { type: Boolean, @@ -73,12 +73,6 @@ const {t} = useI18n({useScope: 'global'}) const tooltipText = computed(() => { if (disabled.value) { - if (props.entity === 'project' && subscriptionEntity.value === 'namespace') { - return t('task.subscription.subscribedProjectThroughParentNamespace') - } - if (props.entity === 'task' && subscriptionEntity.value === 'namespace') { - return t('task.subscription.subscribedTaskThroughParentNamespace') - } if (props.entity === 'task' && subscriptionEntity.value === 'project') { return t('task.subscription.subscribedTaskThroughParentProject') } @@ -87,10 +81,6 @@ const tooltipText = computed(() => { } switch (props.entity) { - case 'namespace': - return props.modelValue !== null ? - t('task.subscription.subscribedNamespace') : - t('task.subscription.notSubscribedNamespace') case 'project': return props.modelValue !== null ? t('task.subscription.subscribedProject') : @@ -130,9 +120,6 @@ async function subscribe() { let message = '' switch (props.entity) { - case 'namespace': - message = t('task.subscription.subscribeSuccessNamespace') - break case 'project': message = t('task.subscription.subscribeSuccessProject') break @@ -153,9 +140,6 @@ async function unsubscribe() { let message = '' switch (props.entity) { - case 'namespace': - message = t('task.subscription.unsubscribeSuccessNamespace') - break case 'project': message = t('task.subscription.unsubscribeSuccessProject') break diff --git a/src/components/namespace/namespace-settings-dropdown.vue b/src/components/namespace/namespace-settings-dropdown.vue deleted file mode 100644 index 6df112eba..000000000 --- a/src/components/namespace/namespace-settings-dropdown.vue +++ /dev/null @@ -1,103 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/components/project/ProjectWrapper.vue b/src/components/project/ProjectWrapper.vue index b084e3042..95457d53a 100644 --- a/src/components/project/ProjectWrapper.vue +++ b/src/components/project/ProjectWrapper.vue @@ -1,6 +1,6 @@ @@ -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') diff --git a/src/components/project/project-settings-dropdown.vue b/src/components/project/project-settings-dropdown.vue index 8a56a9c6f..0f237aa2e 100644 --- a/src/components/project/project-settings-dropdown.vue +++ b/src/components/project/project-settings-dropdown.vue @@ -72,6 +72,13 @@ @update:model-value="setSubscriptionInStore" type="dropdown" /> + + {{ $t('menu.createProject') }} + , required: true, }, + level: { + type: Number, + }, }) const projectStore = useProjectStore() -const namespaceStore = useNamespaceStore() const subscription = ref(null) watchEffect(() => { subscription.value = props.project.subscription ?? null @@ -122,6 +130,5 @@ function setSubscriptionInStore(sub: ISubscription) { subscription: sub, } projectStore.setProject(updatedProject) - namespaceStore.setProjectInNamespaceById(updatedProject) } \ No newline at end of file diff --git a/src/components/quick-actions/quick-actions.vue b/src/components/quick-actions/quick-actions.vue index d04a10d67..f5606a181 100644 --- a/src/components/quick-actions/quick-actions.vue +++ b/src/components/quick-actions/quick-actions.vue @@ -61,7 +61,6 @@ import {useRouter} from 'vue-router' import TaskService from '@/services/task' import TeamService from '@/services/team' -import NamespaceModel from '@/models/namespace' import TeamModel from '@/models/team' import ProjectModel from '@/models/project' @@ -70,7 +69,6 @@ import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue' import {useBaseStore} from '@/stores/base' import {useProjectStore} from '@/stores/projects' -import {useNamespaceStore} from '@/stores/namespaces' import {useLabelStore} from '@/stores/labels' import {useTaskStore} from '@/stores/tasks' @@ -81,7 +79,6 @@ import {success} from '@/message' import type {ITeam} from '@/modelTypes/ITeam' import type {ITask} from '@/modelTypes/ITask' -import type {INamespace} from '@/modelTypes/INamespace' import type {IProject} from '@/modelTypes/IProject' const {t} = useI18n({useScope: 'global'}) @@ -89,7 +86,6 @@ const router = useRouter() const baseStore = useBaseStore() const projectStore = useProjectStore() -const namespaceStore = useNamespaceStore() const labelStore = useLabelStore() const taskStore = useTaskStore() @@ -105,7 +101,6 @@ enum ACTION_TYPE { enum COMMAND_TYPE { NEW_TASK = 'newTask', NEW_PROJECT = 'newProject', - NEW_NAMESPACE = 'newNamespace', NEW_TEAM = 'newTeam', } @@ -147,24 +142,15 @@ const foundProjects = computed(() => { return [] } - const ncache: { [id: ProjectModel['id']]: INamespace } = {} const history = getHistory() const allProjects = [ ...new Set([ - ...history.map((l) => projectStore.getProjectById(l.id)), + ...history.map((l) => projectStore.projects[l.id]), ...projectStore.searchProject(project), ]), ] - return allProjects.filter((l) => { - if (typeof l === 'undefined' || l === null) { - return false - } - if (typeof ncache[l.namespaceId] === 'undefined') { - ncache[l.namespaceId] = namespaceStore.getNamespaceById(l.namespaceId) - } - return !ncache[l.namespaceId].isArchived - }) + return allProjects.filter(l => Boolean(l)) }) // FIXME: use fuzzysearch @@ -205,7 +191,6 @@ const results = computed(() => { 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[] 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() { diff --git a/src/components/sharing/userTeam.vue b/src/components/sharing/userTeam.vue index 099887f71..35288cd0e 100644 --- a/src/components/sharing/userTeam.vue +++ b/src/components/sharing/userTeam.vue @@ -139,10 +139,6 @@ import {ref, reactive, computed, shallowReactive, type Ref} from 'vue' import type {PropType} from 'vue' import {useI18n} from 'vue-i18n' -import UserNamespaceService from '@/services/userNamespace' -import UserNamespaceModel from '@/models/userNamespace' -import type {IUserNamespace} from '@/modelTypes/IUserNamespace' - import UserProjectService from '@/services/userProject' import UserProjectModel from '@/models/userProject' import type {IUserProject} from '@/modelTypes/IUserProject' @@ -151,10 +147,6 @@ import UserService from '@/services/user' import UserModel, { getDisplayName } from '@/models/user' import type {IUser} from '@/modelTypes/IUser' -import TeamNamespaceService from '@/services/teamNamespace' -import TeamNamespaceModel from '@/models/teamNamespace' -import type { ITeamNamespace } from '@/modelTypes/ITeamNamespace' - import TeamProjectService from '@/services/teamProject' import TeamProjectModel from '@/models/teamProject' import type { ITeamProject } from '@/modelTypes/ITeamProject' @@ -170,13 +162,15 @@ import Nothing from '@/components/misc/nothing.vue' import {success} from '@/message' import {useAuthStore} from '@/stores/auth' +// FIXME: I think this whole thing can now only manage user/team sharing for projects? Maybe remove a little generalization? + const props = defineProps({ type: { - type: String as PropType<'project' | 'namespace'>, + type: String as PropType<'project'>, default: '', }, shareType: { - type: String as PropType<'user' | 'team' | 'namespace'>, + type: String as PropType<'user' | 'team'>, default: '', }, id: { @@ -191,9 +185,9 @@ const props = defineProps({ const {t} = useI18n({useScope: 'global'}) -// This user service is either a userNamespaceService or a userProjectService, depending on the type we are using -let stuffService: UserNamespaceService | UserProjectService | TeamProjectService | TeamNamespaceService -let stuffModel: IUserNamespace | IUserProject | ITeamProject | ITeamNamespace +// This user service is a userProjectService, depending on the type we are using +let stuffService: UserProjectService | TeamProjectService +let stuffModel: IUserProject | ITeamProject let searchService: UserService | TeamService let sharable: Ref @@ -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) } diff --git a/src/components/tasks/partials/projectSearch.vue b/src/components/tasks/partials/projectSearch.vue index 21360e254..72ff6d1f4 100644 --- a/src/components/tasks/partials/projectSearch.vue +++ b/src/components/tasks/partials/projectSearch.vue @@ -11,8 +11,10 @@ @search="findProjects" > @@ -20,13 +22,11 @@ - - \ No newline at end of file diff --git a/src/components/tasks/partials/relatedTasks.vue b/src/components/tasks/partials/relatedTasks.vue index fd7506d48..40af34847 100644 --- a/src/components/tasks/partials/relatedTasks.vue +++ b/src/components/tasks/partials/relatedTasks.vue @@ -46,11 +46,6 @@ class="different-project" v-if="task.projectId !== projectId" > - - {{ task.differentNamespace }} > - @@ -101,11 +96,6 @@ class="different-project" v-if="t.projectId !== projectId" > - - {{ t.differentNamespace }} > - @@ -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, } diff --git a/src/components/tasks/partials/singleTaskInProject.vue b/src/components/tasks/partials/singleTaskInProject.vue index e45ff9367..32485d772 100644 --- a/src/components/tasks/partials/singleTaskInProject.vue +++ b/src/components/tasks/partials/singleTaskInProject.vue @@ -7,19 +7,19 @@ /> - + @@ -104,7 +104,7 @@ { 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(null) diff --git a/src/composables/useNamespaceSearch.ts b/src/composables/useNamespaceSearch.ts deleted file mode 100644 index a75ee0ac7..000000000 --- a/src/composables/useNamespaceSearch.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {ref, computed} from 'vue' -import {useNamespaceStore} from '@/stores/namespaces' - -export function useNamespaceSearch() { - const query = ref('') - - const namespaceStore = useNamespaceStore() - const namespaces = computed(() => namespaceStore.searchNamespace(query.value)) - - function findNamespaces(newQuery: string) { - query.value = newQuery - } - - return { - namespaces, - findNamespaces, - } -} - diff --git a/src/helpers/canNestProjectDeeper.ts b/src/helpers/canNestProjectDeeper.ts new file mode 100644 index 000000000..4f536c931 --- /dev/null +++ b/src/helpers/canNestProjectDeeper.ts @@ -0,0 +1,7 @@ +export function canNestProjectDeeper(level: number) { + if (level < 2) { + return true + } + + return level >= 2 && window.PROJECT_INFINITE_NESTING_ENABLED +} \ No newline at end of file diff --git a/src/helpers/getNamespaceTitle.ts b/src/helpers/getNamespaceTitle.ts deleted file mode 100644 index d13f39ab9..000000000 --- a/src/helpers/getNamespaceTitle.ts +++ /dev/null @@ -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 -} diff --git a/src/helpers/getProjectTitle.ts b/src/helpers/getProjectTitle.ts index 0d3b9ee5c..4e649034d 100644 --- a/src/helpers/getProjectTitle.ts +++ b/src/helpers/getProjectTitle.ts @@ -1,9 +1,14 @@ import {i18n} from '@/i18n' import type {IProject} from '@/modelTypes/IProject' -export function getProjectTitle(l: IProject) { - if (l.id === -1) { +export function getProjectTitle(project: IProject) { + if (project.id === -1) { return i18n.global.t('project.pseudo.favorites.title') } - return l.title + + if (project.title === 'Inbox') { + return i18n.global.t('project.inboxTitle') + } + + return project.title } diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json index 68d4a6c5e..6faaed9fb 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -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.", diff --git a/src/main.ts b/src/main.ts index d31f2f801..adf2f8854 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,7 @@ declare global { API_URL: string; SENTRY_ENABLED: boolean; SENTRY_DSN: string; + PROJECT_INFINITE_NESTING_ENABLED: boolean; } } diff --git a/src/modelTypes/INamespace.ts b/src/modelTypes/INamespace.ts deleted file mode 100644 index 555d7463d..000000000 --- a/src/modelTypes/INamespace.ts +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/src/modelTypes/IProject.ts b/src/modelTypes/IProject.ts index 8d6ef81fe..20f46d6ae 100644 --- a/src/modelTypes/IProject.ts +++ b/src/modelTypes/IProject.ts @@ -2,7 +2,6 @@ import type {IAbstract} from './IAbstract' import type {ITask} from './ITask' import type {IUser} from './IUser' import type {ISubscription} from './ISubscription' -import type {INamespace} from './INamespace' export interface IProject extends IAbstract { @@ -11,7 +10,6 @@ export interface IProject extends IAbstract { description: string owner: IUser tasks: ITask[] - namespaceId: INamespace['id'] isArchived: boolean hexColor: string identifier: string @@ -20,6 +18,7 @@ export interface IProject extends IAbstract { subscription: ISubscription position: number backgroundBlurHash: string + parentProjectId: number created: Date updated: Date diff --git a/src/modelTypes/IProjectDuplicate.ts b/src/modelTypes/IProjectDuplicate.ts index ab738d9bc..37419efd8 100644 --- a/src/modelTypes/IProjectDuplicate.ts +++ b/src/modelTypes/IProjectDuplicate.ts @@ -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'] } \ No newline at end of file diff --git a/src/modelTypes/ITeamNamespace.ts b/src/modelTypes/ITeamNamespace.ts deleted file mode 100644 index e66a98bb6..000000000 --- a/src/modelTypes/ITeamNamespace.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type {ITeamShareBase} from './ITeamShareBase' -import type {INamespace} from './INamespace' - -export interface ITeamNamespace extends ITeamShareBase { - namespaceId: INamespace['id'] -} \ No newline at end of file diff --git a/src/modelTypes/IUserNamespace.ts b/src/modelTypes/IUserNamespace.ts deleted file mode 100644 index e2bd1b468..000000000 --- a/src/modelTypes/IUserNamespace.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type {IUserShareBase} from './IUserShareBase' -import type {INamespace} from './INamespace' - -export interface IUserNamespace extends IUserShareBase { - namespaceId: INamespace['id'] -} \ No newline at end of file diff --git a/src/models/namespace.ts b/src/models/namespace.ts deleted file mode 100644 index 7419d2e64..000000000 --- a/src/models/namespace.ts +++ /dev/null @@ -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 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 = {}) { - 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) - } -} diff --git a/src/models/project.ts b/src/models/project.ts index c090e2917..17da3d798 100644 --- a/src/models/project.ts +++ b/src/models/project.ts @@ -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 implements IProject { @@ -15,7 +14,6 @@ export default class ProjectModel extends AbstractModel 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 implements IPr subscription: ISubscription = null position = 0 backgroundBlurHash = '' + parentProjectId = 0 created: Date = null updated: Date = null @@ -46,7 +45,7 @@ export default class ProjectModel extends AbstractModel 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) } diff --git a/src/models/projectDuplicateModel.ts b/src/models/projectDuplicateModel.ts index 479c91a0d..ed4f8534f 100644 --- a/src/models/projectDuplicateModel.ts +++ b/src/models/projectDuplicateModel.ts @@ -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 implements IProjectDuplicate { projectId = 0 - namespaceId: INamespace['id'] = 0 project: IProject = ProjectModel + parentProjectId = 0 constructor(data : Partial) { super() diff --git a/src/models/teamNamespace.ts b/src/models/teamNamespace.ts deleted file mode 100644 index 3d583ae80..000000000 --- a/src/models/teamNamespace.ts +++ /dev/null @@ -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) { - super(data) - this.assignData(data) - } -} \ No newline at end of file diff --git a/src/models/teamShareBase.ts b/src/models/teamShareBase.ts index b0c04d8da..b2dc266a6 100644 --- a/src/models/teamShareBase.ts +++ b/src/models/teamShareBase.ts @@ -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 implements ITeamShareBase { teamId: ITeam['id'] = 0 diff --git a/src/models/userNamespace.ts b/src/models/userNamespace.ts deleted file mode 100644 index 326373837..000000000 --- a/src/models/userNamespace.ts +++ /dev/null @@ -1,14 +0,0 @@ -import UserShareBaseModel from './userShareBase' - -import type {INamespace} from '@/modelTypes/INamespace' -import type {IUserNamespace} from '@/modelTypes/IUserNamespace' - -// This class extends the user share model with a 'rights' parameter which is used in sharing -export default class UserNamespaceModel extends UserShareBaseModel implements IUserNamespace { - namespaceId: INamespace['id'] = 0 - - constructor(data: Partial) { - super(data) - this.assignData(data) - } -} \ No newline at end of file diff --git a/src/router/index.ts b/src/router/index.ts index 542ec3249..a685542ce 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -22,7 +22,6 @@ const DataExportDownload = () => import('@/views/user/DataExportDownload.vue') // Tasks import UpcomingTasksComponent from '@/views/tasks/ShowTasks.vue' import LinkShareAuthComponent from '@/views/sharing/LinkSharingAuth.vue' -const ListNamespaces = () => import('@/views/namespaces/ListNamespaces.vue') const TaskDetailView = () => import('@/views/tasks/TaskDetailView.vue') // Team Handling @@ -41,6 +40,7 @@ const ProjectKanban = () => import('@/views/project/ProjectKanban.vue') const ProjectInfo = () => import('@/views/project/ProjectInfo.vue') // Project Settings +const ListProjects = () => import('@/views/project/ListProjects.vue') const ProjectSettingEdit = () => import('@/views/project/settings/edit.vue') const ProjectSettingBackground = () => import('@/views/project/settings/background.vue') const ProjectSettingDuplicate = () => import('@/views/project/settings/duplicate.vue') @@ -48,12 +48,6 @@ const ProjectSettingShare = () => import('@/views/project/settings/share.vue') const ProjectSettingDelete = () => import('@/views/project/settings/delete.vue') const ProjectSettingArchive = () => import('@/views/project/settings/archive.vue') -// Namespace Settings -const NamespaceSettingEdit = () => import('@/views/namespaces/settings/edit.vue') -const NamespaceSettingShare = () => import('@/views/namespaces/settings/share.vue') -const NamespaceSettingArchive = () => import('@/views/namespaces/settings/archive.vue') -const NamespaceSettingDelete = () => import('@/views/namespaces/settings/delete.vue') - // Saved Filters const FilterNew = () => import('@/views/filters/FilterNew.vue') const FilterEdit = () => import('@/views/filters/FilterEdit.vue') @@ -74,9 +68,6 @@ const UserSettingsTOTPComponent = () => import('@/views/user/settings/TOTP.vue') // Project Handling const NewProjectComponent = () => import('@/views/project/NewProject.vue') -// Namespace Handling -const NewNamespaceComponent = () => import('@/views/namespaces/NewNamespace.vue') - const EditTeamComponent = () => import('@/views/teams/EditTeam.vue') const NewTeamComponent = () => import('@/views/teams/NewTeam.vue') @@ -203,54 +194,6 @@ const router = createRouter({ name: 'link-share.auth', component: LinkShareAuthComponent, }, - { - path: '/namespaces', - name: 'namespaces.index', - component: ListNamespaces, - }, - { - path: '/namespaces/new', - name: 'namespace.create', - component: NewNamespaceComponent, - meta: { - showAsModal: true, - }, - }, - { - path: '/namespaces/:id/settings/edit', - name: 'namespace.settings.edit', - component: NamespaceSettingEdit, - meta: { - showAsModal: true, - }, - props: route => ({ namespaceId: Number(route.params.id as string) }), - }, - { - path: '/namespaces/:namespaceId/settings/share', - name: 'namespace.settings.share', - component: NamespaceSettingShare, - meta: { - showAsModal: true, - }, - }, - { - path: '/namespaces/:id/settings/archive', - name: 'namespace.settings.archive', - component: NamespaceSettingArchive, - meta: { - showAsModal: true, - }, - props: route => ({ namespaceId: parseInt(route.params.id as string) }), - }, - { - path: '/namespaces/:id/settings/delete', - name: 'namespace.settings.delete', - component: NamespaceSettingDelete, - meta: { - showAsModal: true, - }, - props: route => ({ namespaceId: Number(route.params.id as string) }), - }, { path: '/tasks/:id', name: 'task.detail', @@ -282,13 +225,27 @@ const router = createRouter({ }, }, { - path: '/projects/new/:namespaceId/', + path: '/projects', + name: 'projects.index', + component: ListProjects, + }, + { + path: '/projects/new', name: 'project.create', component: NewProjectComponent, meta: { showAsModal: true, }, }, + { + path: '/projects/:parentProjectId/new', + name: 'project.createFromParent', + component: NewProjectComponent, + props: route => ({ parentProjectId: Number(route.params.parentProjectId as string) }), + meta: { + showAsModal: true, + }, + }, { path: '/projects/:projectId/settings/edit', name: 'project.settings.edit', @@ -412,7 +369,7 @@ const router = createRouter({ saveProjectView(to.params.projectId, to.name) // Properly set the page title when a task popup is closed const projectStore = useProjectStore() - const projectFromStore = projectStore.getProjectById(Number(to.params.projectId)) + const projectFromStore = projectStore.projects[Number(to.params.projectId)] if(projectFromStore) { setTitle(projectFromStore.title) } diff --git a/src/services/namespace.ts b/src/services/namespace.ts deleted file mode 100644 index dfc056bb1..000000000 --- a/src/services/namespace.ts +++ /dev/null @@ -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 { - 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 - } -} \ No newline at end of file diff --git a/src/services/project.ts b/src/services/project.ts index 4bd4ae5bc..42892eacf 100644 --- a/src/services/project.ts +++ b/src/services/project.ts @@ -7,7 +7,7 @@ import {colorFromHex} from '@/helpers/color/colorFromHex' export default class ProjectService extends AbstractService { constructor() { super({ - create: '/namespaces/{namespaceId}/projects', + create: '/projects', get: '/projects/{id}', getAll: '/projects', update: '/projects/{id}', diff --git a/src/services/savedFilter.ts b/src/services/savedFilter.ts index 94edbab71..c9ca0fa24 100644 --- a/src/services/savedFilter.ts +++ b/src/services/savedFilter.ts @@ -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 { @@ -81,7 +81,7 @@ export default class SavedFilterService extends AbstractService { export function useSavedFilter(projectId?: MaybeRef) { 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) { async function createFilter() { filter.value = await filterService.create(filter.value) - await namespaceStore.loadNamespaces() + await projectStore.loadProjects() router.push({name: 'project.index', params: {projectId: getProjectId(filter.value)}}) } async function saveFilter() { const response = await filterService.update(filter.value) - await namespaceStore.loadNamespaces() + await projectStore.loadProjects() success({message: t('filters.edit.success')}) response.filters = objectToSnakeCase(response.filters) filter.value = response @@ -129,9 +129,9 @@ export function useSavedFilter(projectId?: MaybeRef) { async function deleteFilter() { await filterService.delete(filter.value) - await namespaceStore.loadNamespaces() + await projectStore.loadProjects() success({message: t('filters.delete.success')}) - router.push({name: 'namespaces.index'}) + router.push({name: 'projects.index'}) } const titleValid = ref(true) diff --git a/src/services/teamNamespace.ts b/src/services/teamNamespace.ts deleted file mode 100644 index 1547a3dfa..000000000 --- a/src/services/teamNamespace.ts +++ /dev/null @@ -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 { - 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) - } -} \ No newline at end of file diff --git a/src/services/userNamespace.ts b/src/services/userNamespace.ts deleted file mode 100644 index 6011899c1..000000000 --- a/src/services/userNamespace.ts +++ /dev/null @@ -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 { - 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) - } -} \ No newline at end of file diff --git a/src/stores/base.ts b/src/stores/base.ts index 71274de66..258d74667 100644 --- a/src/stores/base.ts +++ b/src/stores/base.ts @@ -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('') diff --git a/src/stores/namespaces.ts b/src/stores/namespaces.ts deleted file mode 100644 index c038a8eb2..000000000 --- a/src/stores/namespaces.ts +++ /dev/null @@ -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([]) - - 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)) -} \ No newline at end of file diff --git a/src/stores/projects.ts b/src/stores/projects.ts index 084f622a8..0ed9c9f7e 100644 --- a/src/stores/projects.ts +++ b/src/stores/projects.ts @@ -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({}) + const projectsArray = computed(() => Object.values(projects.value) + .sort((a, b) => a.position - b.position)) + const notArchivedRootProjects = computed(() => projectsArray.value + .filter(p => p.parentProjectId === 0 && !p.isArchived)) + const favoriteProjects = computed(() => projectsArray.value + .filter(p => !p.isArchived && p.isFavorite)) + const hasProjects = computed(() => projectsArray.value.length > 0) - - const getProjectById = computed(() => { - return (id: IProject['id']) => typeof projects.value[id] !== 'undefined' ? projects.value[id] : null + const getChildProjects = computed(() => { + return (id: IProject['id']) => projectsArray.value.filter(p => p.parentProjectId === id) }) 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) + // 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) { 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 - 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) + + const projectService = new ProjectService() + try { + const loadedProjects = await projectService.getAll({}, {is_archived: true}) as IProject[] + projects.value = {} + setProjects(loadedProjects) + + return loadedProjects + } finally { + cancel() + } + } + + function getAncestors(project: IProject): IProject[] { + if (!project?.parentProjectId) { + return [project] + } + + const parentProject = projects.value[project.parentProjectId] + return [ + ...getAncestors(parentProject), + project, + ] + } + return { isLoading: readonly(isLoading), projects: readonly(projects), + projectsArray: readonly(projectsArray), + notArchivedRootProjects: readonly(notArchivedRootProjects), + favoriteProjects: readonly(favoriteProjects), + hasProjects: readonly(hasProjects), - getProjectById, + getChildProjects, findProjectByExactname, searchProject, @@ -171,17 +196,24 @@ export const useProjectStore = defineStore('project', () => { setProjects, removeProjectById, toggleProjectFavorite, + loadProjects, createProject, updateProject, deleteProject, + getAncestors, } }) export function useProject(projectId: MaybeRef) { const projectService = shallowReactive(new ProjectService()) - const {loading: isLoading} = toRefs(projectService) + const projectDuplicateService = shallowReactive(new ProjectDuplicateService()) + + const isLoading = computed(() => projectService.loading || projectDuplicateService.loading) const project: IProject = reactive(new ProjectModel()) + const {t} = useI18n({useScope: 'global'}) + const router = useRouter() + const projectStore = useProjectStore() watch( () => unref(projectId), @@ -192,20 +224,34 @@ export function useProject(projectId: MaybeRef) { {immediate: true}, ) - const projectStore = useProjectStore() async function save() { - await projectStore.updateProject(project) + const updatedProject = await projectStore.updateProject(project) + Object.assign(project, updatedProject) success({message: t('project.edit.success')}) } + + async function duplicateProject(parentProjectId: IProject['id']) { + const projectDuplicate = new ProjectDuplicateModel({ + projectId: unref(projectId), + parentProjectId, + }) + + const duplicate = await projectDuplicateService.create(projectDuplicate) + + projectStore.setProject(duplicate.project) + success({message: t('project.duplicate.success')}) + router.push({name: 'project.index', params: {projectId: duplicate.project.id}}) + } return { isLoading: readonly(isLoading), project, save, + duplicateProject, } } // support hot reloading if (import.meta.hot) { - import.meta.hot.accept(acceptHMRUpdate(useProjectStore, import.meta.hot)) + import.meta.hot.accept(acceptHMRUpdate(useProjectStore, import.meta.hot)) } \ No newline at end of file diff --git a/src/stores/tasks.ts b/src/stores/tasks.ts index 9f26ad40b..4dbee906d 100644 --- a/src/stores/tasks.ts +++ b/src/stores/tasks.ts @@ -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, } }) diff --git a/src/styles/common-imports.scss b/src/styles/common-imports.scss index a383c2c8a..82e596f69 100644 --- a/src/styles/common-imports.scss +++ b/src/styles/common-imports.scss @@ -33,3 +33,7 @@ $switch-view-height: 2.69rem; $navbar-height: 4rem; $navbar-width: 300px; +$navbar-padding: 2rem; + +$vikunja-nav-color: var(--grey-700); +$vikunja-nav-selected-width: 0.4rem; diff --git a/src/styles/theme/_index.scss b/src/styles/theme/_index.scss index bc86c7bfe..5ce534bbd 100644 --- a/src/styles/theme/_index.scss +++ b/src/styles/theme/_index.scss @@ -8,4 +8,5 @@ @import "link-share"; @import "loading"; @import "flatpickr"; -@import 'helpers'; \ No newline at end of file +@import 'helpers'; +@import 'navigation'; \ No newline at end of file diff --git a/src/styles/theme/navigation.scss b/src/styles/theme/navigation.scss new file mode 100644 index 000000000..f10c79809 --- /dev/null +++ b/src/styles/theme/navigation.scss @@ -0,0 +1,139 @@ +// these are general menu styles +// should be in own components +.menu { + .menu-list .list-menu-link, + .menu-list a { + display: flex; + align-items: center; + cursor: pointer; + + .color-bubble { + height: 12px; + flex: 0 0 12px; + opacity: 1; + margin: 0 .5rem 0 .25rem; + } + } + + .menu-list { + list-style: none; + margin: 0; + padding: 0; + + &.other-menu-items li, + li > div { + height: 44px; + display: flex; + align-items: center; + + &:hover { + background: var(--white); + } + } + + li > div { + .menu-list-dropdown { + opacity: 1; + transition: $transition; + } + + @media(hover: hover) and (pointer: fine) { + .menu-list-dropdown { + opacity: 0; + } + + &:hover .menu-list-dropdown { + opacity: 1; + } + } + } + + li > menu { + margin: 0 0 0 var(--menu-nested-list-margin); + } + + .menu-item-icon { + color: var(--grey-400); + } + + .menu-list-dropdown-trigger { + display: flex; + padding: 0.5rem; + } + + .flip-list-move { + transition: transform $transition-duration; + } + + .ghost { + background: var(--grey-200); + + * { + opacity: 0; + } + } + + a:hover { + background: transparent; + } + + .list-menu-link, + li, li > div { + .collapse-project-button { + padding: .5rem .25rem .5rem .5rem; + + svg { + transition: all $transition; + color: var(--grey-400); + } + } + + .collapse-project-button-placeholder { + width: 2.25rem; + } + + > a { + color: $vikunja-nav-color; + padding: .75rem .5rem .75rem .25rem; + transition: all 0.2s ease; + + border-radius: 0; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + width: 100%; + + &.router-link-exact-active { + color: var(--primary); + } + + .icon { + height: 1rem; + vertical-align: middle; + padding-right: 0.5rem; + } + + &.router-link-exact-active .icon:not(.handle) { + color: var(--primary); + } + + .handle { + opacity: 0; + transition: opacity $transition; + } + + &:hover .handle { + opacity: 1; + } + } + } + + &:not(.dragging-disabled) .handle { + cursor: grab; + } + + menu { + border-left: 0; + } + } +} diff --git a/src/views/Home.vue b/src/views/Home.vue index dbcf52f90..aba1aea5e 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -17,22 +17,11 @@ @taskAdded="updateTaskKey" class="is-max-width-desktop" /> -