Skip to content

Commit cdf4712

Browse files
CopilotPhantomDave
andcommitted
Fix SnackbarService import and add validation for empty Title/Subtitle in AddWidget
Co-authored-by: PhantomDave <34485699+PhantomDave@users.noreply.github.com>
1 parent acd6dda commit cdf4712

6 files changed

Lines changed: 425 additions & 3 deletions

File tree

PhantomDave.BankTracking.Api/Types/Mutations/DashboardWidgetMutations.cs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,36 @@ public async Task<DashboardWidgetType> AddWidget(
3838
.Build());
3939
}
4040

41+
string? title = null;
42+
if (input.Title is not null)
43+
{
44+
var trimmedTitle = input.Title.Trim();
45+
if (string.IsNullOrEmpty(trimmedTitle))
46+
{
47+
throw new GraphQLException(
48+
ErrorBuilder.New()
49+
.SetMessage("Widget title cannot be empty or whitespace.")
50+
.SetCode("BAD_USER_INPUT")
51+
.Build());
52+
}
53+
title = trimmedTitle;
54+
}
55+
56+
string? subtitle = null;
57+
if (input.Subtitle is not null)
58+
{
59+
var trimmedSubtitle = input.Subtitle.Trim();
60+
if (string.IsNullOrEmpty(trimmedSubtitle))
61+
{
62+
throw new GraphQLException(
63+
ErrorBuilder.New()
64+
.SetMessage("Widget subtitle cannot be empty or whitespace.")
65+
.SetCode("BAD_USER_INPUT")
66+
.Build());
67+
}
68+
subtitle = trimmedSubtitle;
69+
}
70+
4171
var widget = new DashboardWidget
4272
{
4373
DashboardId = input.DashboardId,
@@ -46,8 +76,8 @@ public async Task<DashboardWidgetType> AddWidget(
4676
Y = Math.Max(0, input.Y),
4777
Rows = input.Rows,
4878
Cols = input.Cols,
49-
Title = input.Title?.Trim(),
50-
Subtitle = input.Subtitle?.Trim(),
79+
Title = title,
80+
Subtitle = subtitle,
5181
Config = input.Config
5282
};
5383

PhantomDave.BankTracking.UnitTests/Mutations/DashboardWidgetMutationsTests.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,64 @@ public async Task AddWidget_WithAllMetadata_StoresAllFields()
195195
Assert.Equal(configJson, result.Config);
196196
}
197197

198+
[Fact]
199+
public async Task AddWidget_WithEmptyTitle_ThrowsException()
200+
{
201+
// Arrange
202+
var dashboard = new Dashboard { Id = 1, Name = "Test", AccountId = 1 };
203+
var account = new Account { Id = 1 };
204+
205+
_mockAccountRepository.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(account);
206+
_mockDashboardRepository.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(dashboard);
207+
208+
var input = new AddWidgetInput
209+
{
210+
DashboardId = 1,
211+
Type = WidgetType.NetGraph,
212+
X = 0,
213+
Y = 0,
214+
Rows = 2,
215+
Cols = 2,
216+
Title = " "
217+
};
218+
219+
// Act & Assert
220+
var exception = await Assert.ThrowsAsync<GraphQLException>(
221+
async () => await _mutations.AddWidget(_mockUnitOfWork.Object, _mockHttpContextAccessor.Object, input)
222+
);
223+
224+
Assert.Contains("title cannot be empty", exception.Message.ToLower());
225+
}
226+
227+
[Fact]
228+
public async Task AddWidget_WithWhitespaceOnlySubtitle_ThrowsException()
229+
{
230+
// Arrange
231+
var dashboard = new Dashboard { Id = 1, Name = "Test", AccountId = 1 };
232+
var account = new Account { Id = 1 };
233+
234+
_mockAccountRepository.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(account);
235+
_mockDashboardRepository.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(dashboard);
236+
237+
var input = new AddWidgetInput
238+
{
239+
DashboardId = 1,
240+
Type = WidgetType.NetGraph,
241+
X = 0,
242+
Y = 0,
243+
Rows = 2,
244+
Cols = 2,
245+
Subtitle = "\t\n "
246+
};
247+
248+
// Act & Assert
249+
var exception = await Assert.ThrowsAsync<GraphQLException>(
250+
async () => await _mutations.AddWidget(_mockUnitOfWork.Object, _mockHttpContextAccessor.Object, input)
251+
);
252+
253+
Assert.Contains("subtitle cannot be empty", exception.Message.ToLower());
254+
}
255+
198256
[Fact]
199257
public async Task UpdateWidget_WithTitle_TrimsAndUpdatesTitle()
200258
{
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { DashboardComponent } from './dashboard-component.component';
3+
import { WidgetType } from '../../../../generated/graphql';
4+
import { SnackbarService } from '../../../services/snackbar-service';
5+
6+
describe('DashboardComponent', () => {
7+
let component: DashboardComponent;
8+
let fixture: ComponentFixture<DashboardComponent>;
9+
let snackbarService: jasmine.SpyObj<SnackbarService>;
10+
11+
beforeEach(async () => {
12+
const snackbarServiceSpy = jasmine.createSpyObj('SnackbarService', ['success', 'error']);
13+
14+
await TestBed.configureTestingModule({
15+
imports: [DashboardComponent],
16+
providers: [{ provide: SnackbarService, useValue: snackbarServiceSpy }],
17+
}).compileComponents();
18+
19+
fixture = TestBed.createComponent(DashboardComponent);
20+
component = fixture.componentInstance;
21+
snackbarService = TestBed.inject(SnackbarService) as jasmine.SpyObj<SnackbarService>;
22+
fixture.detectChanges();
23+
});
24+
25+
it('should create', () => {
26+
expect(component).toBeTruthy();
27+
});
28+
29+
describe('onWidgetSelected', () => {
30+
it('should create and add a NetGraph widget to the dashboard', () => {
31+
const initialWidgetCount = component.widgets().length;
32+
33+
component.onWidgetSelected(WidgetType.NET_GRAPH);
34+
35+
expect(component.widgets().length).toBe(initialWidgetCount + 1);
36+
const addedWidget = component.widgets()[initialWidgetCount];
37+
expect(addedWidget.type).toBe(WidgetType.NET_GRAPH);
38+
});
39+
40+
it('should create and add a CurrentBalance widget to the dashboard', () => {
41+
const initialWidgetCount = component.widgets().length;
42+
43+
component.onWidgetSelected(WidgetType.CURRENT_BALANCE);
44+
45+
expect(component.widgets().length).toBe(initialWidgetCount + 1);
46+
const addedWidget = component.widgets()[initialWidgetCount];
47+
expect(addedWidget.type).toBe(WidgetType.CURRENT_BALANCE);
48+
});
49+
50+
it('should show success snackbar when widget is added successfully', () => {
51+
component.onWidgetSelected(WidgetType.NET_GRAPH);
52+
53+
expect(snackbarService.success).toHaveBeenCalledWith('Added Net Graph widget to dashboard.');
54+
});
55+
56+
it('should show success snackbar with correct widget name for CurrentBalance', () => {
57+
component.onWidgetSelected(WidgetType.CURRENT_BALANCE);
58+
59+
expect(snackbarService.success).toHaveBeenCalledWith('Added Remaining Budget widget to dashboard.');
60+
});
61+
62+
it('should show error snackbar if widget creation fails', () => {
63+
spyOn(console, 'error'); // Suppress console output in tests
64+
const invalidWidgetType = 'INVALID_TYPE' as WidgetType;
65+
66+
component.onWidgetSelected(invalidWidgetType);
67+
68+
expect(snackbarService.error).toHaveBeenCalledWith('Failed to add widget to dashboard.');
69+
});
70+
71+
it('should not add widget to list if creation fails', () => {
72+
spyOn(console, 'error'); // Suppress console output in tests
73+
const invalidWidgetType = 'INVALID_TYPE' as WidgetType;
74+
const initialWidgetCount = component.widgets().length;
75+
76+
component.onWidgetSelected(invalidWidgetType);
77+
78+
expect(component.widgets().length).toBe(initialWidgetCount);
79+
});
80+
81+
it('should preserve existing widgets when adding new one', () => {
82+
component.onWidgetSelected(WidgetType.NET_GRAPH);
83+
const firstWidget = component.widgets()[0];
84+
85+
component.onWidgetSelected(WidgetType.CURRENT_BALANCE);
86+
87+
expect(component.widgets().length).toBe(2);
88+
expect(component.widgets()[0]).toBe(firstWidget);
89+
expect(component.widgets()[1].type).toBe(WidgetType.CURRENT_BALANCE);
90+
});
91+
92+
it('should add multiple widgets of the same type', () => {
93+
component.onWidgetSelected(WidgetType.NET_GRAPH);
94+
component.onWidgetSelected(WidgetType.NET_GRAPH);
95+
96+
expect(component.widgets().length).toBe(2);
97+
expect(component.widgets()[0].type).toBe(WidgetType.NET_GRAPH);
98+
expect(component.widgets()[1].type).toBe(WidgetType.NET_GRAPH);
99+
});
100+
});
101+
102+
describe('widget configuration', () => {
103+
it('should create NetGraph widget with default configuration', () => {
104+
component.onWidgetSelected(WidgetType.NET_GRAPH);
105+
106+
const widget = component.widgets()[0];
107+
expect(widget.config).toBeDefined();
108+
109+
const config = widget.getTypedConfig();
110+
expect(config).toBeDefined();
111+
expect(config?.title).toBe('Net Graph');
112+
expect(config?.from).toBeDefined();
113+
expect(config?.to).toBeDefined();
114+
});
115+
116+
it('should create CurrentBalance widget with default configuration', () => {
117+
component.onWidgetSelected(WidgetType.CURRENT_BALANCE);
118+
119+
const widget = component.widgets()[0];
120+
expect(widget.config).toBeDefined();
121+
122+
const config = widget.getTypedConfig();
123+
expect(config).toBeDefined();
124+
expect(config?.title).toBe('Current Balance');
125+
});
126+
});
127+
128+
describe('edit mode', () => {
129+
it('should start with edit mode disabled', () => {
130+
expect(component.isEditMode()).toBe(false);
131+
});
132+
});
133+
});

frontend/src/app/components/dashboards/dashboard-component/dashboard-component.component.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { DashboardDrawerComponent } from '../dashboard-drawer-component/dashboar
1818
import { WidgetType } from '../../../../generated/graphql';
1919
import { Widget } from '../../../models/dashboards/gridster-item';
2020
import { WidgetFactory } from '../widgets/widget-factory';
21-
import { SnackbarService } from '../../../services/snackbar-service';
21+
import { SnackbarService } from '../../../shared/services/snackbar.service';
2222

2323
@Component({
2424
standalone: true,
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { DashboardDrawerComponent } from './dashboard-drawer-component.component';
3+
import { WidgetType } from '../../../../generated/graphql';
4+
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
5+
6+
describe('DashboardDrawerComponent', () => {
7+
let component: DashboardDrawerComponent;
8+
let fixture: ComponentFixture<DashboardDrawerComponent>;
9+
10+
beforeEach(async () => {
11+
await TestBed.configureTestingModule({
12+
imports: [DashboardDrawerComponent, NoopAnimationsModule],
13+
}).compileComponents();
14+
15+
fixture = TestBed.createComponent(DashboardDrawerComponent);
16+
component = fixture.componentInstance;
17+
fixture.detectChanges();
18+
});
19+
20+
it('should create', () => {
21+
expect(component).toBeTruthy();
22+
});
23+
24+
describe('availableWidgets', () => {
25+
it('should include all widget types', () => {
26+
const widgetTypes = component.availableWidgets.map((w) => w.type);
27+
28+
expect(widgetTypes).toContain(WidgetType.NET_GRAPH);
29+
expect(widgetTypes).toContain(WidgetType.CURRENT_BALANCE);
30+
});
31+
32+
it('should have display names for all widgets', () => {
33+
component.availableWidgets.forEach((widget) => {
34+
expect(widget.name).toBeTruthy();
35+
expect(widget.name).not.toBe('');
36+
});
37+
});
38+
39+
it('should have correct display name for Net Graph', () => {
40+
const netGraphWidget = component.availableWidgets.find((w) => w.type === WidgetType.NET_GRAPH);
41+
42+
expect(netGraphWidget?.name).toBe('Net Graph');
43+
});
44+
45+
it('should have correct display name for Current Balance', () => {
46+
const currentBalanceWidget = component.availableWidgets.find(
47+
(w) => w.type === WidgetType.CURRENT_BALANCE
48+
);
49+
50+
expect(currentBalanceWidget?.name).toBe('Remaining Budget');
51+
});
52+
});
53+
54+
describe('addWidgetToDashboard', () => {
55+
it('should emit widgetSelected event with correct widget type', (done) => {
56+
const widget = { type: WidgetType.NET_GRAPH, name: 'Net Graph' };
57+
58+
component.widgetSelected.subscribe((type) => {
59+
expect(type).toBe(WidgetType.NET_GRAPH);
60+
done();
61+
});
62+
63+
component.addWidgetToDashboard(widget);
64+
});
65+
66+
it('should emit widgetSelected for CurrentBalance widget', (done) => {
67+
const widget = { type: WidgetType.CURRENT_BALANCE, name: 'Remaining Budget' };
68+
69+
component.widgetSelected.subscribe((type) => {
70+
expect(type).toBe(WidgetType.CURRENT_BALANCE);
71+
done();
72+
});
73+
74+
component.addWidgetToDashboard(widget);
75+
});
76+
77+
it('should not show snackbar when adding widget', () => {
78+
const widget = { type: WidgetType.NET_GRAPH, name: 'Net Graph' };
79+
80+
// Should not throw or call snackbar service
81+
expect(() => component.addWidgetToDashboard(widget)).not.toThrow();
82+
});
83+
});
84+
85+
describe('onDrawerClosed', () => {
86+
it('should emit closed event', (done) => {
87+
component.closed.subscribe(() => {
88+
expect(true).toBe(true);
89+
done();
90+
});
91+
92+
component.onDrawerClosed();
93+
});
94+
});
95+
96+
describe('drawer state', () => {
97+
it('should accept opened input', () => {
98+
fixture.componentRef.setInput('opened', true);
99+
fixture.detectChanges();
100+
101+
expect(component.opened()).toBe(true);
102+
});
103+
104+
it('should handle closed state', () => {
105+
fixture.componentRef.setInput('opened', false);
106+
fixture.detectChanges();
107+
108+
expect(component.opened()).toBe(false);
109+
});
110+
});
111+
});

0 commit comments

Comments
 (0)