From 7dc91c1b6a8eb731e38731d10ef436ca09954345 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 17 Sep 2025 19:38:17 +0200 Subject: [PATCH] feat(projectFolders): improve code quality and extract translations --- .../project-folders/project-folders.spec.ts | 109 ++++----- package-lock.json | 208 ------------------ package.json | 3 - .../folder-context-menu.component.html | 4 +- .../folder-context-menu.component.ts | 13 +- .../magic-nav-config.service.ts | 5 +- .../magic-side-nav.component.html | 199 ++++++++--------- .../nav-list/nav-list-tree.component.ts | 72 ++++-- ...-create-edit-project-folder.component.html | 27 ++- ...og-create-edit-project-folder.component.ts | 5 + .../project-folder-select.component.ts | 23 +- .../project-folder/project-folder.service.ts | 29 ++- .../store/project-folder.effects.ts | 7 - src/app/root-store/feature-stores.module.ts | 2 - src/app/t.const.ts | 19 ++ src/app/ui/tree-dnd/tree.component.ts | 3 + src/assets/i18n/de.json | 19 ++ src/assets/i18n/en.json | 19 ++ 18 files changed, 341 insertions(+), 425 deletions(-) delete mode 100644 src/app/features/project-folder/store/project-folder.effects.ts diff --git a/e2e/tests/project-folders/project-folders.spec.ts b/e2e/tests/project-folders/project-folders.spec.ts index 1953f1ed7..ff73656d3 100644 --- a/e2e/tests/project-folders/project-folders.spec.ts +++ b/e2e/tests/project-folders/project-folders.spec.ts @@ -1,89 +1,60 @@ import { expect, test } from '../../fixtures/test.fixture'; test.describe('Project Folders', () => { - test('should create and display project folders in navigation', async ({ - page, - workViewPage, - }) => { - // Wait for work view to be ready + test('can create a project folder via navigation', async ({ page, workViewPage }) => { await workViewPage.waitForTaskList(); - // Take screenshot to see the initial navigation structure - await page.screenshot({ - path: 'e2e-results/project-folders-before.png', - fullPage: true, - }); - - // Check if navigation is visible const navigation = page.locator('nav.nav-sidenav, .nav-sidenav'); await expect(navigation).toBeVisible(); - // Look for projects section in navigation - const projectsSection = page.locator('text="Projects"'); - const projectsSectionExists = (await projectsSection.count()) > 0; - console.log(`Projects section found: ${projectsSectionExists}`); + const projectsHeader = page + .locator('.g-multi-btn-wrapper') + .filter({ hasText: 'Projects' }) + .first(); + await expect(projectsHeader).toBeVisible(); - // If projects section exists, check if we can expand it - if (projectsSectionExists) { - // Take screenshot after finding projects section - await page.screenshot({ - path: 'e2e-results/project-folders-projects-found.png', - fullPage: true, - }); - } + const createFolderBtn = projectsHeader + .locator('button.additional-btn') + .filter({ has: page.locator('mat-icon', { hasText: 'create_new_folder' }) }) + .first(); + await createFolderBtn.click(); - // For now, just verify the navigation exists and has basic structure - const navItems = page.locator('.nav-sidenav li, nav-item'); - const navCount = await navItems.count(); - expect(navCount).toBeGreaterThan(0); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + + const folderName = 'E2E Folder'; + await dialog.locator('input[formcontrolname="title"]').fill(folderName); + await dialog.locator('button[type="submit"]').click(); + + await expect(dialog).toHaveCount(0); + + const createdFolderNavItem = page + .locator('nav-item, .nav-child-item') + .filter({ hasText: folderName }) + .first(); + await expect(createdFolderNavItem).toBeVisible(); }); - test('should test project creation flow', async ({ page, workViewPage }) => { - // Wait for work view to be ready + test('opens create project dialog from navigation', async ({ page, workViewPage }) => { await workViewPage.waitForTaskList(); - // Find and click "Create Project" button - const createProjectBtn = page.locator('text="Create Project"'); - await expect(createProjectBtn).toBeVisible(); - - // Take screenshot before clicking - await page.screenshot({ - path: 'e2e-results/project-folders-before-create.png', - fullPage: true, - }); + const projectsHeader = page + .locator('.g-multi-btn-wrapper') + .filter({ hasText: 'Projects' }) + .first(); + await expect(projectsHeader).toBeVisible(); + const createProjectBtn = projectsHeader + .locator('button.additional-btn') + .filter({ has: page.locator('mat-icon', { hasText: 'add' }) }) + .first(); await createProjectBtn.click(); - // Wait for dialog or new project form to appear - await page.waitForTimeout(1000); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('input[formcontrolname="title"]')).toBeVisible(); - // Take screenshot after clicking to see what appears - await page.screenshot({ - path: 'e2e-results/project-folders-after-create-click.png', - fullPage: true, - }); - - // Look for project creation dialog - const dialog = page.locator( - 'mat-dialog-container, .mat-dialog-container, [role="dialog"]', - ); - const hasDialog = (await dialog.count()) > 0; - - console.log(`Project creation dialog appeared: ${hasDialog}`); - - // For now, just verify the create project button works - expect(hasDialog).toBeTruthy(); - - // Close dialog if it opened - if (hasDialog) { - const closeBtn = page.locator( - 'button[mat-dialog-close], .mat-dialog-close, button:has-text("Cancel")', - ); - if ((await closeBtn.count()) > 0) { - await closeBtn.click(); - } else { - await page.keyboard.press('Escape'); - } - } + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).toHaveCount(0); }); }); diff --git a/package-lock.json b/package-lock.json index 8d30206cb..e90f8a482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,9 +43,6 @@ "@angular/router": "^20.1.6", "@angular/service-worker": "^20.1.6", "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", - "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.2", - "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", - "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "^3.2.7", "@capacitor/android": "^7.3.0", "@capacitor/app": "^7.0.1", "@capacitor/cli": "^7.3.0", @@ -2858,61 +2855,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@atlaskit/atlassian-context": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@atlaskit/atlassian-context/-/atlassian-context-0.5.0.tgz", - "integrity": "sha512-ui8J50lnr7I8i8yq+nqupZqEQ3awJzWa3PsGUR+BY/vuM/A/UzNMA3EQApBItyeOPv49nov8lOIidtU0VnzLxw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.0.0" - }, - "peerDependencies": { - "react": "^18.2.0" - } - }, - "node_modules/@atlaskit/ds-lib": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@atlaskit/ds-lib/-/ds-lib-5.0.1.tgz", - "integrity": "sha512-vP71lCCXSM1S1TkGIpWDsNRs5IwQnM99BGa4Dsx14odTXRr/c9k/0ywL7voibHfofCPYgxn9I+iiq4T8KiQ1Ew==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@atlaskit/platform-feature-flags": "^1.1.0", - "@babel/runtime": "^7.0.0", - "bind-event-listener": "^3.0.0", - "react-uid": "^2.2.0", - "tiny-invariant": "^1.2.0" - }, - "peerDependencies": { - "react": "^18.2.0" - } - }, - "node_modules/@atlaskit/feature-gate-js-client": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/@atlaskit/feature-gate-js-client/-/feature-gate-js-client-5.5.5.tgz", - "integrity": "sha512-uuF/gK35LZoyEUtoVcjxXtbls9fb0RiF5kxp9kYfVwuT6XkZxrYAGuQMPq8tnHKpZE3m70tdMuANyIlMddLzqw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@atlaskit/atlassian-context": "^0.5.0", - "@babel/runtime": "^7.0.0", - "@statsig/client-core": "^3.21.1", - "@statsig/js-client": "^3.21.1", - "eventemitter3": "^4.0.0" - } - }, - "node_modules/@atlaskit/platform-feature-flags": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@atlaskit/platform-feature-flags/-/platform-feature-flags-1.1.2.tgz", - "integrity": "sha512-PM+fVkV4Yn4/0keiN6ioAap2Y+odTyw1Z4uf+TA0zMmg3/lp/rVNJEc4a24dvd8DfVs5m9bxa/hbO1qJarhCBw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@atlaskit/feature-gate-js-client": "^5.5.0", - "@babel/runtime": "^7.0.0" - } - }, "node_modules/@atlaskit/pragmatic-drag-and-drop": { "version": "1.7.7", "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.7.7.tgz", @@ -2925,63 +2867,6 @@ "raf-schd": "^4.0.3" } }, - "node_modules/@atlaskit/pragmatic-drag-and-drop-auto-scroll": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-auto-scroll/-/pragmatic-drag-and-drop-auto-scroll-2.1.2.tgz", - "integrity": "sha512-6BgAUxSNbQFiG3uqNxf53cDQADn5mSeh/JsQzCHo46GPQnVWIJk77zWC8yZ++0Mfg1ECy02zNrbniF7SgHAhXQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@atlaskit/pragmatic-drag-and-drop": "^1.7.0", - "@babel/runtime": "^7.0.0" - } - }, - "node_modules/@atlaskit/pragmatic-drag-and-drop-hitbox": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.1.0.tgz", - "integrity": "sha512-JWt6eVp6Br2FPHRM8s0dUIHQk/jFInGP1f3ti5CdtM1Ji5/pt8Akm44wDC063Gv2i5RGseixtbW0z/t6RYtbdg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@atlaskit/pragmatic-drag-and-drop": "^1.6.0", - "@babel/runtime": "^7.0.0" - } - }, - "node_modules/@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/-/pragmatic-drag-and-drop-react-drop-indicator-3.2.7.tgz", - "integrity": "sha512-QdJJuBnERhnR/iG9TDXJEc1HudIsPVjP3BKuFE14NSVVs8fnSkFsfUrpVxVQPJT6fYZdfEHXREoU+DdLtEI7Fg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@atlaskit/pragmatic-drag-and-drop": "^1.7.0", - "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", - "@atlaskit/tokens": "^6.3.0", - "@babel/runtime": "^7.0.0", - "@compiled/react": "^0.18.3" - }, - "peerDependencies": { - "react": "^18.2.0 || ^19.0.0" - } - }, - "node_modules/@atlaskit/tokens": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@atlaskit/tokens/-/tokens-6.3.1.tgz", - "integrity": "sha512-acIM5WwrETYp9OuGEO/kh2MZOrCzQoYYz3Hv6HK7Pt5VwxStmAhZw9II3IE81uLRsac+gzo0NgDtuaBJHA4a1g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@atlaskit/ds-lib": "^5.0.0", - "@atlaskit/platform-feature-flags": "^1.1.0", - "@babel/runtime": "^7.0.0", - "@babel/traverse": "^7.23.2", - "@babel/types": "^7.20.0", - "bind-event-listener": "^3.0.0" - }, - "peerDependencies": { - "react": "^18.2.0" - } - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -4859,19 +4744,6 @@ "node": ">=0.1.90" } }, - "node_modules/@compiled/react": { - "version": "0.18.6", - "resolved": "https://registry.npmjs.org/@compiled/react/-/react-0.18.6.tgz", - "integrity": "sha512-Mt6sJOwykeoToEBFbOUNR4xABi2gOr/+X5QSGqGEYiCBMh+XPDAclG2UX94zveiYJXO4AUJIQBCVwa4/lwPMBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "csstype": "^3.1.3" - }, - "peerDependencies": { - "react": ">= 16.12.0" - } - }, "node_modules/@conventional-changelog/git-client": { "version": "1.0.1", "dev": true, @@ -9153,23 +9025,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@statsig/client-core": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/@statsig/client-core/-/client-core-3.25.2.tgz", - "integrity": "sha512-Yznv8wj5VkGxxj2QVUSEYZMnvWPPNfXe0tUXSJswwrmH+Jy5s+91TSjle1j+faypKJIQLKSuuw3rwdxb9B45dA==", - "dev": true, - "license": "ISC" - }, - "node_modules/@statsig/js-client": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/@statsig/js-client/-/js-client-3.25.2.tgz", - "integrity": "sha512-kydziYuIjo1jEQDH+0rR5sNBxb5UKENTiiYv+kiF7YEn1F63FJZ5ZwO7SVYS4knJpJjsLXS4SJGSd5n449Sz7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "@statsig/client-core": "3.25.2" - } - }, "node_modules/@super-productivity/plugin-api": { "resolved": "packages/plugin-api", "link": true @@ -12962,13 +12817,6 @@ "node": ">=4" } }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, - "license": "MIT" - }, "node_modules/custom-event": { "version": "1.0.1", "dev": true, @@ -18989,20 +18837,6 @@ "node": ">=8.0" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/loupe": { "version": "3.1.2", "dev": true, @@ -22313,42 +22147,6 @@ "node": ">=0.10.0" } }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-uid": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/react-uid/-/react-uid-2.4.0.tgz", - "integrity": "sha512-+MVs/25NrcZuGrmlVRWPOSsbS8y72GJOBsR7d68j3/wqOrRBF52U29XAw4+XSelw0Vm6s5VmGH5mCbTCPGVCVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "dev": true, @@ -24984,12 +24782,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, "node_modules/tinyexec": { "version": "0.3.2", "dev": true, diff --git a/package.json b/package.json index 6b9cca03e..91d150d05 100644 --- a/package.json +++ b/package.json @@ -162,9 +162,6 @@ "@angular/router": "^20.1.6", "@angular/service-worker": "^20.1.6", "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", - "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.2", - "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", - "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "^3.2.7", "@capacitor/android": "^7.3.0", "@capacitor/app": "^7.0.1", "@capacitor/cli": "^7.3.0", diff --git a/src/app/core-ui/folder-context-menu/folder-context-menu.component.html b/src/app/core-ui/folder-context-menu/folder-context-menu.component.html index e65dbd523..4dab3bb20 100644 --- a/src/app/core-ui/folder-context-menu/folder-context-menu.component.html +++ b/src/app/core-ui/folder-context-menu/folder-context-menu.component.html @@ -3,7 +3,7 @@ (click)="editFolder()" > edit - Edit + {{ T.G.EDIT | translate }} diff --git a/src/app/core-ui/folder-context-menu/folder-context-menu.component.ts b/src/app/core-ui/folder-context-menu/folder-context-menu.component.ts index d32dee53e..f6fcc54be 100644 --- a/src/app/core-ui/folder-context-menu/folder-context-menu.component.ts +++ b/src/app/core-ui/folder-context-menu/folder-context-menu.component.ts @@ -9,22 +9,27 @@ import { MatMenuItem } from '@angular/material/menu'; import { MatIcon } from '@angular/material/icon'; import { ProjectService } from '../../features/project/project.service'; import { ProjectFolder } from '../../features/project-folder/store/project-folder.model'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { T } from '../../t.const'; @Component({ selector: 'folder-context-menu', templateUrl: './folder-context-menu.component.html', styleUrls: ['./folder-context-menu.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [MatMenuItem, MatIcon], + imports: [MatMenuItem, MatIcon, TranslateModule], standalone: true, }) export class FolderContextMenuComponent { private readonly _matDialog = inject(MatDialog); private readonly _projectFolderService = inject(ProjectFolderService); private readonly _projectService = inject(ProjectService); + private readonly _translateService = inject(TranslateService); @Input() folderId!: string; + readonly T = T; + async editFolder(): Promise { const folder = await this._loadFolder(this.folderId); @@ -43,12 +48,16 @@ export class FolderContextMenuComponent { if (!folder) return; + const message = this._translateService.instant(T.F.PROJECT_FOLDER.CONFIRM_DELETE, { + title: folder.title, + }); + const isConfirmed = await new Promise((resolve) => { this._matDialog .open(DialogConfirmComponent, { restoreFocus: true, data: { - message: `Are you sure you want to delete the folder "${folder.title}"? All projects in this folder will be moved to the root level.`, + message, }, }) .afterClosed() diff --git a/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts b/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts index c6b80172d..87c44f59e 100644 --- a/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts +++ b/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts @@ -25,7 +25,6 @@ import { toggleHideFromMenu } from '../../features/project/store/project.actions import { NavConfig, NavItem, NavWorkContextItem } from './magic-side-nav.model'; import { TODAY_TAG } from '../../features/tag/tag.const'; import { PluginBridgeService } from '../../plugins/plugin-bridge.service'; -import { Log } from '../../core/log'; import { lsGetBoolean, lsSetItem } from '../../util/ls-util'; @Injectable({ @@ -131,13 +130,13 @@ export class MagicNavConfigService { { id: 'project-visibility', icon: 'visibility', - tooltip: 'Show/Hide Projects', + tooltip: T.F.PROJECT_FOLDER.TOOLTIP_VISIBILITY, action: () => this._openProjectVisibilityMenu(), }, { id: 'add-project-folder', icon: 'create_new_folder', - tooltip: 'Create Project Folder', + tooltip: T.F.PROJECT_FOLDER.TOOLTIP_CREATE, action: () => this._openCreateProjectFolder(), }, { diff --git a/src/app/core-ui/magic-side-nav/magic-side-nav.component.html b/src/app/core-ui/magic-side-nav/magic-side-nav.component.html index 769a85f6b..37d81d272 100644 --- a/src/app/core-ui/magic-side-nav/magic-side-nav.component.html +++ b/src/app/core-ui/magic-side-nav/magic-side-nav.component.html @@ -65,111 +65,112 @@ } - - + +} @if (config().resizable && !isMobile()) { diff --git a/src/app/core-ui/magic-side-nav/nav-list/nav-list-tree.component.ts b/src/app/core-ui/magic-side-nav/nav-list/nav-list-tree.component.ts index 3d55322e6..d64d477eb 100644 --- a/src/app/core-ui/magic-side-nav/nav-list/nav-list-tree.component.ts +++ b/src/app/core-ui/magic-side-nav/nav-list/nav-list-tree.component.ts @@ -24,6 +24,7 @@ import { MagicNavConfigService } from '../magic-nav-config.service'; import { T } from '../../../t.const'; import { WorkContextType } from '../../../features/work-context/work-context.model'; import { ProjectFolderService } from '../../../features/project-folder/project-folder.service'; +import { ProjectFolder } from '../../../features/project-folder/store/project-folder.model'; import { ProjectService } from '../../../features/project/project.service'; import { TagService } from '../../../features/tag/tag.service'; import { TODAY_TAG } from '../../../features/tag/tag.const'; @@ -187,21 +188,41 @@ export class NavListTreeComponent { private _persistProjectFolderRelationships( folderProjectMap: Map, - rootProjectIds: string[], rootLayout: string[], + folderParentMap: Map, + orderedFolderIds: string[], ): void { const currentFolders = this._projectFolders(); + const nextFolderById = new Map(); let didChange = false; - const updatedFolders = currentFolders.map((folder) => { + + currentFolders.forEach((folder) => { const nextProjectIds = folderProjectMap.get(folder.id) ?? []; - if (!didChange && !this._areArraysEqual(folder.projectIds, nextProjectIds)) { - didChange = true; + const nextParentId = folderParentMap.has(folder.id) + ? (folderParentMap.get(folder.id) ?? null) + : folder.parentId; + if (!didChange) { + didChange = + !this._areArraysEqual(folder.projectIds, nextProjectIds) || + folder.parentId !== nextParentId; } - return { + nextFolderById.set(folder.id, { ...folder, projectIds: nextProjectIds, - }; + parentId: nextParentId, + }); + }); + + const orderedFolders: ProjectFolder[] = orderedFolderIds + .map((id) => nextFolderById.get(id)) + .filter((folder): folder is ProjectFolder => !!folder); + + const included = new Set(orderedFolders.map((folder) => folder.id)); + currentFolders.forEach((folder) => { + if (!included.has(folder.id)) { + orderedFolders.push(folder); + } }); const layoutChanged = !this._areArraysEqual(this._rootProjectIdsSig(), rootLayout); @@ -211,7 +232,7 @@ export class NavListTreeComponent { } this._projectFolderService.updateProjectFolderRelationships( - updatedFolders, + orderedFolders, rootLayout, ); } @@ -299,10 +320,20 @@ export class NavListTreeComponent { } private _applyProjectTreeChanges(nodes: TreeNode[]): void { - const { folderProjectMap, rootProjectIds, orderedProjectIds, rootLayout } = - this._collectProjectStructure(nodes); + const { + folderProjectMap, + orderedProjectIds, + rootLayout, + folderParentMap, + orderedFolderIds, + } = this._collectProjectStructure(nodes); - this._persistProjectFolderRelationships(folderProjectMap, rootProjectIds, rootLayout); + this._persistProjectFolderRelationships( + folderProjectMap, + rootLayout, + folderParentMap, + orderedFolderIds, + ); const allProjects = this._navConfigService.allProjectsExceptInbox(); const visibleProjects = allProjects.filter( @@ -317,7 +348,9 @@ export class NavListTreeComponent { folderProjectMap.forEach((projectIds, folderId) => { projectIds.forEach((projectId) => desiredAssignment.set(projectId, folderId)); }); - rootProjectIds.forEach((projectId) => desiredAssignment.set(projectId, null)); + rootLayout + .filter((entry) => entry.startsWith('project:')) + .forEach((entry) => desiredAssignment.set(entry.replace('project:', ''), null)); desiredAssignment.forEach((targetFolderId, projectId) => { const project = projectLookup.get(projectId); @@ -373,14 +406,16 @@ export class NavListTreeComponent { private _collectProjectStructure(nodes: TreeNode[]): { folderProjectMap: Map; - rootProjectIds: string[]; orderedProjectIds: string[]; rootLayout: string[]; + folderParentMap: Map; + orderedFolderIds: string[]; } { const folderProjectMap = new Map(); - const rootProjectIds: string[] = []; const orderedProjectIds: string[] = []; const rootLayout: string[] = []; + const folderParentMap = new Map(); + const orderedFolderIds: string[] = []; const visit = ( node: TreeNode, @@ -388,6 +423,8 @@ export class NavListTreeComponent { level: number, ): void => { if (node.isFolder) { + folderParentMap.set(node.id, parentFolderId); + orderedFolderIds.push(node.id); if (level === 0) { rootLayout.push(`folder:${node.id}`); } @@ -412,7 +449,6 @@ export class NavListTreeComponent { if (projectId) { orderedProjectIds.push(projectId); if (parentFolderId === null) { - rootProjectIds.push(projectId); rootLayout.push(`project:${projectId}`); } } @@ -420,7 +456,13 @@ export class NavListTreeComponent { nodes.forEach((node) => visit(node, null, 0)); - return { folderProjectMap, rootProjectIds, orderedProjectIds, rootLayout }; + return { + folderProjectMap, + orderedProjectIds, + rootLayout, + folderParentMap, + orderedFolderIds, + }; } private _extractProjectId(nodeId: string): string | null { diff --git a/src/app/features/project-folder/dialogs/create-edit-project-folder/dialog-create-edit-project-folder.component.html b/src/app/features/project-folder/dialogs/create-edit-project-folder/dialog-create-edit-project-folder.component.html index 1beaf9d18..70a820753 100644 --- a/src/app/features/project-folder/dialogs/create-edit-project-folder/dialog-create-edit-project-folder.component.html +++ b/src/app/features/project-folder/dialogs/create-edit-project-folder/dialog-create-edit-project-folder.component.html @@ -1,4 +1,11 @@ -

{{ isEdit ? 'Edit Folder' : 'Create New Folder' }}

+

+ {{ + (isEdit + ? T.F.PROJECT_FOLDER.DIALOG.EDIT_TITLE + : T.F.PROJECT_FOLDER.DIALOG.CREATE_TITLE + ) | translate + }} +

- Folder Name + {{ T.F.PROJECT_FOLDER.DIALOG.NAME_LABEL | translate }} @if (form.get('title')?.errors?.['required']) { - Folder name is required + + {{ T.F.PROJECT_FOLDER.DIALOG.NAME_REQUIRED | translate }} + } @@ -25,9 +34,11 @@ appearance="outline" class="full-width" > - Parent Folder + {{ T.F.PROJECT_FOLDER.DIALOG.PARENT_LABEL | translate }} - No parent (root level) + + {{ T.F.PROJECT_FOLDER.DIALOG.NO_PARENT | translate }} + @for (folder of availableFolders$ | async; track folder.id) { @if (!isEdit || folder.id !== data?.folder?.id) { {{ folder.title }} @@ -43,7 +54,7 @@ mat-button (click)="cancel()" > - Cancel + {{ T.G.CANCEL | translate }} diff --git a/src/app/features/project-folder/dialogs/create-edit-project-folder/dialog-create-edit-project-folder.component.ts b/src/app/features/project-folder/dialogs/create-edit-project-folder/dialog-create-edit-project-folder.component.ts index 1591ba741..8f095279b 100644 --- a/src/app/features/project-folder/dialogs/create-edit-project-folder/dialog-create-edit-project-folder.component.ts +++ b/src/app/features/project-folder/dialogs/create-edit-project-folder/dialog-create-edit-project-folder.component.ts @@ -9,6 +9,8 @@ import { CommonModule } from '@angular/common'; import { ProjectFolder } from '../../store/project-folder.model'; import { ProjectFolderService } from '../../project-folder.service'; +import { TranslateModule } from '@ngx-translate/core'; +import { T } from '../../../../t.const'; export interface DialogCreateEditProjectFolderData { folder?: ProjectFolder; @@ -27,6 +29,7 @@ export interface DialogCreateEditProjectFolderData { MatInputModule, MatSelectModule, MatButtonModule, + TranslateModule, ], }) export class DialogCreateEditProjectFolderComponent { @@ -45,6 +48,8 @@ export class DialogCreateEditProjectFolderComponent { parentId: [null], }); + readonly T = T; + constructor() { if (this.isEdit && this.data?.folder) { const folder = this.data.folder; diff --git a/src/app/features/project-folder/formly-fields/project-folder-select/project-folder-select.component.ts b/src/app/features/project-folder/formly-fields/project-folder-select/project-folder-select.component.ts index f497cc90f..09592b693 100644 --- a/src/app/features/project-folder/formly-fields/project-folder-select/project-folder-select.component.ts +++ b/src/app/features/project-folder/formly-fields/project-folder-select/project-folder-select.component.ts @@ -5,17 +5,25 @@ import { ProjectFolderService } from '../../project-folder.service'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatOptionModule } from '@angular/material/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { T } from '../../../../t.const'; @Component({ selector: 'formly-field-project-folder-select', template: ` - {{ to.label }} + + {{ to.label || (T.F.PROJECT_FOLDER.SELECT.LABEL | translate) }} + - No folder (root level) + + {{ T.F.PROJECT_FOLDER.SELECT.NO_PARENT | translate }} + @for (folder of projectFolderService.projectFolders$ | async; track folder.id) { {{ folder.title }} } @@ -24,8 +32,15 @@ import { MatOptionModule } from '@angular/material/core'; `, styleUrls: ['./project-folder-select.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, MatFormFieldModule, MatSelectModule, MatOptionModule], + imports: [ + CommonModule, + MatFormFieldModule, + MatSelectModule, + MatOptionModule, + TranslateModule, + ], }) export class ProjectFolderSelectComponent extends FieldType { readonly projectFolderService = inject(ProjectFolderService); + readonly T = T; } diff --git a/src/app/features/project-folder/project-folder.service.ts b/src/app/features/project-folder/project-folder.service.ts index 886f9338f..9bee7971e 100644 --- a/src/app/features/project-folder/project-folder.service.ts +++ b/src/app/features/project-folder/project-folder.service.ts @@ -65,8 +65,14 @@ export class ProjectFolderService { .pipe(take(1), withLatestFrom(this.rootProjectIds$)) .subscribe(([folders, rootProjectIds]) => { const updatedFolders = folders.filter((folder) => folder.id !== id); + const updatedRootLayout = rootProjectIds.filter( + (entry) => entry !== `folder:${id}`, + ); this._store.dispatch( - updateProjectFolders({ projectFolders: updatedFolders, rootProjectIds }), + updateProjectFolders({ + projectFolders: updatedFolders, + rootProjectIds: updatedRootLayout, + }), ); }); } @@ -75,8 +81,25 @@ export class ProjectFolderService { this.projectFolders$ .pipe(take(1), withLatestFrom(this.rootProjectIds$)) .subscribe(([folders, rootProjectIds]) => { - const folderMap = Object.fromEntries(folders.map((f) => [f.id, f])); - const reorderedFolders = newIds.map((id) => folderMap[id]).filter(Boolean); + const folderMap = new Map(folders.map((f) => [f.id, f] as const)); + const seen = new Set(); + const reorderedFolders: ProjectFolder[] = []; + + newIds.forEach((id) => { + const folder = folderMap.get(id); + if (folder && !seen.has(id)) { + reorderedFolders.push(folder); + seen.add(id); + } + }); + + folders.forEach((folder) => { + if (!seen.has(folder.id)) { + reorderedFolders.push(folder); + seen.add(folder.id); + } + }); + this._store.dispatch( updateProjectFolders({ projectFolders: reorderedFolders, rootProjectIds }), ); diff --git a/src/app/features/project-folder/store/project-folder.effects.ts b/src/app/features/project-folder/store/project-folder.effects.ts deleted file mode 100644 index 658fbb7ee..000000000 --- a/src/app/features/project-folder/store/project-folder.effects.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { Actions } from '@ngrx/effects'; - -@Injectable() -export class ProjectFolderEffects { - private readonly actions$ = inject(Actions); -} diff --git a/src/app/root-store/feature-stores.module.ts b/src/app/root-store/feature-stores.module.ts index fef94513c..c977cf6f1 100644 --- a/src/app/root-store/feature-stores.module.ts +++ b/src/app/root-store/feature-stores.module.ts @@ -52,7 +52,6 @@ import { projectFolderFeatureKey, projectFolderReducer, } from '../features/project-folder/store/project-folder.reducer'; -import { ProjectFolderEffects } from '../features/project-folder/store/project-folder.effects'; import { SIMPLE_COUNTER_FEATURE_NAME, simpleCounterReducer, @@ -144,7 +143,6 @@ import { PluginHooksEffects } from '../plugins/plugin-hooks.effects'; EffectsModule.forFeature([ProjectEffects]), StoreModule.forFeature(projectFolderFeatureKey, projectFolderReducer), - EffectsModule.forFeature([ProjectFolderEffects]), StoreModule.forFeature(SIMPLE_COUNTER_FEATURE_NAME, simpleCounterReducer), EffectsModule.forFeature([SimpleCounterEffects]), diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 73947f830..b472bd897 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -860,6 +860,25 @@ const T = { UPDATED: 'F.PROJECT.S.UPDATED', }, }, + PROJECT_FOLDER: { + DIALOG: { + CREATE_TITLE: 'F.PROJECT_FOLDER.DIALOG.CREATE_TITLE', + EDIT_TITLE: 'F.PROJECT_FOLDER.DIALOG.EDIT_TITLE', + NAME_LABEL: 'F.PROJECT_FOLDER.DIALOG.NAME_LABEL', + NAME_PLACEHOLDER: 'F.PROJECT_FOLDER.DIALOG.NAME_PLACEHOLDER', + NAME_REQUIRED: 'F.PROJECT_FOLDER.DIALOG.NAME_REQUIRED', + PARENT_LABEL: 'F.PROJECT_FOLDER.DIALOG.PARENT_LABEL', + NO_PARENT: 'F.PROJECT_FOLDER.DIALOG.NO_PARENT', + }, + SELECT: { + LABEL: 'F.PROJECT_FOLDER.SELECT.LABEL', + PLACEHOLDER: 'F.PROJECT_FOLDER.SELECT.PLACEHOLDER', + NO_PARENT: 'F.PROJECT_FOLDER.SELECT.NO_PARENT', + }, + CONFIRM_DELETE: 'F.PROJECT_FOLDER.CONFIRM_DELETE', + TOOLTIP_CREATE: 'F.PROJECT_FOLDER.TOOLTIP_CREATE', + TOOLTIP_VISIBILITY: 'F.PROJECT_FOLDER.TOOLTIP_VISIBILITY', + }, QUICK_HISTORY: { NO_DATA: 'F.QUICK_HISTORY.NO_DATA', PAGE_TITLE: 'F.QUICK_HISTORY.PAGE_TITLE', diff --git a/src/app/ui/tree-dnd/tree.component.ts b/src/app/ui/tree-dnd/tree.component.ts index 26224d75e..39dae99de 100644 --- a/src/app/ui/tree-dnd/tree.component.ts +++ b/src/app/ui/tree-dnd/tree.component.ts @@ -336,6 +336,9 @@ export class TreeDndComponent implements AfterViewInit { } private flashJustDropped(id: string): void { + if (typeof window === 'undefined') { + return; + } this.justDroppedId.set(id); if (this._dropFlashTimer) clearTimeout(this._dropFlashTimer); this._dropFlashTimer = window.setTimeout(() => { diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index 51327ca6b..2a8aeb8c4 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -812,6 +812,25 @@ "UPDATED": "Projekteinstellungen aktualisiert" } }, + "PROJECT_FOLDER": { + "DIALOG": { + "CREATE_TITLE": "Ordner erstellen", + "EDIT_TITLE": "Ordner bearbeiten", + "NAME_LABEL": "Ordnername", + "NAME_PLACEHOLDER": "Ordnernamen eingeben", + "NAME_REQUIRED": "Ordnername ist erforderlich", + "PARENT_LABEL": "Übergeordneter Ordner", + "NO_PARENT": "Kein übergeordneter Ordner (Root-Ebene)" + }, + "SELECT": { + "LABEL": "Ordner", + "PLACEHOLDER": "Ordner wählen", + "NO_PARENT": "Kein Ordner (Root-Ebene)" + }, + "CONFIRM_DELETE": "Möchtest du den Ordner \"{{title}}\" wirklich löschen? Alle Projekte in diesem Ordner werden auf die oberste Ebene verschoben.", + "TOOLTIP_CREATE": "Projektordner erstellen", + "TOOLTIP_VISIBILITY": "Projekte anzeigen/ausblenden" + }, "QUICK_HISTORY": { "NO_DATA": "Keine Daten für das laufende Jahr", "PAGE_TITLE": "Schnellverlauf", diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index b050ac628..af3964e02 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -846,6 +846,25 @@ "UPDATED": "Updated project settings" } }, + "PROJECT_FOLDER": { + "DIALOG": { + "CREATE_TITLE": "Create folder", + "EDIT_TITLE": "Edit folder", + "NAME_LABEL": "Folder name", + "NAME_PLACEHOLDER": "Enter folder name", + "NAME_REQUIRED": "Folder name is required", + "PARENT_LABEL": "Parent folder", + "NO_PARENT": "No parent (root level)" + }, + "SELECT": { + "LABEL": "Folder", + "PLACEHOLDER": "Select folder", + "NO_PARENT": "No folder (root level)" + }, + "CONFIRM_DELETE": "Are you sure you want to delete the folder \"{{title}}\"? All projects in this folder will be moved to the root level.", + "TOOLTIP_CREATE": "Create project folder", + "TOOLTIP_VISIBILITY": "Show/hide projects" + }, "QUICK_HISTORY": { "NO_DATA": "No data for current year", "PAGE_TITLE": "Quick History",