mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
refactor(projectFolders): further improve and simplify
This commit is contained in:
parent
6198a0a2b0
commit
5d4d94d1e4
9 changed files with 102 additions and 257 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ export const createAppDataCompleteMock = (): AppDataCompleteNew => ({
|
|||
project: {
|
||||
...createEmptyEntity(),
|
||||
},
|
||||
projectFolder: {
|
||||
...createEmptyEntity(),
|
||||
rootItems: [],
|
||||
menuTree: {
|
||||
tagTree: [],
|
||||
projectTree: [],
|
||||
},
|
||||
globalConfig: DEFAULT_GLOBAL_CONFIG,
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue