Browse Source

feat: add budget editor dialog

pull/7140/head
Yip Shing him 3 weeks ago
parent
commit
ccd944ea41
  1. 36
      apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts
  2. 48
      apps/client/src/app/pages/portfolio/budget/budget-page.component.ts
  3. 33
      apps/client/src/app/pages/portfolio/budget/budget-page.html
  4. 90
      apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts
  5. 93
      apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts
  6. 45
      apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html
  7. 5
      apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.scss

36
apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts

@ -2,6 +2,7 @@ import { DataService } from '@ghostfolio/ui/services';
import { provideHttpClient } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { of } from 'rxjs';
@ -45,11 +46,15 @@ const {
const { GfBudgetPageComponent } = require('./budget-page.component');
describe('GfBudgetPageComponent', () => {
let dataService: jest.Mocked<Pick<DataService, 'fetchBudgets'>>;
let dataService: jest.Mocked<
Pick<DataService, 'deleteBudget' | 'fetchBudgets'>
>;
let dialog: jest.Mocked<Pick<MatDialog, 'open'>>;
let fixture: ComponentFixture<GfBudgetPageComponent>;
beforeEach(async () => {
dataService = {
deleteBudget: jest.fn().mockReturnValue(of(undefined)),
fetchBudgets: jest.fn().mockReturnValue(
of({
budgets: [
@ -75,6 +80,11 @@ describe('GfBudgetPageComponent', () => {
})
)
};
dialog = {
open: jest.fn().mockReturnValue({
afterClosed: () => of({ refresh: true })
})
};
await TestBed.configureTestingModule({
imports: [GfBudgetPageComponent, NoopAnimationsModule],
@ -84,6 +94,10 @@ describe('GfBudgetPageComponent', () => {
provide: DataService,
useValue: dataService
},
{
provide: MatDialog,
useValue: dialog
},
{
provide: UserService,
useValue: {
@ -114,4 +128,24 @@ describe('GfBudgetPageComponent', () => {
expect(fixture.nativeElement.textContent).toContain('Budget');
expect(fixture.nativeElement.textContent).toContain('Food');
});
it('reloads budgets after creating a budget', async () => {
await fixture.whenStable();
fixture.componentInstance.onCreateBudget();
await fixture.whenStable();
expect(dialog.open).toHaveBeenCalled();
expect(dataService.fetchBudgets).toHaveBeenCalledTimes(2);
});
it('deletes a budget and reloads the month', async () => {
await fixture.whenStable();
fixture.componentInstance.onDeleteBudget('budget-1');
await fixture.whenStable();
expect(dataService.deleteBudget).toHaveBeenCalledWith('budget-1');
expect(dataService.fetchBudgets).toHaveBeenCalledTimes(2);
});
});

48
apps/client/src/app/pages/portfolio/budget/budget-page.component.ts

@ -12,15 +12,22 @@ import {
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { format } from 'date-fns';
import { GfCreateOrUpdateBudgetDialogComponent } from './create-or-update-budget-dialog/create-or-update-budget-dialog.component';
@Component({
host: { class: 'page' },
imports: [
CommonModule,
GfValueComponent,
MatButtonModule,
MatIconModule,
MatProgressBarModule,
MatTableModule,
ReactiveFormsModule
@ -36,7 +43,8 @@ export class GfBudgetPageComponent implements OnInit {
'amount',
'spent',
'remaining',
'progress'
'progress',
'actions'
];
public isLoading = true;
public monthControl = new FormControl(format(new Date(), 'yyyy-MM'), {
@ -51,6 +59,7 @@ export class GfBudgetPageComponent implements OnInit {
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private dialog: MatDialog,
private userService: UserService
) {}
@ -96,4 +105,41 @@ export class GfBudgetPageComponent implements OnInit {
return Math.min((spent / amount) * 100, 100);
}
public onCreateBudget() {
this.openBudgetDialog({
month: this.monthControl.value
});
}
public onDeleteBudget(id: string) {
this.dataService
.deleteBudget(id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.fetchBudgets();
});
}
public onUpdateBudget(budget: BudgetResponse) {
this.openBudgetDialog({
budget,
month: this.monthControl.value
});
}
private openBudgetDialog(data: { budget?: BudgetResponse; month: string }) {
this.dialog
.open(GfCreateOrUpdateBudgetDialogComponent, {
data,
width: '32rem'
})
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (result?.refresh) {
this.fetchBudgets();
}
});
}
}

33
apps/client/src/app/pages/portfolio/budget/budget-page.html

@ -104,6 +104,28 @@
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="text-right" mat-header-cell></th>
<td *matCellDef="let budget" class="text-right" mat-cell>
<button
mat-icon-button
type="button"
[attr.aria-label]="'Edit budget'"
(click)="onUpdateBudget(budget)"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
type="button"
[attr.aria-label]="'Delete budget'"
(click)="onDeleteBudget(budget.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>
@ -112,6 +134,17 @@
No budgets have been created for this month.
</div>
}
<button
class="mt-3"
color="primary"
i18n
mat-flat-button
type="button"
(click)="onCreateBudget()"
>
Create budget
</button>
</div>
</div>
</div>

90
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts

@ -0,0 +1,90 @@
import { DataService } from '@ghostfolio/ui/services';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { of } from 'rxjs';
import { GfCreateOrUpdateBudgetDialogComponent } from './create-or-update-budget-dialog.component';
describe('GfCreateOrUpdateBudgetDialogComponent', () => {
let component: GfCreateOrUpdateBudgetDialogComponent;
let dataService: jest.Mocked<
Pick<DataService, 'createBudget' | 'updateBudget'>
>;
let dialogRef: jest.Mocked<
Pick<MatDialogRef<GfCreateOrUpdateBudgetDialogComponent>, 'close'>
>;
let fixture: ComponentFixture<GfCreateOrUpdateBudgetDialogComponent>;
beforeEach(async () => {
dataService = {
createBudget: jest.fn().mockReturnValue(
of({
amount: 500,
categoryId: 'food',
id: 'budget-1',
month: '2026-06',
remaining: 500,
spent: 0
})
),
updateBudget: jest.fn().mockReturnValue(
of({
amount: 650,
categoryId: 'food',
id: 'budget-1',
month: '2026-06',
remaining: 650,
spent: 0
})
)
};
dialogRef = {
close: jest.fn()
};
await TestBed.configureTestingModule({
imports: [GfCreateOrUpdateBudgetDialogComponent, NoopAnimationsModule],
providers: [
{
provide: DataService,
useValue: dataService
},
{
provide: MatDialogRef,
useValue: dialogRef
},
{
provide: MAT_DIALOG_DATA,
useValue: {
budget: undefined,
month: '2026-06'
}
}
]
}).compileComponents();
fixture = TestBed.createComponent(GfCreateOrUpdateBudgetDialogComponent);
component = fixture.componentInstance;
fixture.autoDetectChanges();
});
it('creates a budget and closes with refresh', async () => {
component.budgetForm.setValue({
amount: 500,
categoryId: 'food',
month: '2026-06'
});
component.onSubmit();
await fixture.whenStable();
expect(dataService.createBudget).toHaveBeenCalledWith({
amount: 500,
categoryId: 'food',
month: '2026-06'
});
expect(dialogRef.close).toHaveBeenCalledWith({ refresh: true });
});
});

93
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts

@ -0,0 +1,93 @@
import { CreateBudgetDto, UpdateBudgetDto } from '@ghostfolio/common/dtos';
import { BudgetResponse } from '@ghostfolio/common/interfaces';
import { DataService } from '@ghostfolio/ui/services';
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core';
import {
FormControl,
FormGroup,
ReactiveFormsModule,
Validators
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
export interface CreateOrUpdateBudgetDialogData {
budget?: BudgetResponse;
month: string;
}
@Component({
imports: [
CommonModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
],
selector: 'gf-create-or-update-budget-dialog',
styleUrls: ['./create-or-update-budget-dialog.scss'],
templateUrl: './create-or-update-budget-dialog.html'
})
export class GfCreateOrUpdateBudgetDialogComponent {
public budgetForm = new FormGroup({
amount: new FormControl<number>(this.data.budget?.amount ?? 0, {
nonNullable: true,
validators: [Validators.required, Validators.min(0.01)]
}),
categoryId: new FormControl<string>(this.data.budget?.categoryId ?? '', {
nonNullable: true,
validators: [Validators.required]
}),
month: new FormControl<string>(this.data.budget?.month ?? this.data.month, {
nonNullable: true,
validators: [Validators.required]
})
});
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateBudgetDialogData,
private dataService: DataService,
private dialogRef: MatDialogRef<GfCreateOrUpdateBudgetDialogComponent>
) {}
public onSubmit() {
if (this.budgetForm.invalid) {
this.budgetForm.markAllAsTouched();
return;
}
const budget = this.budgetForm.getRawValue();
if (this.data.budget) {
this.updateBudget(budget);
} else {
this.createBudget(budget);
}
}
private createBudget(budget: CreateBudgetDto) {
this.dataService.createBudget(budget).subscribe(() => {
this.dialogRef.close({ refresh: true });
});
}
private updateBudget(budget: UpdateBudgetDto) {
this.dataService
.updateBudget({
budget,
id: this.data.budget.id
})
.subscribe(() => {
this.dialogRef.close({ refresh: true });
});
}
}

45
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html

@ -0,0 +1,45 @@
<h2 mat-dialog-title>
@if (data.budget) {
<ng-container i18n>Edit budget</ng-container>
} @else {
<ng-container i18n>Create budget</ng-container>
}
</h2>
<form [formGroup]="budgetForm" (ngSubmit)="onSubmit()">
<mat-dialog-content class="budget-dialog-content">
<mat-form-field appearance="outline">
<mat-label i18n>Month</mat-label>
<input formControlName="month" matInput type="month" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label i18n>Category id</mat-label>
<input formControlName="categoryId" matInput />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label i18n>Amount</mat-label>
<input
formControlName="amount"
matInput
min="0.01"
step="0.01"
type="number"
/>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button i18n mat-button mat-dialog-close type="button">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
type="submit"
[disabled]="budgetForm.invalid"
>
Save
</button>
</mat-dialog-actions>
</form>

5
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.scss

@ -0,0 +1,5 @@
.budget-dialog-content {
display: grid;
gap: 0.5rem;
min-width: min(26rem, 80vw);
}
Loading…
Cancel
Save