refactor(projectFolders): further improve and simplify

This commit is contained in:
Johannes Millan 2025-09-19 15:50:57 +02:00
parent 6198a0a2b0
commit 5d4d94d1e4
9 changed files with 102 additions and 257 deletions

View file

@ -1,60 +0,0 @@
import { expect, test } from '../../fixtures/test.fixture';
test.describe('Project Folders', () => {
test('can create a project folder via navigation', async ({ page, workViewPage }) => {
await workViewPage.waitForTaskList();
const navigation = page.locator('nav.nav-sidenav, .nav-sidenav');
await expect(navigation).toBeVisible();
const projectsHeader = page
.locator('.g-multi-btn-wrapper')
.filter({ hasText: 'Projects' })
.first();
await expect(projectsHeader).toBeVisible();
const createFolderBtn = projectsHeader
.locator('button.additional-btn')
.filter({ has: page.locator('mat-icon', { hasText: 'create_new_folder' }) })
.first();
await createFolderBtn.click();
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('opens create project dialog from navigation', async ({ page, workViewPage }) => {
await workViewPage.waitForTaskList();
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();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await expect(dialog.locator('input[formcontrolname="title"]')).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toHaveCount(0);
});
});

View file

@ -71,6 +71,7 @@ export class MagicNavConfigService {
private readonly _pluginMenuEntries = this._pluginBridge.menuEntries;
constructor() {
// TODO these should probably live in the _menuTreeService
effect(() => {
const projects = this._visibleProjects();
if (projects.length && !this._menuTreeService.hasProjectTree()) {
@ -78,6 +79,7 @@ export class MagicNavConfigService {
}
});
// TODO these should probably live in the _menuTreeService
effect(() => {
const tags = this._tags();
if (tags.length && !this._menuTreeService.hasTagTree()) {
@ -375,10 +377,24 @@ export class MagicNavConfigService {
}
private _openCreateProjectFolder(): void {
// TODO properly implement folder creation via DialogPromptComponent
this._matDialog.open(DialogPromptComponent, {
restoreFocus: true,
});
this._matDialog
.open(DialogPromptComponent, {
restoreFocus: true,
data: {
placeholder: T.F.PROJECT_FOLDER.DIALOG.NAME_PLACEHOLDER,
},
})
.afterClosed()
.subscribe((title) => {
if (!title) {
return;
}
const trimmed = title.trim();
if (!trimmed) {
return;
}
this._menuTreeService.createProjectFolder(trimmed);
});
}
private _createNewTag(): void {

View file

@ -92,20 +92,6 @@
></nav-list-tree>
</li>
}
@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"

View file

@ -1,46 +0,0 @@
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
import { CommonModule } from '@angular/common';
import { MenuTreeService } from '../../menu-tree.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 || (T.F.PROJECT_FOLDER.SELECT.LABEL | translate) }}
</mat-label>
<mat-select
[formControl]="formControl"
[placeholder]="
to.placeholder ?? (T.F.PROJECT_FOLDER.SELECT.PLACEHOLDER | translate)
"
>
<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>
}
</mat-select>
</mat-form-field>
`,
styleUrls: ['./project-folder-select.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
MatFormFieldModule,
MatSelectModule,
MatOptionModule,
TranslateModule,
],
})
export class MenuTreeSelectComponent extends FieldType<FieldTypeConfig> {
readonly projectFolderService = inject(MenuTreeService);
readonly T = T;
}

View file

@ -111,6 +111,29 @@ export class MenuTreeService {
this.setTagTree(stored);
}
createProjectFolder(name: string, parentFolderId?: string | null): void {
const trimmed = name.trim();
if (!trimmed) {
return;
}
const newFolder: MenuTreeFolderNode = {
kind: 'folder',
id: this._createFolderId(),
name: trimmed,
isExpanded: true,
children: [],
};
const currentTree = this.projectTree();
const nextTree = this._insertFolderNode(
currentTree,
newFolder,
parentFolderId ?? null,
);
this.setProjectTree(nextTree);
}
private _buildViewTree<T extends { id: string }>(options: {
storedTree: MenuTreeTreeNode[];
items: T[];
@ -127,9 +150,6 @@ export class MenuTreeService {
const children = node.children
.map((child) => mapNode(child))
.filter((child): child is MenuTreeViewNode => child !== null);
if (children.length === 0) {
return null;
}
return {
kind: 'folder',
id: node.id,
@ -184,9 +204,6 @@ export class MenuTreeService {
const children = node.children
.map((child) => mapNode(child))
.filter((child): child is MenuTreeTreeNode => child !== null);
if (!children.length) {
return null;
}
return {
kind: 'folder',
id: node.id,
@ -235,4 +252,60 @@ export class MenuTreeService {
walk(nodes);
return result;
}
private _insertFolderNode(
tree: MenuTreeTreeNode[],
folder: MenuTreeFolderNode,
parentId: string | null,
): MenuTreeTreeNode[] {
const cloned = this._cloneTree(tree);
if (!parentId) {
return [...cloned, folder];
}
const target = this._findFolder(cloned, parentId);
if (!target) {
return [...cloned, folder];
}
target.children = [...target.children, folder];
target.isExpanded = true;
return cloned;
}
private _cloneTree(tree: MenuTreeTreeNode[]): MenuTreeTreeNode[] {
return tree.map((node) =>
node.kind === 'folder'
? {
kind: 'folder',
id: node.id,
name: node.name,
isExpanded: node.isExpanded,
children: this._cloneTree(node.children),
}
: { ...node },
);
}
private _findFolder(tree: MenuTreeTreeNode[], id: string): MenuTreeFolderNode | null {
for (const node of tree) {
if (node.kind === 'folder') {
if (node.id === id) {
return node;
}
const childMatch = this._findFolder(node.children, id);
if (childMatch) {
return childMatch;
}
}
}
return null;
}
private _createFolderId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return `folder-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
}

View file

@ -71,14 +71,6 @@ export const CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG: ConfigFormSection<Project>
description: T.G.ICON_INP_DESCRIPTION,
},
},
// {
// key: 'folderId',
// type: 'project-folder-select',
// templateOptions: {
// label: 'Folder',
// placeholder: 'Select folder (optional)',
// },
// },
{
key: 'isEnableBacklog',
type: 'checkbox',

View file

@ -7,9 +7,9 @@ export const createAppDataCompleteMock = (): AppDataCompleteNew => ({
project: {
...createEmptyEntity(),
},
projectFolder: {
...createEmptyEntity(),
rootItems: [],
menuTree: {
tagTree: [],
projectTree: [],
},
globalConfig: DEFAULT_GLOBAL_CONFIG,

View file

@ -1,113 +0,0 @@
import { Injectable } from '@angular/core';
export interface TreeNodeBase {
id: string;
children?: TreeNodeBase[];
}
@Injectable({
providedIn: 'root',
})
export class TreeUtilsService {
/**
* Finds a node in a tree by its ID using depth-first search
*/
findNode<T extends TreeNodeBase>(tree: T[], id: string): T | undefined {
for (const node of tree) {
if (node.id === id) {
return node;
}
if (node.children) {
const found = this.findNode(node.children as T[], id);
if (found) {
return found;
}
}
}
return undefined;
}
/**
* Maps over a tree structure recursively, applying a transformation function to each node
*/
mapTree<T extends TreeNodeBase>(tree: T[], mapFn: (node: T) => T): T[] {
return tree.map((node) => {
const mapped = mapFn(node);
if (mapped.children) {
return {
...mapped,
children: this.mapTree(mapped.children as T[], mapFn),
};
}
return mapped;
});
}
/**
* Removes a node from a tree and returns the updated tree and the removed node
*/
removeNode<T extends TreeNodeBase>(
tree: T[],
id: string,
): { tree: T[]; removed: T } | null {
const copy = [...tree];
for (let i = 0; i < copy.length; i++) {
const node = copy[i];
if (node.id === id) {
copy.splice(i, 1);
return { tree: copy, removed: node };
}
if (node.children) {
const result = this.removeNode(node.children as T[], id);
if (result) {
copy[i] = { ...node, children: result.tree };
return { tree: copy, removed: result.removed };
}
}
}
return null;
}
/**
* Inserts a node into a tree at the specified parent and index
*/
insertNode<T extends TreeNodeBase>(
tree: T[],
node: T,
parentId: string | null,
index: number | undefined,
): T[] {
if (!parentId) {
const copy = [...tree];
if (index === undefined || index < 0 || index > copy.length) {
copy.push(node);
} else {
copy.splice(index, 0, node);
}
return copy;
}
return tree.map((item) => {
if (item.id === parentId && item.children) {
const children = [...item.children] as T[];
const filtered = children.filter((child) => child.id !== node.id);
if (index === undefined || index < 0 || index > filtered.length) {
filtered.push(node);
} else {
filtered.splice(index, 0, node);
}
return {
...item,
children: filtered,
};
}
if (item.children) {
return {
...item,
children: this.insertNode(item.children as T[], node, parentId, index),
};
}
return item;
});
}
}