Skip to content

Commit 240cb92

Browse files
guguclaude
andcommitted
feat: add dashboards feature with draggable chart widgets
- Add dashboards list page with search, create, edit, delete functionality - Add dashboard view with Gridster-based drag/resize grid for charts - Add chart widget renderer using ng2-charts (bar, line, pie, doughnut, polar) - Add widget edit dialog for adding charts linked to saved queries - Add dashboards service with rxResource for reactive data fetching - Replace Charts nav tab with Dashboards tab - Add link to manage saved queries from dashboards page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4db76c6 commit 240cb92

48 files changed

Lines changed: 3027 additions & 9 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@zxcvbn-ts/core": "^3.0.4",
3737
"@zxcvbn-ts/language-en": "^3.0.2",
3838
"amplitude-js": "^8.21.9",
39+
"angular-gridster2": "^21.0.1",
3940
"angular-password-strength-meter": "npm:@eresearchqut/angular-password-strength-meter@^13.0.7",
4041
"angulartics2": "^14.1.0",
4142
"chart.js": "^4.5.1",

frontend/src/app/app-routing.module.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,22 @@ const routes: Routes = [
205205
canActivate: [AuthGuard],
206206
title: 'Edit Query | Rocketadmin',
207207
},
208+
{
209+
path: 'dashboards/:connection-id',
210+
loadComponent: () =>
211+
import('./components/dashboards/dashboards-list/dashboards-list.component').then(
212+
(m) => m.DashboardsListComponent,
213+
),
214+
canActivate: [AuthGuard],
215+
title: 'Dashboards | Rocketadmin',
216+
},
217+
{
218+
path: 'dashboards/:connection-id/:dashboard-id',
219+
loadComponent: () =>
220+
import('./components/dashboards/dashboard-view/dashboard-view.component').then((m) => m.DashboardViewComponent),
221+
canActivate: [AuthGuard],
222+
title: 'Dashboard | Rocketadmin',
223+
},
208224
{
209225
path: '**',
210226
loadComponent: () =>

frontend/src/app/app.component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,8 @@ export class AppComponent {
194194
permissions: {
195195
caption: 'Permissions',
196196
},
197-
charts: {
198-
caption: 'Charts',
197+
dashboards: {
198+
caption: 'Dashboards',
199199
},
200200
'connection-settings': {
201201
caption: 'Connection settings',
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
.warning-container {
2+
display: flex;
3+
gap: 16px;
4+
align-items: flex-start;
5+
}
6+
7+
.warning-icon {
8+
color: #f57c00;
9+
font-size: 48px;
10+
width: 48px;
11+
height: 48px;
12+
flex-shrink: 0;
13+
}
14+
15+
.warning-content p {
16+
margin: 0 0 8px 0;
17+
}
18+
19+
.warning-details {
20+
color: rgba(0, 0, 0, 0.6);
21+
font-size: 14px;
22+
}
23+
24+
@media (prefers-color-scheme: dark) {
25+
.warning-details {
26+
color: rgba(255, 255, 255, 0.6);
27+
}
28+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<h2 mat-dialog-title>Delete Dashboard</h2>
2+
3+
<mat-dialog-content>
4+
<div class="warning-container">
5+
<mat-icon class="warning-icon">warning</mat-icon>
6+
<div class="warning-content">
7+
<p>Are you sure you want to delete the dashboard <strong>{{data.dashboard.name}}</strong>?</p>
8+
<p class="warning-details">
9+
This action cannot be undone. The dashboard and all its widgets will be permanently removed.
10+
</p>
11+
</div>
12+
</div>
13+
</mat-dialog-content>
14+
15+
<mat-dialog-actions align="end">
16+
<button mat-button mat-dialog-close [disabled]="submitting()">Cancel</button>
17+
<button mat-flat-button color="warn"
18+
(click)="onDelete()"
19+
[disabled]="submitting()"
20+
data-testid="delete-dashboard-confirm-button">
21+
{{submitting() ? 'Deleting...' : 'Delete Dashboard'}}
22+
</button>
23+
</mat-dialog-actions>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { provideHttpClient } from '@angular/common/http';
2+
import { provideHttpClientTesting } from '@angular/common/http/testing';
3+
import { ComponentFixture, TestBed } from '@angular/core/testing';
4+
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
5+
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
6+
import { Angulartics2Module } from 'angulartics2';
7+
import { DashboardsService } from 'src/app/services/dashboards.service';
8+
import { DashboardDeleteDialogComponent } from './dashboard-delete-dialog.component';
9+
10+
describe('DashboardDeleteDialogComponent', () => {
11+
let component: DashboardDeleteDialogComponent;
12+
let fixture: ComponentFixture<DashboardDeleteDialogComponent>;
13+
let mockDashboardsService: Partial<DashboardsService>;
14+
15+
beforeEach(async () => {
16+
mockDashboardsService = {
17+
deleteDashboard: vi.fn(),
18+
} as Partial<DashboardsService>;
19+
20+
await TestBed.configureTestingModule({
21+
imports: [DashboardDeleteDialogComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()],
22+
providers: [
23+
provideHttpClient(),
24+
provideHttpClientTesting(),
25+
{
26+
provide: MAT_DIALOG_DATA,
27+
useValue: {
28+
connectionId: 'test-conn',
29+
dashboard: { id: 'test-id', name: 'Test Dashboard' },
30+
},
31+
},
32+
{ provide: MatDialogRef, useValue: { close: vi.fn() } },
33+
{ provide: DashboardsService, useValue: mockDashboardsService },
34+
],
35+
}).compileComponents();
36+
37+
fixture = TestBed.createComponent(DashboardDeleteDialogComponent);
38+
component = fixture.componentInstance;
39+
fixture.detectChanges();
40+
});
41+
42+
it('should create', () => {
43+
expect(component).toBeTruthy();
44+
});
45+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { CommonModule } from '@angular/common';
2+
import { Component, Inject, signal } from '@angular/core';
3+
import { MatButtonModule } from '@angular/material/button';
4+
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
5+
import { MatIconModule } from '@angular/material/icon';
6+
import { Angulartics2 } from 'angulartics2';
7+
import { Dashboard } from 'src/app/models/dashboard';
8+
import { DashboardsService } from 'src/app/services/dashboards.service';
9+
10+
@Component({
11+
selector: 'app-dashboard-delete-dialog',
12+
templateUrl: './dashboard-delete-dialog.component.html',
13+
styleUrls: ['./dashboard-delete-dialog.component.css'],
14+
imports: [CommonModule, MatDialogModule, MatButtonModule, MatIconModule],
15+
})
16+
export class DashboardDeleteDialogComponent {
17+
protected submitting = signal(false);
18+
19+
constructor(
20+
@Inject(MAT_DIALOG_DATA) public data: { dashboard: Dashboard; connectionId: string },
21+
private dialogRef: MatDialogRef<DashboardDeleteDialogComponent>,
22+
private _dashboards: DashboardsService,
23+
private angulartics2: Angulartics2,
24+
) {}
25+
26+
onDelete(): void {
27+
this.submitting.set(true);
28+
this._dashboards.deleteDashboard(this.data.connectionId, this.data.dashboard.id).subscribe({
29+
next: () => {
30+
this.angulartics2.eventTrack.next({
31+
action: 'Dashboards: dashboard deleted successfully',
32+
});
33+
this.submitting.set(false);
34+
this.dialogRef.close(true);
35+
},
36+
error: () => {
37+
this.submitting.set(false);
38+
},
39+
});
40+
}
41+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
.dashboard-form {
2+
display: flex;
3+
flex-direction: column;
4+
gap: 8px;
5+
min-width: 400px;
6+
}
7+
8+
@media (width <= 600px) {
9+
.dashboard-form {
10+
min-width: auto;
11+
}
12+
}
13+
14+
.full-width {
15+
width: 100%;
16+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<h2 mat-dialog-title>{{ isEdit ? 'Edit Dashboard' : 'Create Dashboard' }}</h2>
2+
3+
<mat-dialog-content>
4+
<form [formGroup]="form" class="dashboard-form">
5+
<mat-form-field appearance="outline" class="full-width">
6+
<mat-label>Name</mat-label>
7+
<input matInput
8+
formControlName="name"
9+
placeholder="Enter dashboard name"
10+
data-testid="dashboard-name-input">
11+
@if (form.get('name')?.hasError('required')) {
12+
<mat-error>Name is required</mat-error>
13+
}
14+
@if (form.get('name')?.hasError('maxlength')) {
15+
<mat-error>Name must be less than 255 characters</mat-error>
16+
}
17+
</mat-form-field>
18+
19+
<mat-form-field appearance="outline" class="full-width">
20+
<mat-label>Description</mat-label>
21+
<textarea matInput
22+
formControlName="description"
23+
placeholder="Enter dashboard description (optional)"
24+
rows="3"
25+
data-testid="dashboard-description-input"></textarea>
26+
@if (form.get('description')?.hasError('maxlength')) {
27+
<mat-error>Description must be less than 1000 characters</mat-error>
28+
}
29+
</mat-form-field>
30+
</form>
31+
</mat-dialog-content>
32+
33+
<mat-dialog-actions align="end">
34+
<button mat-button mat-dialog-close [disabled]="submitting()">Cancel</button>
35+
<button mat-flat-button color="primary"
36+
(click)="onSubmit()"
37+
[disabled]="submitting() || form.invalid"
38+
data-testid="dashboard-save-button">
39+
{{ submitting() ? 'Saving...' : (isEdit ? 'Update' : 'Create') }}
40+
</button>
41+
</mat-dialog-actions>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { provideHttpClient } from '@angular/common/http';
2+
import { provideHttpClientTesting } from '@angular/common/http/testing';
3+
import { ComponentFixture, TestBed } from '@angular/core/testing';
4+
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
5+
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
6+
import { Angulartics2Module } from 'angulartics2';
7+
import { DashboardsService } from 'src/app/services/dashboards.service';
8+
import { DashboardEditDialogComponent } from './dashboard-edit-dialog.component';
9+
10+
describe('DashboardEditDialogComponent', () => {
11+
let component: DashboardEditDialogComponent;
12+
let fixture: ComponentFixture<DashboardEditDialogComponent>;
13+
let mockDashboardsService: Partial<DashboardsService>;
14+
15+
beforeEach(async () => {
16+
mockDashboardsService = {
17+
createDashboard: vi.fn(),
18+
updateDashboard: vi.fn(),
19+
} as Partial<DashboardsService>;
20+
21+
await TestBed.configureTestingModule({
22+
imports: [DashboardEditDialogComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()],
23+
providers: [
24+
provideHttpClient(),
25+
provideHttpClientTesting(),
26+
{ provide: MAT_DIALOG_DATA, useValue: { connectionId: 'test-conn', dashboard: null } },
27+
{ provide: MatDialogRef, useValue: { close: vi.fn() } },
28+
{ provide: DashboardsService, useValue: mockDashboardsService },
29+
],
30+
}).compileComponents();
31+
32+
fixture = TestBed.createComponent(DashboardEditDialogComponent);
33+
component = fixture.componentInstance;
34+
fixture.detectChanges();
35+
});
36+
37+
it('should create', () => {
38+
expect(component).toBeTruthy();
39+
});
40+
});

0 commit comments

Comments
 (0)