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