mirror of https://github.com/ghostfolio/ghostfolio
7 changed files with 348 additions and 2 deletions
@ -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 }); |
|||
}); |
|||
}); |
|||
@ -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 }); |
|||
}); |
|||
} |
|||
} |
|||
@ -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> |
|||
@ -0,0 +1,5 @@ |
|||
.budget-dialog-content { |
|||
display: grid; |
|||
gap: 0.5rem; |
|||
min-width: min(26rem, 80vw); |
|||
} |
|||
Loading…
Reference in new issue