feat(projectFolders): first working draft

This commit is contained in:
Johannes Millan 2025-09-11 17:41:46 +02:00
parent c5740a76ca
commit e669037c17
27 changed files with 918 additions and 6 deletions

View file

@ -0,0 +1,89 @@
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
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}`);
// 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,
});
}
// 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);
});
test('should test project creation flow', async ({ page, workViewPage }) => {
// Wait for work view to be ready
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,
});
await createProjectBtn.click();
// Wait for dialog or new project form to appear
await page.waitForTimeout(1000);
// 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');
}
}
});
});

View file

@ -225,6 +225,16 @@ export interface Task {
_hideSubTasksMode?: number;
}
export interface ProjectFolder {
id: string;
title: string;
icon?: string | null;
parentId?: string | null;
isExpanded?: boolean;
created: number;
updated?: number;
}
export interface Project {
id: string;
title: string;
@ -241,6 +251,7 @@ export interface Project {
noteIds: string[];
isEnableBacklog?: boolean;
isHiddenFromMenu?: boolean;
folderId?: string | null;
// Advanced config (internal) - must be any to match WorkContextCommon
advancedCfg: unknown;

View file

@ -9,11 +9,13 @@ import { WorkContextType } from '../../features/work-context/work-context.model'
import { WorkContextService } from '../../features/work-context/work-context.service';
import { TagService } from '../../features/tag/tag.service';
import { ProjectService } from '../../features/project/project.service';
import { ProjectFolderService } from '../../features/project-folder/project-folder.service';
import { ShepherdService } from '../../features/shepherd/shepherd.service';
import { TourId } from '../../features/shepherd/shepherd-steps.const';
import { T } from '../../t.const';
import { LS } from '../../core/persistence/storage-keys.const';
import { DialogCreateProjectComponent } from '../../features/project/dialogs/create-project/dialog-create-project.component';
import { DialogCreateEditProjectFolderComponent } from '../../features/project-folder/dialogs/create-edit-project-folder/dialog-create-edit-project-folder.component';
import { getGithubErrorUrl } from '../../core/error-handler/global-error-handler.util';
import { DialogPromptComponent } from '../../ui/dialog-prompt/dialog-prompt.component';
import {
@ -34,6 +36,7 @@ export class MagicNavConfigService {
private readonly _workContextService = inject(WorkContextService);
private readonly _tagService = inject(TagService);
private readonly _projectService = inject(ProjectService);
private readonly _projectFolderService = inject(ProjectFolderService);
private readonly _shepherdService = inject(ShepherdService);
private readonly _matDialog = inject(MatDialog);
private readonly _store = inject(Store);
@ -57,6 +60,10 @@ export class MagicNavConfigService {
this._store.select(selectUnarchivedVisibleProjects),
{ initialValue: [] },
);
private readonly _projectFolders = toSignal(
this._projectFolderService.projectFolders$,
{ initialValue: [] },
);
private readonly _allProjectsExceptInbox = toSignal(
this._store.select(selectAllProjectsExceptInbox),
{ initialValue: [] },
@ -124,6 +131,12 @@ export class MagicNavConfigService {
tooltip: 'Show/Hide Projects',
action: () => this._openProjectVisibilityMenu(),
},
{
id: 'add-project-folder',
icon: 'create_new_folder',
tooltip: 'Create Project Folder',
action: () => this._openCreateProjectFolder(),
},
{
id: 'add-project',
icon: 'add',
@ -300,16 +313,36 @@ export class MagicNavConfigService {
private _buildProjectItems(): NavItem[] {
const projects = this._visibleProjects();
const projectFolders = this._projectFolders();
const activeId = this._activeWorkContextId();
let filteredProjects = projects;
if (!this._isProjectsExpanded() && activeId) {
// Show only active project when group is collapsed
filteredProjects = projects.filter((project) => project.id === activeId);
// Build hierarchy: folders with their projects, plus root-level projects
const items: NavItem[] = [];
// Add top-level folders
const topLevelFolders = projectFolders.filter((folder) => !folder.parentId);
for (const folder of topLevelFolders) {
const folderItems = this._buildProjectFolderItems(
folder,
projects,
projectFolders,
activeId,
);
items.push(...folderItems);
}
return filteredProjects.map((project) => ({
type: 'workContext',
// Add root-level projects (projects not in any folder)
const rootProjects = projects.filter((project) => !project.folderId);
let filteredRootProjects = rootProjects;
if (!this._isProjectsExpanded() && activeId) {
// Show only active project when group is collapsed
filteredRootProjects = rootProjects.filter((project) => project.id === activeId);
}
const rootProjectItems = filteredRootProjects.map((project) => ({
type: 'workContext' as const,
id: `project-${project.id}`,
label: project.title,
icon: project.icon || 'folder_special',
@ -318,6 +351,110 @@ export class MagicNavConfigService {
workContextType: WorkContextType.PROJECT,
defaultIcon: project.icon || 'folder_special',
}));
items.push(...rootProjectItems);
return items;
}
private _buildProjectFolderItems(
folder: any,
allProjects: any[],
allFolders: any[],
activeId: string | null,
): NavItem[] {
const items: NavItem[] = [];
// Get projects in this folder
const folderProjects = allProjects.filter(
(project) => project.folderId === folder.id,
);
// Get sub-folders in this folder
const subFolders = allFolders.filter((f) => f.parentId === folder.id);
// If collapsed and no active items in this folder, skip it
if (!this._isProjectsExpanded() && activeId) {
const hasActiveProject = folderProjects.some((p) => p.id === activeId);
const hasActiveSubFolder = subFolders.some((f) =>
this._folderContainsActiveProject(f, allProjects, allFolders, activeId),
);
if (!hasActiveProject && !hasActiveSubFolder) {
return [];
}
}
// Add the folder as a group
const folderChildren: NavItem[] = [];
// Add sub-folders recursively
for (const subFolder of subFolders) {
const subFolderItems = this._buildProjectFolderItems(
subFolder,
allProjects,
allFolders,
activeId,
);
folderChildren.push(...subFolderItems);
}
// Add projects in this folder
let filteredProjects = folderProjects;
if (!this._isProjectsExpanded() && activeId) {
filteredProjects = folderProjects.filter((p) => p.id === activeId);
}
const projectItems = filteredProjects.map((project) => ({
type: 'workContext' as const,
id: `project-${project.id}`,
label: project.title,
icon: project.icon || 'folder_special',
route: `/project/${project.id}/tasks`,
workContext: project,
workContextType: WorkContextType.PROJECT,
defaultIcon: project.icon || 'folder_special',
}));
folderChildren.push(...projectItems);
// Only add the folder if it has children
if (folderChildren.length > 0) {
items.push({
type: 'group',
id: `folder-${folder.id}`,
label: folder.title,
icon: folder.isExpanded ? 'expand_more' : 'chevron_right',
children: folderChildren,
action: () => this._toggleFolderExpansion(folder.id),
});
}
return items;
}
private _folderContainsActiveProject(
folder: any,
allProjects: any[],
allFolders: any[],
activeId: string | null,
): boolean {
// Check if any project in this folder is active
const folderProjects = allProjects.filter(
(project) => project.folderId === folder.id,
);
if (folderProjects.some((p) => p.id === activeId)) {
return true;
}
// Check sub-folders recursively
const subFolders = allFolders.filter((f) => f.parentId === folder.id);
return subFolders.some((f) =>
this._folderContainsActiveProject(f, allProjects, allFolders, activeId),
);
}
private _toggleFolderExpansion(folderId: string): void {
this._projectFolderService.toggleFolderExpansion(folderId);
}
private _buildTagItems(): NavItem[] {
@ -384,6 +521,12 @@ export class MagicNavConfigService {
this._matDialog.open(DialogCreateProjectComponent, { restoreFocus: true });
}
private _openCreateProjectFolder(): void {
this._matDialog.open(DialogCreateEditProjectFolderComponent, {
restoreFocus: true,
});
}
private _createNewTag(): void {
this._matDialog
.open(DialogPromptComponent, {

View file

@ -77,6 +77,19 @@
(clicked)="onChildClick(child)"
></nav-item>
</li>
} @else if (child.type === 'group') {
<li
class="nav-child-item"
role="listitem"
>
<nav-list
[item]="child"
[showLabels]="true"
[isExpanded]="child.icon === 'expand_more'"
[activeWorkContextId]="activeWorkContextId()"
(itemClick)="onChildClick($event)"
></nav-list>
</li>
} @else {
<li
class="nav-child-item"

View file

@ -122,6 +122,17 @@
will-change: transform;
}
// Indentation for nested levels
// Applies a small offset to each nested nav-list level
:host-context(.nav-child-item) {
.g-multi-btn-wrapper {
padding-left: var(--s);
}
.nav-children {
margin-left: var(--s);
}
}
// CompactMode state behaviors inherited from parent sidenav
:host-context(.nav-sidenav.compactMode) {
.g-multi-btn-wrapper {

View file

@ -0,0 +1,57 @@
<h2 mat-dialog-title>{{ isEdit ? 'Edit Folder' : 'Create New Folder' }}</h2>
<form
[formGroup]="form"
(ngSubmit)="save()"
>
<mat-dialog-content>
<mat-form-field
appearance="outline"
class="full-width"
>
<mat-label>Folder Name</mat-label>
<input
matInput
formControlName="title"
placeholder="Enter folder name"
required
/>
@if (form.get('title')?.errors?.['required']) {
<mat-error>Folder name is required</mat-error>
}
</mat-form-field>
<mat-form-field
appearance="outline"
class="full-width"
>
<mat-label>Parent Folder</mat-label>
<mat-select formControlName="parentId">
<mat-option [value]="null">No parent (root level)</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>
}
}
</mat-select>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
type="button"
mat-button
(click)="cancel()"
>
Cancel
</button>
<button
type="submit"
mat-raised-button
color="primary"
[disabled]="form.invalid"
>
{{ isEdit ? 'Save' : 'Create' }}
</button>
</mat-dialog-actions>
</form>

View file

@ -0,0 +1,14 @@
.full-width {
width: 100%;
margin-bottom: 16px;
}
mat-dialog-content {
min-width: 400px;
padding: 24px;
}
mat-dialog-actions {
padding: 16px 24px 24px;
gap: 8px;
}

View file

@ -0,0 +1,83 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatButtonModule } from '@angular/material/button';
import { CommonModule } from '@angular/common';
import { ProjectFolder } from '../../project-folder.model';
import { ProjectFolderService } from '../../project-folder.service';
export interface DialogCreateEditProjectFolderData {
folder?: ProjectFolder;
}
@Component({
selector: 'dialog-create-edit-project-folder',
templateUrl: './dialog-create-edit-project-folder.component.html',
styleUrls: ['./dialog-create-edit-project-folder.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatButtonModule,
],
})
export class DialogCreateEditProjectFolderComponent {
private readonly _dialogRef = inject(
MatDialogRef<DialogCreateEditProjectFolderComponent>,
);
readonly data = inject<DialogCreateEditProjectFolderData>(MAT_DIALOG_DATA);
private readonly _projectFolderService = inject(ProjectFolderService);
private readonly _fb = inject(FormBuilder);
readonly isEdit = !!this.data?.folder;
readonly availableFolders$ = this._projectFolderService.topLevelFolders$;
form: FormGroup = this._fb.group({
title: ['', [Validators.required, Validators.minLength(1)]],
parentId: [null],
});
constructor() {
if (this.isEdit && this.data.folder) {
this.form.patchValue({
title: this.data.folder.title,
parentId: this.data.folder.parentId,
});
}
}
save(): void {
if (this.form.invalid) {
return;
}
const formValue = this.form.value;
if (this.isEdit && this.data.folder) {
this._projectFolderService.updateProjectFolder(this.data.folder.id, {
title: formValue.title,
parentId: formValue.parentId,
});
} else {
this._projectFolderService.addProjectFolder({
title: formValue.title,
parentId: formValue.parentId,
isExpanded: true,
});
}
this._dialogRef.close();
}
cancel(): void {
this._dialogRef.close();
}
}

View file

@ -0,0 +1,3 @@
.full-width {
width: 100%;
}

View file

@ -0,0 +1,29 @@
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
import { MatSelectModule } from '@angular/material/select';
import { MatOptionModule } from '@angular/material/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { ProjectFolderService } from '../../project-folder.service';
@Component({
selector: 'formly-field-project-folder-select',
template: `
<mat-select
[formControl]="formControl"
[placeholder]="props.placeholder || 'Select folder'"
>
<mat-option [value]="null">No folder (root level)</mat-option>
@for (folder of projectFolderService.projectFolders$ | async; track folder.id) {
<mat-option [value]="folder.id">{{ folder.title }}</mat-option>
}
</mat-select>
`,
styleUrls: ['./project-folder-select.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule, MatSelectModule, MatOptionModule],
})
export class ProjectFolderSelectComponent extends FieldType<FieldTypeConfig> {
readonly projectFolderService = inject(ProjectFolderService);
}

View file

@ -0,0 +1,6 @@
import { EntityState } from '@ngrx/entity';
import { ProjectFolder as PluginProjectFolder } from '@super-productivity/plugin-api';
export type ProjectFolder = Readonly<PluginProjectFolder>;
export type ProjectFolderState = EntityState<ProjectFolder>;

View file

@ -0,0 +1,125 @@
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';
import { select, Store } from '@ngrx/store';
import { nanoid } from 'nanoid';
import { ProjectFolder } from './project-folder.model';
import * as ProjectFolderActions from './store/project-folder.actions';
import * as ProjectFolderSelectors from './store/project-folder.selectors';
import { PfapiService } from '../../pfapi/pfapi.service';
import { ProjectService } from '../project/project.service';
@Injectable({
providedIn: 'root',
})
export class ProjectFolderService {
private readonly _store = inject(Store);
private readonly _pfapiService = inject(PfapiService);
private readonly _projectService = inject(ProjectService);
projectFolders$ = this._store.pipe(
select(ProjectFolderSelectors.selectAllProjectFolders),
);
topLevelFolders$ = this._store.pipe(
select(ProjectFolderSelectors.selectTopLevelFolders),
);
constructor() {
// Project folders are loaded via loadAllData action during app initialization
// No need to manually load them here
}
loadProjectFolders(): void {
this._store.dispatch(ProjectFolderActions.loadProjectFolders());
}
getAllProjectFolders(): Observable<ProjectFolder[]> {
return this.projectFolders$;
}
addProjectFolder(folderData: Omit<ProjectFolder, 'id' | 'created'>): void {
// Prevent nesting deeper than one level
if (folderData.parentId) {
// Check if the parent already has a parent (would create 2+ levels)
this.getFolderById(folderData.parentId)
.pipe(take(1))
.subscribe((parentFolder: ProjectFolder | undefined) => {
if (parentFolder?.parentId) {
console.warn('Cannot create folder: nesting is limited to one level');
return;
}
this._createFolder(folderData);
});
} else {
this._createFolder(folderData);
}
}
private _createFolder(folderData: Omit<ProjectFolder, 'id' | 'created'>): void {
const newFolder: ProjectFolder = {
id: nanoid(),
created: Date.now(),
updated: Date.now(),
isExpanded: true,
...folderData,
};
this._store.dispatch(
ProjectFolderActions.addProjectFolder({ projectFolder: newFolder }),
);
}
updateProjectFolder(id: string, changes: Partial<ProjectFolder>): void {
// Prevent nesting deeper than one level when updating parent
if (changes.parentId) {
this.getFolderById(changes.parentId)
.pipe(take(1))
.subscribe((parentFolder: ProjectFolder | undefined) => {
if (parentFolder?.parentId) {
console.warn('Cannot update folder: nesting is limited to one level');
return;
}
this._updateFolder(id, changes);
});
} else {
this._updateFolder(id, changes);
}
}
private _updateFolder(id: string, changes: Partial<ProjectFolder>): void {
const updatedChanges = {
...changes,
updated: Date.now(),
};
this._store.dispatch(
ProjectFolderActions.updateProjectFolder({ id, changes: updatedChanges }),
);
}
deleteProjectFolder(id: string): void {
// First move any projects in this folder to the root
this._projectService.moveProjectsFromFolderToRoot(id);
// Then delete the folder
this._store.dispatch(ProjectFolderActions.deleteProjectFolder({ id }));
}
toggleFolderExpansion(id: string): void {
this._store.dispatch(ProjectFolderActions.toggleFolderExpansion({ id }));
}
getFoldersByParentId(parentId: string | null): Observable<ProjectFolder[]> {
return this._store.pipe(
select(ProjectFolderSelectors.selectFoldersByParentId, { parentId }),
);
}
getFolderById(id: string): Observable<ProjectFolder | undefined> {
return this._store.pipe(
select(ProjectFolderSelectors.selectProjectFolderById, { id }),
);
}
}

View file

@ -0,0 +1,50 @@
import { createAction, props } from '@ngrx/store';
import { Update } from '@ngrx/entity';
import { ProjectFolder } from '../project-folder.model';
export const loadProjectFolders = createAction('[ProjectFolder] Load Project Folders');
export const loadProjectFoldersSuccess = createAction(
'[ProjectFolder] Load Project Folders Success',
props<{ projectFolders: ProjectFolder[] }>(),
);
export const addProjectFolder = createAction(
'[ProjectFolder] Add Project Folder',
props<{ projectFolder: ProjectFolder }>(),
);
export const addProjectFolderSuccess = createAction(
'[ProjectFolder] Add Project Folder Success',
props<{ projectFolder: ProjectFolder }>(),
);
export const updateProjectFolder = createAction(
'[ProjectFolder] Update Project Folder',
props<{ id: string; changes: Partial<ProjectFolder> }>(),
);
export const updateProjectFolderSuccess = createAction(
'[ProjectFolder] Update Project Folder Success',
props<{ update: Update<ProjectFolder> }>(),
);
export const deleteProjectFolder = createAction(
'[ProjectFolder] Delete Project Folder',
props<{ id: string }>(),
);
export const deleteProjectFolderSuccess = createAction(
'[ProjectFolder] Delete Project Folder Success',
props<{ id: string }>(),
);
export const toggleFolderExpansion = createAction(
'[ProjectFolder] Toggle Folder Expansion',
props<{ id: string }>(),
);
export const updateProjectFolderOrder = createAction(
'[ProjectFolder] Update Project Folder Order',
props<{ ids: string[] }>(),
);

View file

@ -0,0 +1,132 @@
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { of, from } from 'rxjs';
import { PfapiService } from '../../../pfapi/pfapi.service';
import * as ProjectFolderActions from './project-folder.actions';
@Injectable()
export class ProjectFolderEffects {
private readonly _actions$ = inject(Actions);
private readonly _pfapiService = inject(PfapiService);
loadProjectFolders$ = createEffect(() =>
this._actions$.pipe(
ofType(ProjectFolderActions.loadProjectFolders),
switchMap(() =>
from(this._pfapiService.m.projectFolder.load()).pipe(
map((projectFolderState) => {
const projectFolders = projectFolderState.ids
.map((id) => projectFolderState.entities[id])
.filter(
(
folder,
): folder is Readonly<import('../project-folder.model').ProjectFolder> =>
Boolean(folder),
);
return ProjectFolderActions.loadProjectFoldersSuccess({ projectFolders });
}),
catchError((error) => {
console.error('Error loading project folders:', error);
return of(
ProjectFolderActions.loadProjectFoldersSuccess({ projectFolders: [] }),
);
}),
),
),
),
);
addProjectFolder$ = createEffect(() =>
this._actions$.pipe(
ofType(ProjectFolderActions.addProjectFolder),
tap(({ projectFolder }) => {
this._pfapiService.m.projectFolder.load().then((currentState) => {
const newState: import('../project-folder.model').ProjectFolderState = {
...currentState,
entities: { ...currentState.entities, [projectFolder.id]: projectFolder },
ids: [...currentState.ids, projectFolder.id] as string[],
};
return this._pfapiService.m.projectFolder.save(newState, {
isUpdateRevAndLastUpdate: true,
});
});
}),
map(({ projectFolder }) =>
ProjectFolderActions.addProjectFolderSuccess({
projectFolder,
}),
),
),
);
updateProjectFolder$ = createEffect(() =>
this._actions$.pipe(
ofType(ProjectFolderActions.updateProjectFolder),
tap(({ id, changes }) => {
this._pfapiService.m.projectFolder.load().then((currentState) => {
const existingFolder = currentState.entities[id];
if (!existingFolder) return;
const newState: import('../project-folder.model').ProjectFolderState = {
...currentState,
entities: {
...currentState.entities,
[id]: { ...existingFolder, ...changes },
},
};
return this._pfapiService.m.projectFolder.save(newState, {
isUpdateRevAndLastUpdate: true,
});
});
}),
map(({ id, changes }) =>
ProjectFolderActions.updateProjectFolderSuccess({ update: { id, changes } }),
),
),
);
deleteProjectFolder$ = createEffect(() =>
this._actions$.pipe(
ofType(ProjectFolderActions.deleteProjectFolder),
tap(({ id }) => {
this._pfapiService.m.projectFolder.load().then((currentState) => {
const newEntities = { ...currentState.entities };
delete newEntities[id];
const newState: import('../project-folder.model').ProjectFolderState = {
...currentState,
entities: newEntities,
ids: currentState.ids.filter((existingId) => existingId !== id) as string[],
};
return this._pfapiService.m.projectFolder.save(newState, {
isUpdateRevAndLastUpdate: true,
});
});
}),
map(({ id }) => ProjectFolderActions.deleteProjectFolderSuccess({ id })),
),
);
toggleFolderExpansion$ = createEffect(
() =>
this._actions$.pipe(
ofType(ProjectFolderActions.toggleFolderExpansion),
tap(({ id }) => {
this._pfapiService.m.projectFolder.load().then((currentState) => {
const folder = currentState.entities[id];
if (!folder) return;
const newState: import('../project-folder.model').ProjectFolderState = {
...currentState,
entities: {
...currentState.entities,
[id]: { ...folder, isExpanded: !folder.isExpanded },
},
};
return this._pfapiService.m.projectFolder.save(newState, {
isUpdateRevAndLastUpdate: true,
});
});
}),
),
{ dispatch: false },
);
}

View file

@ -0,0 +1,4 @@
import { ProjectFolderState } from '../project-folder.model';
import { adapter } from './project-folder.reducer';
export const initialProjectFolderState: ProjectFolderState = adapter.getInitialState({});

View file

@ -0,0 +1,45 @@
import { createEntityAdapter, EntityAdapter } from '@ngrx/entity';
import { createReducer, on } from '@ngrx/store';
import { ProjectFolder, ProjectFolderState } from '../project-folder.model';
import * as ProjectFolderActions from './project-folder.actions';
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
export const projectFolderFeatureKey = 'projectFolder';
export const adapter: EntityAdapter<ProjectFolder> = createEntityAdapter<ProjectFolder>({
selectId: (projectFolder) => projectFolder.id,
sortComparer: (a, b) => a.title.localeCompare(b.title),
});
export const initialState: ProjectFolderState = adapter.getInitialState({});
export const projectFolderReducer = createReducer(
initialState,
on(loadAllData, (state, { appDataComplete }) =>
appDataComplete.projectFolder?.ids ? appDataComplete.projectFolder : state,
),
on(ProjectFolderActions.loadProjectFoldersSuccess, (state, { projectFolders }) =>
adapter.setAll(projectFolders, state),
),
on(ProjectFolderActions.addProjectFolderSuccess, (state, { projectFolder }) =>
adapter.addOne(projectFolder, state),
),
on(ProjectFolderActions.updateProjectFolderSuccess, (state, { update }) =>
adapter.updateOne(update, state),
),
on(ProjectFolderActions.deleteProjectFolderSuccess, (state, { id }) =>
adapter.removeOne(id, state),
),
on(ProjectFolderActions.toggleFolderExpansion, (state, { id }) => {
const folder = state.entities[id];
if (!folder) return state;
return adapter.updateOne(
{
id,
changes: { isExpanded: !folder.isExpanded },
},
state,
);
}),
);

View file

@ -0,0 +1,37 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ProjectFolderState } from '../project-folder.model';
import { adapter, projectFolderFeatureKey } from './project-folder.reducer';
export const selectProjectFolderState = createFeatureSelector<ProjectFolderState>(
projectFolderFeatureKey,
);
const { selectAll, selectEntities, selectIds, selectTotal } = adapter.getSelectors(
selectProjectFolderState,
);
export const selectAllProjectFolders = selectAll;
export const selectProjectFolderEntities = selectEntities;
export const selectProjectFolderIds = selectIds;
export const selectProjectFolderTotal = selectTotal;
export const selectProjectFolderFeatureState = selectProjectFolderState;
export const selectProjectFolderById = createSelector(
selectProjectFolderEntities,
(entities, props: { id: string }) => entities[props.id],
);
export const selectTopLevelFolders = createSelector(selectAllProjectFolders, (folders) =>
folders.filter((folder) => !folder.parentId),
);
export const selectFoldersByParentId = createSelector(
selectAllProjectFolders,
(folders, props: { parentId: string | null }) =>
folders.filter((folder) => folder.parentId === props.parentId),
);
export const selectExpandedFolderIds = createSelector(
selectAllProjectFolders,
(folders) => folders.filter((folder) => folder.isExpanded).map((folder) => folder.id),
);

View file

@ -71,6 +71,14 @@ 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

@ -217,4 +217,15 @@ export class ProjectService {
updateOrder(ids: string[]): void {
this._store$.dispatch(updateProjectOrder({ ids }));
}
moveProjectsFromFolderToRoot(folderId: string): void {
this.list$.pipe(take(1)).subscribe((projects) => {
const projectsInFolder = projects.filter(
(project) => project.folderId === folderId,
);
projectsInFolder.forEach((project) => {
this.update(project.id, { folderId: null });
});
});
}
}

View file

@ -15,6 +15,7 @@ import { plannerInitialState } from '../../features/planner/store/planner.reduce
import { GlobalConfigState } from '../../features/config/global-config.model';
import { issueProviderInitialState } from '../../features/issue/store/issue-provider.reducer';
import { initialBoardsState } from '../../features/boards/store/boards.reducer';
import { initialState as projectFolderInitialState } from '../../features/project-folder/store/project-folder.reducer';
export const SYNC_INITIAL_SYNC_TRIGGER = 'INITIAL_SYNC_TRIGGER';
export const SYNC_DEFAULT_AUDIT_TIME = 10000;
@ -26,6 +27,7 @@ export const SYNC_MIN_INTERVAL = 5000;
export const DEFAULT_APP_BASE_DATA: AppBaseData = {
project: initialProjectState,
projectFolder: projectFolderInitialState,
archivedProjects: {},
globalConfig: initialGlobalConfigState,
reminders: [],

View file

@ -14,9 +14,11 @@ import { NoteState } from '../../features/note/note.model';
import { PlannerState } from '../../features/planner/store/planner.reducer';
import { IssueProviderState } from '../../features/issue/issue.model';
import { BoardsState } from '../../features/boards/store/boards.reducer';
import { ProjectFolderState } from '../../features/project-folder/project-folder.model';
export interface AppBaseWithoutLastSyncModelChange {
project: ProjectState;
projectFolder: ProjectFolderState;
globalConfig: GlobalConfigState;
reminders: Reminder[];
planner: PlannerState;

View file

@ -6,6 +6,7 @@ import {
PfapiBaseCfg,
} from './api';
import { ProjectState } from '../features/project/project.model';
import { ProjectFolderState } from '../features/project-folder/project-folder.model';
import { GlobalConfigState } from '../features/config/global-config.model';
import { Reminder } from '../features/reminder/reminder.model';
import {
@ -23,6 +24,7 @@ import { TagState } from '../features/tag/tag.model';
import { SimpleCounterState } from '../features/simple-counter/simple-counter.model';
import { TaskRepeatCfgState } from '../features/task-repeat-cfg/task-repeat-cfg.model';
import { initialProjectState } from '../features/project/store/project.reducer';
import { initialState as initialProjectFolderState } from '../features/project-folder/store/project-folder.reducer';
import { DEFAULT_GLOBAL_CONFIG } from '../features/config/default-global-config.const';
import { initialNoteState } from '../features/note/store/note.reducer';
import { issueProviderInitialState } from '../features/issue/store/issue-provider.reducer';
@ -67,6 +69,7 @@ export const CROSS_MODEL_VERSION = 4.2 as const;
export type PfapiAllModelCfg = {
project: ModelCfg<ProjectState>;
projectFolder: ModelCfg<ProjectFolderState>;
globalConfig: ModelCfg<GlobalConfigState>;
planner: ModelCfg<PlannerState>;
boards: ModelCfg<BoardsState>;
@ -114,6 +117,12 @@ export const PFAPI_MODEL_CFGS: PfapiAllModelCfg = {
validate: appDataValidators.project,
repair: fixEntityStateConsistency,
},
projectFolder: {
defaultData: initialProjectFolderState,
isMainFileModel: true,
validate: appDataValidators.projectFolder,
repair: fixEntityStateConsistency,
},
tag: {
defaultData: initialTagState,
isMainFileModel: true,

View file

@ -4,6 +4,7 @@ import {
TimeTrackingState,
} from '../../features/time-tracking/time-tracking.model';
import { ProjectState } from '../../features/project/project.model';
import { ProjectFolderState } from '../../features/project-folder/project-folder.model';
import { TaskState } from '../../features/tasks/task.model';
import { createValidate } from 'typia';
import { TagState } from '../../features/tag/tag.model';
@ -40,6 +41,7 @@ const _validateTask = createValidate<TaskState>();
const _validateTaskRepeatCfg = createValidate<TaskRepeatCfgState>();
const _validateArchive = createValidate<ArchiveModel>();
const _validateProject = createValidate<ProjectState>();
const _validateProjectFolder = createValidate<ProjectFolderState>();
const _validateTag = createValidate<TagState>();
const _validateSimpleCounter = createValidate<SimpleCounterState>();
const _validateNote = createValidate<NoteState>();
@ -87,6 +89,8 @@ export const appDataValidators: {
archiveYoung: <R>(d: R | ArchiveModel) => validateArchiveModel(d),
archiveOld: <R>(d: R | ArchiveModel) => validateArchiveModel(d),
project: <R>(d: R | ProjectState) => _wrapValidate(_validateProject(d), d, true),
projectFolder: <R>(d: R | ProjectFolderState) =>
_wrapValidate(_validateProjectFolder(d), d, true),
tag: <R>(d: R | TagState) => _wrapValidate(_validateTag(d), d, true),
simpleCounter: <R>(d: R | SimpleCounterState) =>
_wrapValidate(_validateSimpleCounter(d), d, true),

View file

@ -48,6 +48,11 @@ import {
projectReducer,
} from '../features/project/store/project.reducer';
import { ProjectEffects } from '../features/project/store/project.effects';
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,
@ -138,6 +143,9 @@ import { PluginHooksEffects } from '../plugins/plugin-hooks.effects';
StoreModule.forFeature(PROJECT_FEATURE_NAME, projectReducer),
EffectsModule.forFeature([ProjectEffects]),
StoreModule.forFeature(projectFolderFeatureKey, projectFolderReducer),
EffectsModule.forFeature([ProjectFolderEffects]),
StoreModule.forFeature(SIMPLE_COUNTER_FEATURE_NAME, simpleCounterReducer),
EffectsModule.forFeature([SimpleCounterEffects]),

View file

@ -10,6 +10,8 @@ import { TAG_FEATURE_NAME } from '../features/tag/store/tag.reducer';
import { TagState } from '../features/tag/tag.model';
import { WORK_CONTEXT_FEATURE_NAME } from '../features/work-context/store/work-context.selectors';
import { ProjectState } from '../features/project/project.model';
import { ProjectFolderState } from '../features/project-folder/project-folder.model';
import { projectFolderFeatureKey } from '../features/project-folder/store/project-folder.reducer';
import { NoteState } from '../features/note/note.model';
import {
BOARDS_FEATURE_NAME,
@ -23,6 +25,7 @@ export interface RootState {
[TASK_FEATURE_NAME]: TaskState;
[WORK_CONTEXT_FEATURE_NAME]: WorkContextState;
[PROJECT_FEATURE_NAME]: ProjectState;
[projectFolderFeatureKey]: ProjectFolderState;
[TAG_FEATURE_NAME]: TagState;
[NOTE_FEATURE_NAME]: NoteState;
[LAYOUT_FEATURE_NAME]: LayoutState;

View file

@ -17,6 +17,7 @@ import { selectConfigFeatureState } from '../../features/config/store/global-con
import { selectIssueProviderState } from '../../features/issue/store/issue-provider.selectors';
import { selectNoteFeatureState } from '../../features/note/store/note.reducer';
import { selectProjectFeatureState } from '../../features/project/store/project.selectors';
import { selectProjectFolderFeatureState } from '../../features/project-folder/store/project-folder.selectors';
import { selectTagFeatureState } from '../../features/tag/store/tag.reducer';
import { selectPlannerState } from '../../features/planner/store/planner.selectors';
import { selectSimpleCounterFeatureState } from '../../features/simple-counter/store/simple-counter.reducer';
@ -60,6 +61,10 @@ export class SaveToDbEffects {
tag$ = this.createSaveEffect(selectTagFeatureState, 'tag');
project$ = this.createSaveEffect(selectProjectFeatureState, 'project');
projectFolder$ = this.createSaveEffect(
selectProjectFolderFeatureState,
'projectFolder',
);
globalCfg$ = this.createSaveEffect(selectConfigFeatureState, 'globalConfig');
planner$ = this.createSaveEffect(selectPlannerState, 'planner');
boards$ = this.createSaveEffect(selectBoardsState, 'boards');

View file

@ -22,6 +22,7 @@ import { FormlyMatSliderModule } from '@ngx-formly/material/slider';
import { FormlyTagSelectionComponent } from './formly-tag-selection/formly-tag-selection.component';
import { FormlyBtnComponent } from './formly-button/formly-btn.component';
import { FormlyImageInputComponent } from './formly-image-input/formly-image-input.component';
import { ProjectFolderSelectComponent } from '../features/project-folder/formly-fields/project-folder-select/project-folder-select.component';
@NgModule({
imports: [
@ -88,6 +89,12 @@ import { FormlyImageInputComponent } from './formly-image-input/formly-image-inp
extends: 'input',
wrappers: ['form-field'],
},
{
name: 'project-folder-select',
component: ProjectFolderSelectComponent,
extends: 'input',
wrappers: [],
},
],
extras: {
immutable: true,
@ -108,6 +115,7 @@ import { FormlyImageInputComponent } from './formly-image-input/formly-image-inp
// might be needed for formly to pick up on directives
ValidationModule,
FormlyLinkWidgetComponent,
ProjectFolderSelectComponent,
],
exports: [
FormlyMaterialModule,