mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* 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:
parent
eca816a68c
commit
1810412015
7 changed files with 271 additions and 21 deletions
|
|
@ -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()"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue