mirror of https://github.com/ghostfolio/ghostfolio
8 changed files with 495 additions and 10 deletions
@ -0,0 +1,164 @@ |
|||||
|
import { DataService } from '@ghostfolio/ui/services'; |
||||
|
|
||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'; |
||||
|
import { MatDialogRef } from '@angular/material/dialog'; |
||||
|
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; |
||||
|
import { of } from 'rxjs'; |
||||
|
|
||||
|
import { GfManageBudgetCategoriesDialogComponent } from './manage-budget-categories-dialog.component'; |
||||
|
|
||||
|
describe('GfManageBudgetCategoriesDialogComponent', () => { |
||||
|
const createdAt = new Date('2026-06-01'); |
||||
|
const updatedAt = new Date('2026-06-01'); |
||||
|
|
||||
|
let component: GfManageBudgetCategoriesDialogComponent; |
||||
|
let dataService: jest.Mocked< |
||||
|
Pick< |
||||
|
DataService, |
||||
|
| 'createExpenseCategory' |
||||
|
| 'deleteExpenseCategory' |
||||
|
| 'fetchExpenseCategories' |
||||
|
| 'updateExpenseCategory' |
||||
|
> |
||||
|
>; |
||||
|
let dialogRef: jest.Mocked< |
||||
|
Pick<MatDialogRef<GfManageBudgetCategoriesDialogComponent>, 'close'> |
||||
|
>; |
||||
|
let fixture: ComponentFixture<GfManageBudgetCategoriesDialogComponent>; |
||||
|
|
||||
|
beforeEach(async () => { |
||||
|
dataService = { |
||||
|
createExpenseCategory: jest.fn().mockReturnValue( |
||||
|
of({ |
||||
|
color: '#0055aa', |
||||
|
createdAt, |
||||
|
id: 'category-2', |
||||
|
name: 'Transport', |
||||
|
updatedAt |
||||
|
}) |
||||
|
), |
||||
|
deleteExpenseCategory: jest.fn().mockReturnValue(of(undefined)), |
||||
|
fetchExpenseCategories: jest.fn().mockReturnValue( |
||||
|
of([ |
||||
|
{ |
||||
|
color: '#0055aa', |
||||
|
createdAt, |
||||
|
id: 'category-1', |
||||
|
name: 'Groceries', |
||||
|
updatedAt |
||||
|
} |
||||
|
]) |
||||
|
), |
||||
|
updateExpenseCategory: jest.fn().mockReturnValue( |
||||
|
of({ |
||||
|
color: '#aa5500', |
||||
|
createdAt, |
||||
|
id: 'category-1', |
||||
|
name: 'Food', |
||||
|
updatedAt |
||||
|
}) |
||||
|
) |
||||
|
}; |
||||
|
dialogRef = { |
||||
|
close: jest.fn() |
||||
|
}; |
||||
|
|
||||
|
await TestBed.configureTestingModule({ |
||||
|
imports: [GfManageBudgetCategoriesDialogComponent, NoopAnimationsModule], |
||||
|
providers: [ |
||||
|
{ |
||||
|
provide: DataService, |
||||
|
useValue: dataService |
||||
|
}, |
||||
|
{ |
||||
|
provide: MatDialogRef, |
||||
|
useValue: dialogRef |
||||
|
} |
||||
|
] |
||||
|
}).compileComponents(); |
||||
|
|
||||
|
fixture = TestBed.createComponent(GfManageBudgetCategoriesDialogComponent); |
||||
|
component = fixture.componentInstance; |
||||
|
fixture.autoDetectChanges(); |
||||
|
}); |
||||
|
|
||||
|
it('loads expense categories', async () => { |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(dataService.fetchExpenseCategories).toHaveBeenCalled(); |
||||
|
expect(component.dataSource.data).toEqual([ |
||||
|
{ |
||||
|
color: '#0055aa', |
||||
|
createdAt, |
||||
|
id: 'category-1', |
||||
|
name: 'Groceries', |
||||
|
updatedAt |
||||
|
} |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it('creates an expense category and reloads categories', async () => { |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
component.categoryForm.setValue({ |
||||
|
color: '#0055aa', |
||||
|
name: 'Transport' |
||||
|
}); |
||||
|
|
||||
|
component.onSubmit(); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(dataService.createExpenseCategory).toHaveBeenCalledWith({ |
||||
|
color: '#0055aa', |
||||
|
name: 'Transport' |
||||
|
}); |
||||
|
expect(dataService.fetchExpenseCategories).toHaveBeenCalledTimes(2); |
||||
|
}); |
||||
|
|
||||
|
it('updates an expense category and reloads categories', async () => { |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
component.onEditCategory({ |
||||
|
color: '#0055aa', |
||||
|
createdAt, |
||||
|
id: 'category-1', |
||||
|
name: 'Groceries', |
||||
|
updatedAt |
||||
|
}); |
||||
|
component.categoryForm.setValue({ |
||||
|
color: '#aa5500', |
||||
|
name: 'Food' |
||||
|
}); |
||||
|
|
||||
|
component.onSubmit(); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(dataService.updateExpenseCategory).toHaveBeenCalledWith({ |
||||
|
category: { |
||||
|
color: '#aa5500', |
||||
|
id: 'category-1', |
||||
|
name: 'Food' |
||||
|
}, |
||||
|
id: 'category-1' |
||||
|
}); |
||||
|
expect(dataService.fetchExpenseCategories).toHaveBeenCalledTimes(2); |
||||
|
}); |
||||
|
|
||||
|
it('deletes an expense category and reloads categories', async () => { |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
component.onDeleteCategory('category-1'); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(dataService.deleteExpenseCategory).toHaveBeenCalledWith( |
||||
|
'category-1' |
||||
|
); |
||||
|
expect(dataService.fetchExpenseCategories).toHaveBeenCalledTimes(2); |
||||
|
}); |
||||
|
|
||||
|
it('closes with a refresh result', () => { |
||||
|
component.onClose(); |
||||
|
|
||||
|
expect(dialogRef.close).toHaveBeenCalledWith({ refresh: true }); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,150 @@ |
|||||
|
import { ExpenseCategoryResponse } from '@ghostfolio/common/interfaces'; |
||||
|
import { DataService } from '@ghostfolio/ui/services'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
DestroyRef, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { |
||||
|
FormControl, |
||||
|
FormGroup, |
||||
|
ReactiveFormsModule, |
||||
|
Validators |
||||
|
} from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatIconModule } from '@angular/material/icon'; |
||||
|
import { MatInputModule } from '@angular/material/input'; |
||||
|
import { MatTableDataSource, MatTableModule } from '@angular/material/table'; |
||||
|
|
||||
|
@Component({ |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
MatButtonModule, |
||||
|
MatDialogModule, |
||||
|
MatFormFieldModule, |
||||
|
MatIconModule, |
||||
|
MatInputModule, |
||||
|
MatTableModule, |
||||
|
ReactiveFormsModule |
||||
|
], |
||||
|
selector: 'gf-manage-budget-categories-dialog', |
||||
|
styleUrls: ['./manage-budget-categories-dialog.scss'], |
||||
|
templateUrl: './manage-budget-categories-dialog.html' |
||||
|
}) |
||||
|
export class GfManageBudgetCategoriesDialogComponent implements OnInit { |
||||
|
public categoryForm = new FormGroup({ |
||||
|
color: new FormControl<string>('', { |
||||
|
nonNullable: true, |
||||
|
validators: [Validators.pattern(/^#[0-9a-fA-F]{6}$/)] |
||||
|
}), |
||||
|
name: new FormControl<string>('', { |
||||
|
nonNullable: true, |
||||
|
validators: [Validators.required] |
||||
|
}) |
||||
|
}); |
||||
|
public dataSource = new MatTableDataSource<ExpenseCategoryResponse>([]); |
||||
|
public displayedColumns = ['name', 'color', 'actions']; |
||||
|
public editingCategory: ExpenseCategoryResponse | undefined; |
||||
|
public isLoading = true; |
||||
|
|
||||
|
public constructor( |
||||
|
private changeDetectorRef: ChangeDetectorRef, |
||||
|
private dataService: DataService, |
||||
|
private destroyRef: DestroyRef, |
||||
|
private dialogRef: MatDialogRef<GfManageBudgetCategoriesDialogComponent> |
||||
|
) {} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.fetchCategories(); |
||||
|
} |
||||
|
|
||||
|
public onCancelEdit() { |
||||
|
this.editingCategory = undefined; |
||||
|
this.categoryForm.reset({ |
||||
|
color: '', |
||||
|
name: '' |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onClose() { |
||||
|
this.dialogRef.close({ refresh: true }); |
||||
|
} |
||||
|
|
||||
|
public onDeleteCategory(id: string) { |
||||
|
this.dataService |
||||
|
.deleteExpenseCategory(id) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.fetchCategories(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onEditCategory(category: ExpenseCategoryResponse) { |
||||
|
this.editingCategory = category; |
||||
|
this.categoryForm.setValue({ |
||||
|
color: category.color ?? '', |
||||
|
name: category.name |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onSubmit() { |
||||
|
if (this.categoryForm.invalid) { |
||||
|
this.categoryForm.markAllAsTouched(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const category = this.toCategoryPayload(); |
||||
|
|
||||
|
if (this.editingCategory) { |
||||
|
this.dataService |
||||
|
.updateExpenseCategory({ |
||||
|
category: { |
||||
|
...category, |
||||
|
id: this.editingCategory.id |
||||
|
}, |
||||
|
id: this.editingCategory.id |
||||
|
}) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.onCancelEdit(); |
||||
|
this.fetchCategories(); |
||||
|
}); |
||||
|
} else { |
||||
|
this.dataService |
||||
|
.createExpenseCategory(category) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.onCancelEdit(); |
||||
|
this.fetchCategories(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fetchCategories() { |
||||
|
this.isLoading = true; |
||||
|
|
||||
|
this.dataService |
||||
|
.fetchExpenseCategories() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((categories) => { |
||||
|
this.dataSource = new MatTableDataSource(categories); |
||||
|
this.isLoading = false; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private toCategoryPayload() { |
||||
|
const { color, name } = this.categoryForm.getRawValue(); |
||||
|
|
||||
|
return { |
||||
|
color: color || undefined, |
||||
|
name |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,98 @@ |
|||||
|
<h2 i18n mat-dialog-title>Manage categories</h2> |
||||
|
|
||||
|
<mat-dialog-content> |
||||
|
<form |
||||
|
class="category-form" |
||||
|
[formGroup]="categoryForm" |
||||
|
(ngSubmit)="onSubmit()" |
||||
|
> |
||||
|
<mat-form-field appearance="outline"> |
||||
|
<mat-label i18n>Name</mat-label> |
||||
|
<input formControlName="name" matInput /> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<mat-form-field appearance="outline"> |
||||
|
<mat-label i18n>Color</mat-label> |
||||
|
<input formControlName="color" matInput placeholder="#0055aa" /> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<div class="category-form-actions"> |
||||
|
@if (editingCategory) { |
||||
|
<button i18n mat-button type="button" (click)="onCancelEdit()"> |
||||
|
Cancel edit |
||||
|
</button> |
||||
|
} |
||||
|
|
||||
|
<button |
||||
|
color="primary" |
||||
|
i18n |
||||
|
mat-flat-button |
||||
|
type="submit" |
||||
|
[disabled]="categoryForm.invalid" |
||||
|
> |
||||
|
@if (editingCategory) { |
||||
|
Update |
||||
|
} @else { |
||||
|
Create |
||||
|
} |
||||
|
</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
|
||||
|
@if (dataSource.data.length > 0) { |
||||
|
<table class="gf-table w-100" mat-table [dataSource]="dataSource"> |
||||
|
<ng-container matColumnDef="name"> |
||||
|
<th *matHeaderCellDef i18n mat-header-cell>Name</th> |
||||
|
<td *matCellDef="let category" mat-cell>{{ category.name }}</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="color"> |
||||
|
<th *matHeaderCellDef i18n mat-header-cell>Color</th> |
||||
|
<td *matCellDef="let category" mat-cell> |
||||
|
@if (category.color) { |
||||
|
<span class="category-color"> |
||||
|
<span |
||||
|
class="category-color-swatch" |
||||
|
[style.background-color]="category.color" |
||||
|
></span> |
||||
|
{{ category.color }} |
||||
|
</span> |
||||
|
} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="actions"> |
||||
|
<th *matHeaderCellDef class="text-right" mat-header-cell></th> |
||||
|
<td *matCellDef="let category" class="text-right" mat-cell> |
||||
|
<button |
||||
|
mat-icon-button |
||||
|
type="button" |
||||
|
[attr.aria-label]="'Edit category'" |
||||
|
(click)="onEditCategory(category)" |
||||
|
> |
||||
|
<mat-icon>edit</mat-icon> |
||||
|
</button> |
||||
|
<button |
||||
|
mat-icon-button |
||||
|
type="button" |
||||
|
[attr.aria-label]="'Delete category'" |
||||
|
(click)="onDeleteCategory(category.id)" |
||||
|
> |
||||
|
<mat-icon>delete</mat-icon> |
||||
|
</button> |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> |
||||
|
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> |
||||
|
</table> |
||||
|
} @else if (!isLoading) { |
||||
|
<div class="py-4 text-center text-muted" i18n> |
||||
|
No categories have been created. |
||||
|
</div> |
||||
|
} |
||||
|
</mat-dialog-content> |
||||
|
|
||||
|
<mat-dialog-actions align="end"> |
||||
|
<button i18n mat-button type="button" (click)="onClose()">Close</button> |
||||
|
</mat-dialog-actions> |
||||
@ -0,0 +1,35 @@ |
|||||
|
.category-form { |
||||
|
display: grid; |
||||
|
gap: 1rem; |
||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 10rem) auto; |
||||
|
} |
||||
|
|
||||
|
.category-form-actions { |
||||
|
align-items: center; |
||||
|
display: flex; |
||||
|
gap: 0.5rem; |
||||
|
justify-content: flex-end; |
||||
|
} |
||||
|
|
||||
|
.category-color { |
||||
|
align-items: center; |
||||
|
display: inline-flex; |
||||
|
gap: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
.category-color-swatch { |
||||
|
border: 1px solid var(--border-color); |
||||
|
display: inline-block; |
||||
|
height: 1rem; |
||||
|
width: 1rem; |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 767.98px) { |
||||
|
.category-form { |
||||
|
grid-template-columns: 1fr; |
||||
|
} |
||||
|
|
||||
|
.category-form-actions { |
||||
|
justify-content: flex-start; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue