feat(projectFolders): improve code quality and extract translations

This commit is contained in:
Johannes Millan 2025-09-17 19:38:17 +02:00
parent 9332bd4701
commit 7dc91c1b6a
18 changed files with 341 additions and 425 deletions

View file

@ -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);
});
});

208
package-lock.json generated
View file

@ -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,

View file

@ -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",

View file

@ -3,7 +3,7 @@
(click)="editFolder()"
>
<mat-icon>edit</mat-icon>
<span>Edit</span>
<span>{{ T.G.EDIT | translate }}</span>
</button>
<button
@ -11,5 +11,5 @@
(click)="deleteFolder()"
>
<mat-icon>delete</mat-icon>
<span>Delete</span>
<span>{{ T.G.DELETE | translate }}</span>
</button>

View file

@ -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<void> {
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<boolean>((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()

View file

@ -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(),
},
{

View file

@ -65,111 +65,112 @@
</button>
}
<!-- Navigation Items -->
<ul
class="nav-list"
role="list"
>
@for (item of config().items; track item.id) {
@switch (item.type) {
@case ('separator') {
<hr
class="nav-separator"
[style.margin-top]="item.mtAuto && 'auto'"
/>
}
@case ('group') {
<li
class="nav-item has-children"
role="listitem"
>
<nav-list-tree
<!-- Navigation Items -->
<ul
class="nav-list"
role="list"
>
@for (item of config().items; track item.id) {
@switch (item.type) {
@case ('separator') {
<hr
class="nav-separator"
[style.margin-top]="item.mtAuto && 'auto'"
/>
}
@case ('group') {
<li
class="nav-item has-children"
role="listitem"
>
<nav-list-tree
[item]="item"
[showLabels]="showText()"
[isExpanded]="isGroupExpanded(item)"
[activeWorkContextId]="activeWorkContextId()"
(itemClick)="onItemClick($event)"
></nav-list-tree>
</li>
}
@case ('menu') {
<nav-mat-menu
[item]="item"
[showLabels]="showText()"
[isExpanded]="isGroupExpanded(item)"
[activeWorkContextId]="activeWorkContextId()"
(itemClick)="onItemClick($event)"
></nav-list-tree>
</li>
}
@case ('menu') {
<nav-mat-menu
[item]="item"
[showLabels]="showText()"
(itemClick)="onItemClick($event)"
></nav-mat-menu>
}
@default {
<li
class="nav-item"
role="listitem"
>
@switch (item.type) {
@case ('workContext') {
<nav-item
[workContext]="item.workContext"
[type]="item.workContextType"
[defaultIcon]="item.defaultIcon"
[activeWorkContextId]="activeWorkContextId() || ''"
[variant]="'nav'"
[showLabels]="showText()"
[showMoreButton]="showText()"
(clicked)="onItemClick(item)"
></nav-item>
></nav-mat-menu>
}
@default {
<li
class="nav-item"
role="listitem"
>
@switch (item.type) {
@case ('workContext') {
<nav-item
[workContext]="item.workContext"
[type]="item.workContextType"
[defaultIcon]="item.defaultIcon"
[activeWorkContextId]="activeWorkContextId() || ''"
[variant]="'nav'"
[showLabels]="showText()"
[showMoreButton]="showText()"
(clicked)="onItemClick(item)"
></nav-item>
}
@case ('route') {
<nav-item
[container]="'route'"
[navRoute]="item.route"
[icon]="item.icon"
[svgIcon]="item.svgIcon"
[label]="item.label"
[showLabels]="showText()"
[tourClass]="item.tourClass"
(clicked)="onItemClick(item)"
></nav-item>
}
@case ('href') {
<nav-item
[container]="'href'"
[navHref]="item.href"
[icon]="item.icon"
[svgIcon]="item.svgIcon"
[label]="item.label"
[showLabels]="showText()"
[tourClass]="item.tourClass"
(clicked)="onItemClick(item)"
></nav-item>
}
@case ('action') {
<nav-item
[container]="'action'"
[icon]="item.icon"
[svgIcon]="item.svgIcon"
[label]="item.label"
[showLabels]="showText()"
[tourClass]="item.tourClass"
(clicked)="onItemClick(item)"
></nav-item>
}
@case ('plugin') {
<nav-item
[container]="'action'"
[icon]="item.icon"
[svgIcon]="item.svgIcon"
[label]="item.label"
[showLabels]="showText()"
[tourClass]="item.tourClass"
(clicked)="onItemClick(item)"
></nav-item>
}
}
@case ('route') {
<nav-item
[container]="'route'"
[navRoute]="item.route"
[icon]="item.icon"
[svgIcon]="item.svgIcon"
[label]="item.label"
[showLabels]="showText()"
[tourClass]="item.tourClass"
(clicked)="onItemClick(item)"
></nav-item>
}
@case ('href') {
<nav-item
[container]="'href'"
[navHref]="item.href"
[icon]="item.icon"
[svgIcon]="item.svgIcon"
[label]="item.label"
[showLabels]="showText()"
[tourClass]="item.tourClass"
(clicked)="onItemClick(item)"
></nav-item>
}
@case ('action') {
<nav-item
[container]="'action'"
[icon]="item.icon"
[svgIcon]="item.svgIcon"
[label]="item.label"
[showLabels]="showText()"
[tourClass]="item.tourClass"
(clicked)="onItemClick(item)"
></nav-item>
}
@case ('plugin') {
<nav-item
[container]="'action'"
[icon]="item.icon"
[svgIcon]="item.svgIcon"
[label]="item.label"
[showLabels]="showText()"
[tourClass]="item.tourClass"
(clicked)="onItemClick(item)"
></nav-item>
}
}
</li>
</li>
}
}
}
}
</ul>
</nav>}
</ul>
</nav>
}
<!-- Resize Handle (moved outside scrolling container) -->
@if (config().resizable && !isMobile()) {

View file

@ -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<string, string[]>,
rootProjectIds: string[],
rootLayout: string[],
folderParentMap: Map<string, string | null>,
orderedFolderIds: string[],
): void {
const currentFolders = this._projectFolders();
const nextFolderById = new Map<string, ProjectFolder>();
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<NavGroupItem>[]): 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<NavGroupItem>[]): {
folderProjectMap: Map<string, string[]>;
rootProjectIds: string[];
orderedProjectIds: string[];
rootLayout: string[];
folderParentMap: Map<string, string | null>;
orderedFolderIds: string[];
} {
const folderProjectMap = new Map<string, string[]>();
const rootProjectIds: string[] = [];
const orderedProjectIds: string[] = [];
const rootLayout: string[] = [];
const folderParentMap = new Map<string, string | null>();
const orderedFolderIds: string[] = [];
const visit = (
node: TreeNode<NavGroupItem>,
@ -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 {

View file

@ -1,4 +1,11 @@
<h2 mat-dialog-title>{{ isEdit ? 'Edit Folder' : 'Create New Folder' }}</h2>
<h2 mat-dialog-title>
{{
(isEdit
? T.F.PROJECT_FOLDER.DIALOG.EDIT_TITLE
: T.F.PROJECT_FOLDER.DIALOG.CREATE_TITLE
) | translate
}}
</h2>
<form
[formGroup]="form"
@ -9,15 +16,17 @@
appearance="outline"
class="full-width"
>
<mat-label>Folder Name</mat-label>
<mat-label>{{ T.F.PROJECT_FOLDER.DIALOG.NAME_LABEL | translate }}</mat-label>
<input
matInput
formControlName="title"
placeholder="Enter folder name"
[placeholder]="T.F.PROJECT_FOLDER.DIALOG.NAME_PLACEHOLDER | translate"
required
/>
@if (form.get('title')?.errors?.['required']) {
<mat-error>Folder name is required</mat-error>
<mat-error>
{{ T.F.PROJECT_FOLDER.DIALOG.NAME_REQUIRED | translate }}
</mat-error>
}
</mat-form-field>
@ -25,9 +34,11 @@
appearance="outline"
class="full-width"
>
<mat-label>Parent Folder</mat-label>
<mat-label>{{ T.F.PROJECT_FOLDER.DIALOG.PARENT_LABEL | translate }}</mat-label>
<mat-select formControlName="parentId">
<mat-option [value]="null">No parent (root level)</mat-option>
<mat-option [value]="null">
{{ T.F.PROJECT_FOLDER.DIALOG.NO_PARENT | translate }}
</mat-option>
@for (folder of availableFolders$ | async; track folder.id) {
@if (!isEdit || folder.id !== data?.folder?.id) {
<mat-option [value]="folder.id">{{ folder.title }}</mat-option>
@ -43,7 +54,7 @@
mat-button
(click)="cancel()"
>
Cancel
{{ T.G.CANCEL | translate }}
</button>
<button
type="submit"
@ -51,7 +62,7 @@
color="primary"
[disabled]="form.invalid"
>
{{ isEdit ? 'Save' : 'Create' }}
{{ (isEdit ? T.G.SAVE : T.G.ADD) | translate }}
</button>
</mat-dialog-actions>
</form>

View file

@ -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;

View file

@ -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: `
<mat-form-field appearance="outline">
<mat-label>{{ to.label }}</mat-label>
<mat-label>
{{ to.label || (T.F.PROJECT_FOLDER.SELECT.LABEL | translate) }}
</mat-label>
<mat-select
[formControl]="formControl"
[placeholder]="to.placeholder || 'Select folder'"
[placeholder]="
to.placeholder ?? (T.F.PROJECT_FOLDER.SELECT.PLACEHOLDER | translate)
"
>
<mat-option [value]="null">No folder (root level)</mat-option>
<mat-option [value]="null">
{{ T.F.PROJECT_FOLDER.SELECT.NO_PARENT | translate }}
</mat-option>
@for (folder of projectFolderService.projectFolders$ | async; track folder.id) {
<mat-option [value]="folder.id">{{ folder.title }}</mat-option>
}
@ -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<FieldTypeConfig> {
readonly projectFolderService = inject(ProjectFolderService);
readonly T = T;
}

View file

@ -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<string>();
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 }),
);

View file

@ -1,7 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { Actions } from '@ngrx/effects';
@Injectable()
export class ProjectFolderEffects {
private readonly actions$ = inject(Actions);
}

View file

@ -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]),

View file

@ -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',

View file

@ -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(() => {

View file

@ -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",

View file

@ -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",