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