feat(filetree): add option to add subfolders and items within folders #8525 (#8722)

* feat(filetree): add option to add subfolders and items within folders #8525

* fix(menu-tree): prevent item duplication when creating inside folder

* test(op-log): update ActionType spec and compact code for MENU_TREE_ADD_ITEM

* refactor(filetree): dedupe folder-id prefix strip in context menu

* refactor(menu-tree): add items to folders via existing tree-update op

Rework "add project/tag inside folder" to compute the new tree in the
service and dispatch the existing updateProjectTree/updateTagTree ops,
matching how "add subfolder" and drag-reorder already persist.

Removes the newly-introduced MENU_TREE_ADD_ITEM op type (action, enum
member, compact code 'MA', reducer handler) and the on(addProject)
handler (scope creep). Benefits:
- No new synced op type (the highest-risk change class).
- Forward-compatible: old clients understand updateProjectTree/Tag, so
  the item lands in the folder everywhere (the granular op no-op'd on
  old clients, leaving the item at root).
- _insertNodeIntoFolder falls back to root-append when the target
  folder is missing, so a placement is never silently dropped.

Behavior covered by new MenuTreeService specs (insert, null-folder,
missing-folder fallback, tag move-dedup).

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
This commit is contained in:
Maikel Hajiabadi 2026-07-08 16:33:40 +02:00 committed by GitHub
parent eca816a68c
commit 1810412015
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 271 additions and 21 deletions

View file

@ -6,6 +6,38 @@
<span>{{ T.G.EDIT | translate }}</span>
</button>
<button
mat-menu-item
(click)="addSubfolder()"
>
<mat-icon>create_new_folder</mat-icon>
<span>{{
treeKind === MenuTreeKind.PROJECT
? (T.F.PROJECT_FOLDER.ADD_SUBFOLDER | translate)
: (T.F.TAG_FOLDER.ADD_SUBFOLDER | translate)
}}</span>
</button>
@if (treeKind === MenuTreeKind.PROJECT) {
<button
mat-menu-item
(click)="addProject()"
>
<mat-icon>add</mat-icon>
<span>{{ T.F.PROJECT_FOLDER.ADD_PROJECT | translate }}</span>
</button>
}
@if (treeKind === MenuTreeKind.TAG) {
<button
mat-menu-item
(click)="addTag()"
>
<mat-icon>add</mat-icon>
<span>{{ T.F.TAG_FOLDER.ADD_TAG | translate }}</span>
</button>
}
<button
mat-menu-item
(click)="deleteFolder()"

View file

@ -8,11 +8,17 @@ import { MatIcon } from '@angular/material/icon';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { T } from '../../t.const';
import { DialogPromptComponent } from '../../ui/dialog-prompt/dialog-prompt.component';
import {
DialogCreateTagComponent,
CreateTagData,
} from '../../ui/dialog-create-tag/dialog-create-tag.component';
import { MenuTreeService } from '../../features/menu-tree/menu-tree.service';
import {
MenuTreeFolderNode,
MenuTreeKind,
} from '../../features/menu-tree/store/menu-tree.model';
import { TagService } from '../../features/tag/tag.service';
import { Router } from '@angular/router';
@Component({
selector: 'folder-context-menu',
@ -26,11 +32,14 @@ export class FolderContextMenuComponent {
private readonly _matDialog = inject(MatDialog);
private readonly _translateService = inject(TranslateService);
private readonly _menuTreeService = inject(MenuTreeService);
private readonly _tagService = inject(TagService);
private readonly _router = inject(Router);
@Input() folderId!: string;
@Input() treeKind: MenuTreeKind = MenuTreeKind.PROJECT;
readonly T = T;
readonly MenuTreeKind = MenuTreeKind;
editFolder(): void {
const folder = this._loadFolder(this.folderId);
@ -56,10 +65,7 @@ export class FolderContextMenuComponent {
if (!trimmed || trimmed === folder.name) {
return;
}
// Extract folder ID (remove the "folder-" prefix if present)
const cleanId = this.folderId.startsWith('folder-')
? this.folderId.substring(7)
: this.folderId;
const cleanId = this._cleanFolderId(this.folderId);
if (this.treeKind === MenuTreeKind.PROJECT) {
this._menuTreeService.updateFolderInProject(cleanId, trimmed);
@ -92,10 +98,7 @@ export class FolderContextMenuComponent {
.pipe(take(1))
.subscribe((result: boolean) => {
if (result) {
// Extract folder ID (remove the "folder-" prefix if present)
const cleanId = this.folderId.startsWith('folder-')
? this.folderId.substring(7)
: this.folderId;
const cleanId = this._cleanFolderId(this.folderId);
if (this.treeKind === MenuTreeKind.PROJECT) {
this._menuTreeService.deleteFolderFromProject(cleanId);
@ -106,14 +109,90 @@ export class FolderContextMenuComponent {
});
}
addSubfolder(): void {
const dialogRef = this._matDialog.open(DialogPromptComponent, {
restoreFocus: true,
data: {
placeholder: this._translateService.instant(
this.treeKind === MenuTreeKind.PROJECT
? T.F.PROJECT_FOLDER.DIALOG.NAME_PLACEHOLDER
: T.F.TAG_FOLDER.DIALOG.NAME_PLACEHOLDER,
),
},
});
dialogRef
.afterClosed()
.pipe(take(1))
.subscribe((title) => {
if (!title) return;
const trimmed = title.trim();
if (!trimmed) return;
const cleanParentId = this._cleanFolderId(this.folderId);
if (this.treeKind === MenuTreeKind.PROJECT) {
this._menuTreeService.createProjectFolder(trimmed, cleanParentId);
} else {
this._menuTreeService.createTagFolder(trimmed, cleanParentId);
}
});
}
async addProject(): Promise<void> {
const { DialogCreateProjectComponent } =
await import('../../features/project/dialogs/create-project/dialog-create-project.component');
this._matDialog
.open(DialogCreateProjectComponent, {
restoreFocus: true,
})
.afterClosed()
.pipe(take(1))
.subscribe((newProjectId: string | undefined) => {
if (newProjectId) {
const cleanParentId = this._cleanFolderId(this.folderId);
this._menuTreeService.addProjectToFolder(newProjectId, cleanParentId);
this._router.navigate([`project/${newProjectId}/tasks`]);
}
});
}
addTag(): void {
const dialogRef = this._matDialog.open(DialogCreateTagComponent, {
restoreFocus: true,
});
dialogRef
.afterClosed()
.pipe(take(1))
.subscribe((result: CreateTagData) => {
if (result && result.title) {
const newTagId = this._tagService.addTag({
title: result.title,
icon: result.icon,
color: result.color,
});
const cleanParentId = this._cleanFolderId(this.folderId);
this._menuTreeService.addTagToFolder(newTagId, cleanParentId);
}
});
}
// Tree node ids are prefixed with 'folder-'; store/service APIs expect the raw id.
private _cleanFolderId(id: string): string {
return id.startsWith('folder-') ? id.substring(7) : id;
}
private _loadFolder(folderId: string): MenuTreeFolderNode | null {
// Extract folder ID (remove the "folder-" prefix if present)
const cleanId = folderId.startsWith('folder-') ? folderId.substring(7) : folderId;
const cleanId = this._cleanFolderId(folderId);
const projectTree = this._menuTreeService.projectTree();
const tagTree = this._menuTreeService.tagTree();
// Search in the appropriate tree first, then fallback to the other
const primaryTree = this.treeKind === MenuTreeKind.PROJECT ? projectTree : tagTree;
const secondaryTree = this.treeKind === MenuTreeKind.PROJECT ? tagTree : projectTree;

View file

@ -181,7 +181,6 @@ describe('UpdateCheckService', () => {
let banner: Banner;
beforeEach(async () => {
// eslint-disable-next-line @typescript-eslint/naming-convention
await checkAndRespond({ tag_name: 'v99.0.0' });
banner = bannerService.open.calls.mostRecent().args[0];
});

View file

@ -1,7 +1,11 @@
import { TestBed } from '@angular/core/testing';
import { MenuTreeService } from './menu-tree.service';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { MenuTreeKind, MenuTreeTreeNode } from './store/menu-tree.model';
import {
MenuTreeFolderNode,
MenuTreeKind,
MenuTreeTreeNode,
} from './store/menu-tree.model';
import { menuTreeFeatureKey } from './store/menu-tree.reducer';
import {
selectMenuTreeProjectTree,
@ -321,4 +325,76 @@ describe('MenuTreeService', () => {
]);
});
});
describe('addProjectToFolder / addTagToFolder', () => {
const folderNode = (
id: string,
children: MenuTreeTreeNode[] = [],
): MenuTreeTreeNode => ({
id,
k: MenuTreeKind.FOLDER,
name: `Folder ${id}`,
isExpanded: false,
children,
});
let dispatchSpy: jasmine.Spy;
beforeEach(() => {
dispatchSpy = spyOn(store, 'dispatch');
});
const lastTree = (): MenuTreeTreeNode[] =>
(dispatchSpy.calls.mostRecent().args[0] as { tree: MenuTreeTreeNode[] }).tree;
it('inserts a project into the target folder and persists via updateProjectTree', () => {
store.overrideSelector(selectMenuTreeProjectTree, [folderNode('folder-1')]);
store.refreshState();
service.addProjectToFolder('p-1', 'folder-1');
expect(dispatchSpy).toHaveBeenCalledTimes(1);
const action = dispatchSpy.calls.mostRecent().args[0] as { type: string };
expect(action.type).toBe('[MenuTree] Update Project Tree');
const folder = lastTree()[0] as MenuTreeFolderNode;
expect(folder.id).toBe('folder-1');
expect(folder.children.map((c) => c.id)).toEqual(['p-1']);
expect(folder.isExpanded).toBeTrue();
});
it('appends the project at root when folderId is null', () => {
store.overrideSelector(selectMenuTreeProjectTree, []);
store.refreshState();
service.addProjectToFolder('p-1', null);
expect(lastTree().map((n) => n.id)).toEqual(['p-1']);
});
it('falls back to a root append when the target folder no longer exists', () => {
store.overrideSelector(selectMenuTreeProjectTree, []);
store.refreshState();
service.addProjectToFolder('p-1', 'missing-folder');
expect(lastTree().map((n) => n.id)).toEqual(['p-1']);
expect(lastTree()[0].k).toBe(MenuTreeKind.PROJECT);
});
it('moves a tag already at root into the folder without duplicating it', () => {
store.overrideSelector(selectMenuTreeTagTree, [
folderNode('folder-1'),
{ id: 't-1', k: MenuTreeKind.TAG },
]);
store.refreshState();
service.addTagToFolder('t-1', 'folder-1');
const action = dispatchSpy.calls.mostRecent().args[0] as { type: string };
expect(action.type).toBe('[MenuTree] Update Tag Tree');
// removed from root, present only inside the folder
expect(lastTree().map((n) => n.id)).toEqual(['folder-1']);
const folder = lastTree()[0] as MenuTreeFolderNode;
expect(folder.children.map((c) => c.id)).toEqual(['t-1']);
});
});
});

View file

@ -398,26 +398,45 @@ export class MenuTreeService {
return result;
}
private _insertFolderNode(
private _insertNodeIntoFolder(
tree: MenuTreeTreeNode[],
folder: MenuTreeFolderNode,
node: MenuTreeTreeNode,
parentId: string | null,
): MenuTreeTreeNode[] {
const cloned = this._cloneTree(tree);
// Append at root when no parent is given or the target folder no longer
// exists (e.g. it was deleted on another device) — never drop the node.
if (!parentId) {
return [...cloned, folder];
return [...cloned, node];
}
const target = this._findFolder(cloned, parentId);
if (!target) {
return [...cloned, folder];
return [...cloned, node];
}
target.children = [...target.children, folder];
target.children = [...target.children, node];
target.isExpanded = true;
return cloned;
}
private _removeItemFromTree(
tree: MenuTreeTreeNode[],
itemId: string,
itemKind: MenuTreeKind.PROJECT | MenuTreeKind.TAG,
): MenuTreeTreeNode[] {
return tree
.filter((node) => !(node.k === itemKind && node.id === itemId))
.map((node) =>
node.k === MenuTreeKind.FOLDER
? {
...node,
children: this._removeItemFromTree(node.children, itemId, itemKind),
}
: node,
);
}
private _cloneTree(tree: MenuTreeTreeNode[]): MenuTreeTreeNode[] {
return tree.map((node) =>
node.k === MenuTreeKind.FOLDER
@ -474,7 +493,7 @@ export class MenuTreeService {
const currentTree =
options.treeKind === MenuTreeKind.PROJECT ? this.projectTree() : this.tagTree();
const nextTree = this._insertFolderNode(
const nextTree = this._insertNodeIntoFolder(
currentTree,
newFolder,
options.parentFolderId,
@ -486,4 +505,41 @@ export class MenuTreeService {
this.setTagTree(nextTree);
}
}
addProjectToFolder(projectId: string, folderId: string | null): void {
this.setProjectTree(
this._placeItemInFolder(
this.projectTree(),
projectId,
MenuTreeKind.PROJECT,
folderId,
),
);
}
addTagToFolder(tagId: string, folderId: string | null): void {
this.setTagTree(
this._placeItemInFolder(this.tagTree(), tagId, MenuTreeKind.TAG, folderId),
);
}
/**
* Move an item into a folder by rewriting the whole tree, then persist via the
* existing `updateProjectTree`/`updateTagTree` op. Reuses the same op old
* clients already understand (avoids a new op type) and matches how folder
* creation and drag-reorder already persist. Removes any prior placement first
* so `addTag`'s root insertion (or a re-add) does not duplicate the item.
*/
private _placeItemInFolder(
tree: MenuTreeTreeNode[],
itemId: string,
itemKind: MenuTreeKind.PROJECT | MenuTreeKind.TAG,
folderId: string | null,
): MenuTreeTreeNode[] {
return this._insertNodeIntoFolder(
this._removeItemFromTree(tree, itemId, itemKind),
{ k: itemKind, id: itemId } as MenuTreeProjectNode | MenuTreeTagNode,
folderId,
);
}
}

View file

@ -1081,6 +1081,8 @@ const T = {
},
TOOLTIP_CREATE: 'F.PROJECT_FOLDER.TOOLTIP_CREATE',
TOOLTIP_VISIBILITY: 'F.PROJECT_FOLDER.TOOLTIP_VISIBILITY',
ADD_SUBFOLDER: 'F.PROJECT_FOLDER.ADD_SUBFOLDER',
ADD_PROJECT: 'F.PROJECT_FOLDER.ADD_PROJECT',
},
REDMINE: {
DIALOG_TRACK_TIME: {
@ -1731,6 +1733,8 @@ const T = {
PLACEHOLDER: 'F.TAG_FOLDER.SELECT.PLACEHOLDER',
},
TOOLTIP_CREATE: 'F.TAG_FOLDER.TOOLTIP_CREATE',
ADD_SUBFOLDER: 'F.TAG_FOLDER.ADD_SUBFOLDER',
ADD_TAG: 'F.TAG_FOLDER.ADD_TAG',
},
TASK: {
ADD_TASK_BAR: {

View file

@ -1060,7 +1060,9 @@
"PLACEHOLDER": "Select folder"
},
"TOOLTIP_CREATE": "Create project folder",
"TOOLTIP_VISIBILITY": "Show/hide projects"
"TOOLTIP_VISIBILITY": "Show/hide projects",
"ADD_SUBFOLDER": "Add subfolder",
"ADD_PROJECT": "Add project"
},
"REDMINE": {
"DIALOG_TRACK_TIME": {
@ -1682,7 +1684,9 @@
"NO_PARENT": "No folder (root level)",
"PLACEHOLDER": "Select folder"
},
"TOOLTIP_CREATE": "Create tag folder"
"TOOLTIP_CREATE": "Create tag folder",
"ADD_SUBFOLDER": "Add subfolder",
"ADD_TAG": "Add tag"
},
"TASK": {
"ADD_TASK_BAR": {