mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
feat(projectFolders): first working draft
This commit is contained in:
parent
c5740a76ca
commit
e669037c17
27 changed files with 918 additions and 6 deletions
89
e2e/tests/project-folders/project-folders.spec.ts
Normal file
89
e2e/tests/project-folders/project-folders.spec.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
6
src/app/features/project-folder/project-folder.model.ts
Normal file
6
src/app/features/project-folder/project-folder.model.ts
Normal 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>;
|
||||
125
src/app/features/project-folder/project-folder.service.ts
Normal file
125
src/app/features/project-folder/project-folder.service.ts
Normal 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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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[] }>(),
|
||||
);
|
||||
132
src/app/features/project-folder/store/project-folder.effects.ts
Normal file
132
src/app/features/project-folder/store/project-folder.effects.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { ProjectFolderState } from '../project-folder.model';
|
||||
import { adapter } from './project-folder.reducer';
|
||||
|
||||
export const initialProjectFolderState: ProjectFolderState = adapter.getInitialState({});
|
||||
|
|
@ -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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
@ -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),
|
||||
);
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue